diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 9163171c90248..0000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Bug report -description: File a bug report. If you need help, contact support instead -labels: [needs-triage, bug] -body: - - type: markdown - attributes: - value: | - Need help with your tailnet? [Contact support](https://tailscale.com/contact/support) instead. - Otherwise, please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues) before filing a new one. - - type: textarea - id: what-happened - attributes: - label: What is the issue? - description: What happened? What did you expect to happen? - validations: - required: true - - type: textarea - id: steps - attributes: - label: Steps to reproduce - description: What are the steps you took that hit this issue? - validations: - required: false - - type: textarea - id: changes - attributes: - label: Are there any recent changes that introduced the issue? - description: If so, what are those changes? - validations: - required: false - - type: dropdown - id: os - attributes: - label: OS - description: What OS are you using? You may select more than one. - multiple: true - options: - - Linux - - macOS - - Windows - - iOS - - Android - - Synology - - Other - validations: - required: false - - type: input - id: os-version - attributes: - label: OS version - description: What OS version are you using? - placeholder: e.g., Debian 11.0, macOS Big Sur 11.6, Synology DSM 7 - validations: - required: false - - type: input - id: ts-version - attributes: - label: Tailscale version - description: What Tailscale version are you using? - placeholder: e.g., 1.14.4 - validations: - required: false - - type: textarea - id: other-software - attributes: - label: Other software - description: What [other software](https://github.com/tailscale/tailscale/wiki/OtherSoftwareInterop) (networking, security, etc) are you running? - validations: - required: false - - type: input - id: bug-report - attributes: - label: Bug report - description: Please run [`tailscale bugreport`](https://tailscale.com/kb/1080/cli/?q=Cli#bugreport) and share the bug identifier. The identifier is a random string which allows Tailscale support to locate your account and gives a point to focus on when looking for errors. - placeholder: e.g., BUG-1b7641a16971a9cd75822c0ed8043fee70ae88cf05c52981dc220eb96a5c49a8-20210427151443Z-fbcd4fd3a4b7ad94 - validations: - required: false - - type: markdown - attributes: - value: | - Thanks for filing a bug report! diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 3f4a31534b7d7..0000000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: true -contact_links: - - name: Support - url: https://tailscale.com/contact/support/ - about: Contact us for support - - name: Troubleshooting - url: https://tailscale.com/kb/1023/troubleshooting - about: See the troubleshooting guide for help addressing common issues \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index f7538627483ab..0000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Feature request -description: Propose a new feature -title: "FR: " -labels: [needs-triage, fr] -body: - - type: markdown - attributes: - value: | - Please check if your feature request is [already filed](https://github.com/tailscale/tailscale/issues). - Tell us about your idea! - - type: textarea - id: problem - attributes: - label: What are you trying to do? - description: Tell us about the problem you're trying to solve. - validations: - required: false - - type: textarea - id: solution - attributes: - label: How should we solve this? - description: If you have an idea of how you'd like to see this feature work, let us know. - validations: - required: false - - type: textarea - id: alternative - attributes: - label: What is the impact of not solving this? - description: (How) Are you currently working around the issue? - validations: - required: false - - type: textarea - id: context - attributes: - label: Anything else? - description: Any additional context to share, e.g., links - validations: - required: false - - type: markdown - attributes: - value: | - Thanks for filing a feature request! diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 14c912905363e..0000000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Documentation for this file can be found at: -# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates -version: 2 -updates: - ## Disabled between releases. We reenable it briefly after every - ## stable release, pull in all changes, and close it again so that - ## the tree remains more stable during development and the upstream - ## changes have time to soak before the next release. - # - package-ecosystem: "gomod" - # directory: "/" - # schedule: - # interval: "daily" - # commit-message: - # prefix: "go.mod:" - # open-pull-requests-limit: 100 - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - commit-message: - prefix: ".github:" diff --git a/.github/licenses.tmpl b/.github/licenses.tmpl deleted file mode 100644 index 5fa7e8e81a2c2..0000000000000 --- a/.github/licenses.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -# Tailscale CLI and daemon dependencies - -The following open source dependencies are used to build the [tailscale][] and -[tailscaled][] commands. These are primarily used on Linux and BSD variants as -well as an [option for macOS][]. - -[tailscale]: https://pkg.go.dev/tailscale.com/cmd/tailscale -[tailscaled]: https://pkg.go.dev/tailscale.com/cmd/tailscaled -[option for macOS]: https://tailscale.com/kb/1065/macos-variants/ - -## Go Packages - -Some packages may only be included on certain architectures or operating systems. - -{{ range . }} - - [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}})) -{{- end }} diff --git a/.github/workflows/checklocks.yml b/.github/workflows/checklocks.yml deleted file mode 100644 index 064797c884a60..0000000000000 --- a/.github/workflows/checklocks.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: checklocks - -on: - push: - branches: - - main - pull_request: - paths: - - '**/*.go' - - '.github/workflows/checklocks.yml' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - checklocks: - runs-on: [ ubuntu-latest ] - steps: - - name: Check out code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Build checklocks - run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks - - - name: Run checklocks vet - # TODO(#12625): add more packages as we add annotations - run: |- - ./tool/go vet -vettool=/tmp/checklocks \ - ./envknob \ - ./ipn/store/mem \ - ./net/stun/stuntest \ - ./net/wsconn \ - ./proxymap diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index d9a287be32d8d..0000000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,83 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main, release-branch/* ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - merge_group: - branches: [ main ] - schedule: - - cron: '31 14 * * 5' - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'go' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - # Install a more recent Go that understands modern go.mod content. - - name: Install Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 - with: - go-version-file: go.mod - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 diff --git a/.github/workflows/docker-file-build.yml b/.github/workflows/docker-file-build.yml deleted file mode 100644 index c535755724391..0000000000000 --- a/.github/workflows/docker-file-build.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "Dockerfile build" -on: - push: - branches: - - main - pull_request: - branches: - - "*" -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: "Build Docker image" - run: docker build . diff --git a/.github/workflows/flakehub-publish-tagged.yml b/.github/workflows/flakehub-publish-tagged.yml deleted file mode 100644 index 60fdba91c1247..0000000000000 --- a/.github/workflows/flakehub-publish-tagged.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: update-flakehub - -on: - push: - tags: - - "v[0-9]+.*[02468].[0-9]+" - workflow_dispatch: - inputs: - tag: - description: "The existing tag to publish to FlakeHub" - type: "string" - required: true -jobs: - flakehub-publish: - runs-on: "ubuntu-latest" - permissions: - id-token: "write" - contents: "read" - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}" - - uses: "DeterminateSystems/nix-installer-action@main" - - uses: "DeterminateSystems/flakehub-push@main" - with: - visibility: "public" - tag: "${{ inputs.tag }}" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml deleted file mode 100644 index 6630e8de852ae..0000000000000 --- a/.github/workflows/golangci-lint.yml +++ /dev/null @@ -1,40 +0,0 @@ -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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 - with: - go-version-file: go.mod - cache: false - - - name: golangci-lint - # Note: this is the 'v6.1.0' tag as of 2024-08-21 - uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 - with: - version: v1.60 - - # Show only new issues if it's a pull request. - only-new-issues: true diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml deleted file mode 100644 index 4a5ad54f391e8..0000000000000 --- a/.github/workflows/govulncheck.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: govulncheck - -on: - schedule: - - cron: "0 12 * * *" # 8am EST / 10am PST / 12pm UTC - workflow_dispatch: # allow manual trigger for testing - pull_request: - paths: - - ".github/workflows/govulncheck.yml" - -jobs: - source-scan: - runs-on: ubuntu-latest - - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Install govulncheck - run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest - - - name: Scan source code for known vulnerabilities - run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./... - - - name: Post to slack - if: failure() && github.event_name == 'schedule' - uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0 - env: - SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }} - with: - channel-id: 'C05PXRM304B' - payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Govulncheck failed in ${{ github.repository }}" - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "View results" - }, - "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - } - } - ] - } diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml deleted file mode 100644 index 48b29c6ec02cd..0000000000000 --- a/.github/workflows/installer.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: test installer.sh - -on: - push: - branches: - - "main" - paths: - - scripts/installer.sh - pull_request: - branches: - - "*" - paths: - - scripts/installer.sh - -jobs: - test: - strategy: - # Don't abort the entire matrix if one element fails. - fail-fast: false - # Don't start all of these at once, which could saturate Github workers. - max-parallel: 4 - matrix: - image: - # This is a list of Docker images against which we test our installer. - # If you find that some of these no longer exist, please feel free - # to remove them from the list. - # When adding new images, please only use official ones. - - "debian:oldstable-slim" - - "debian:stable-slim" - - "debian:testing-slim" - - "debian:sid-slim" - - "ubuntu:18.04" - - "ubuntu:20.04" - - "ubuntu:22.04" - - "ubuntu:23.04" - - "elementary/docker:stable" - - "elementary/docker:unstable" - - "parrotsec/core:lts-amd64" - - "parrotsec/core:latest" - - "kalilinux/kali-rolling" - - "kalilinux/kali-dev" - - "oraclelinux:9" - - "oraclelinux:8" - - "fedora:latest" - - "rockylinux:8.7" - - "rockylinux:9" - - "amazonlinux:latest" - - "opensuse/leap:latest" - - "opensuse/tumbleweed:latest" - - "archlinux:latest" - - "alpine:3.14" - - "alpine:latest" - - "alpine:edge" - deps: - # Run all images installing curl as a dependency. - - curl - include: - # Check a few images with wget rather than curl. - - { image: "debian:oldstable-slim", deps: "wget" } - - { image: "debian:sid-slim", deps: "wget" } - - { image: "ubuntu:23.04", deps: "wget" } - # Ubuntu 16.04 also needs apt-transport-https installed. - - { image: "ubuntu:16.04", deps: "curl apt-transport-https" } - - { image: "ubuntu:16.04", deps: "wget apt-transport-https" } - runs-on: ubuntu-latest - container: - image: ${{ matrix.image }} - options: --user root - steps: - - name: install dependencies (pacman) - # Refresh the package databases to ensure that the tailscale package is - # defined. - run: pacman -Sy - if: contains(matrix.image, 'archlinux') - - name: install dependencies (yum) - # tar and gzip are needed by the actions/checkout below. - run: yum install -y --allowerasing tar gzip ${{ matrix.deps }} - if: | - contains(matrix.image, 'centos') - || contains(matrix.image, 'oraclelinux') - || contains(matrix.image, 'fedora') - || contains(matrix.image, 'amazonlinux') - - name: install dependencies (zypper) - # tar and gzip are needed by the actions/checkout below. - run: zypper --non-interactive install tar gzip ${{ matrix.deps }} - if: contains(matrix.image, 'opensuse') - - name: install dependencies (apt-get) - run: | - apt-get update - apt-get install -y ${{ matrix.deps }} - if: | - contains(matrix.image, 'debian') - || contains(matrix.image, 'ubuntu') - || contains(matrix.image, 'elementary') - || contains(matrix.image, 'parrotsec') - || contains(matrix.image, 'kalilinux') - - name: checkout - # We cannot use v4, as it requires a newer glibc version than some of the - # tested images provide. See - # https://github.com/actions/checkout/issues/1487 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - name: run installer - run: scripts/installer.sh - # Package installation can fail in docker because systemd is not running - # as PID 1, so ignore errors at this step. The real check is the - # `tailscale --version` command below. - continue-on-error: true - - name: check tailscale version - run: tailscale --version diff --git a/.github/workflows/kubemanifests.yaml b/.github/workflows/kubemanifests.yaml deleted file mode 100644 index f943ccb524f35..0000000000000 --- a/.github/workflows/kubemanifests.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: "Kubernetes manifests" -on: - pull_request: - paths: - - 'cmd/k8s-operator/**' - - 'k8s-operator/**' - - '.github/workflows/kubemanifests.yaml' - -# Cancel workflow run if there is a newer push to the same PR for which it is -# running -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - testchart: - runs-on: [ ubuntu-latest ] - steps: - - name: Check out code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Build and lint Helm chart - run: | - eval `./tool/go run ./cmd/mkversion` - ./tool/helm package --app-version="${VERSION_SHORT}" --version=${VERSION_SHORT} './cmd/k8s-operator/deploy/chart' - ./tool/helm lint "tailscale-operator-${VERSION_SHORT}.tgz" - - name: Verify that static manifests are up to date - run: | - make kube-generate-all - echo - echo - git diff --name-only --exit-code || (echo "Generated files for Tailscale Kubernetes operator are out of date. Please run 'make kube-generate-all' and commit the diff."; exit 1) diff --git a/.github/workflows/ssh-integrationtest.yml b/.github/workflows/ssh-integrationtest.yml deleted file mode 100644 index a82696307ea4b..0000000000000 --- a/.github/workflows/ssh-integrationtest.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Run the ssh integration tests with `make sshintegrationtest`. -# These tests can also be running locally. -name: "ssh-integrationtest" - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -on: - pull_request: - paths: - - "ssh/**" - - "tempfork/gliderlabs/ssh/**" - - ".github/workflows/ssh-integrationtest" -jobs: - ssh-integrationtest: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Run SSH integration tests - run: | - make sshintegrationtest \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index f9bb5cae2235f..0000000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,606 +0,0 @@ -# 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: - # all PRs on all 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: - race-root-integration: - runs-on: ubuntu-22.04 - strategy: - fail-fast: false # don't abort the entire matrix if one element fails - matrix: - include: - - shard: '1/4' - - shard: '2/4' - - shard: '3/4' - - shard: '4/4' - steps: - - name: checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: build test wrapper - run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper - - name: integration tests as root - run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/ - env: - TS_TEST_SHARD: ${{ matrix.shard }} - - test: - strategy: - fail-fast: false # don't abort the entire matrix if one element fails - matrix: - include: - - goarch: amd64 - coverflags: "-coverprofile=/tmp/coverage.out" - - goarch: amd64 - buildflags: "-race" - shard: '1/3' - - goarch: amd64 - buildflags: "-race" - shard: '2/3' - - goarch: amd64 - buildflags: "-race" - shard: '3/3' - - goarch: "386" # thanks yaml - runs-on: ubuntu-22.04 - steps: - - name: checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Restore Cache - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 - 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') }}-${{ github.run_id }} - restore-keys: | - ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }} - ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2- - - name: build all - if: matrix.buildflags == '' # skip on race builder - run: ./tool/go build ${{matrix.buildflags}} ./... - env: - GOARCH: ${{ matrix.goarch }} - - name: build variant CLIs - if: matrix.buildflags == '' # skip on race builder - 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.buildflags == '' - 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: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ${{matrix.coverflags}} ./... ${{matrix.buildflags}} - env: - GOARCH: ${{ matrix.goarch }} - TS_TEST_SHARD: ${{ matrix.shard }} - - name: Publish to coveralls.io - if: matrix.coverflags != '' # only publish results if we've tracked coverage - uses: shogo82148/actions-goveralls@v1 - with: - path-to-profile: /tmp/coverage.out - - name: bench all - run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done) - 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Install Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 - with: - go-version-file: go.mod - cache: false - - - name: Restore Cache - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 - 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') }}-${{ github.run_id }} - restore-keys: | - ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }} - ${{ github.job }}-${{ runner.os }}-go-2- - - name: test - run: go run ./cmd/testwrapper ./... - - name: bench all - # Don't use -bench=. -benchtime=1x. - # Somewhere in the layers (powershell?) - # the equals signs cause great confusion. - run: go test ./... -bench . -benchtime 1x -run "^$" - - privileged: - runs-on: ubuntu-22.04 - container: - image: golang:latest - options: --privileged - steps: - - name: checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: chown - run: chown -R $(id -u):$(id -g) $PWD - - name: privileged tests - run: ./tool/go test ./util/linuxfw ./derp/xdp - - 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Run VM tests - run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004 - env: - HOME: "/var/lib/ghrunner/home" - TMPDIR: "/tmp" - XDG_CACHE_HOME: "/var/lib/ghrunner/cache" - - race-build: - runs-on: ubuntu-22.04 - steps: - - name: checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: build all - run: ./tool/go install -race ./cmd/... - - name: build tests - run: ./tool/go test -race -exec=true ./... - - 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Restore Cache - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 - 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') }}-${{ github.run_id }} - restore-keys: | - ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }} - ${{ 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: build some - run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient - env: - GOOS: ios - GOARCH: arm64 - - crossmin: # cross-compile for platforms where we only check cmd/tailscale{,d} - strategy: - fail-fast: false # don't abort the entire matrix if one element fails - matrix: - include: - # Plan9 - - goos: plan9 - goarch: amd64 - # AIX - - goos: aix - goarch: ppc64 - - runs-on: ubuntu-22.04 - steps: - - name: checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Restore Cache - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 - 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') }}-${{ github.run_id }} - restore-keys: | - ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }} - ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2- - - name: build core - run: ./tool/go build ./cmd/tailscale ./cmd/tailscaled - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - GOARM: ${{ matrix.goarm }} - CGO_ENABLED: "0" - - 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - # 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/netmon ./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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Restore Cache - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 - 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') }}-${{ github.run_id }} - restore-keys: | - ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }} - ${{ 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - 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: Set artifacts_path in env (workaround for actions/upload-artifact#176) - if: steps.run.outcome != 'success' && steps.build.outcome == 'success' - run: | - echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV - - name: upload crash - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 - if: steps.run.outcome != 'success' && steps.build.outcome == 'success' - with: - name: artifacts - path: ${{ env.artifacts_path }}/out/artifacts - - depaware: - runs-on: ubuntu-22.04 - steps: - - name: checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: check that 'go generate' is clean - run: | - pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp') - ./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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - 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: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.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 }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK - - 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@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 - with: - jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml deleted file mode 100644 index f79248c1ed4e9..0000000000000 --- a/.github/workflows/update-flake.yml +++ /dev/null @@ -1,50 +0,0 @@ -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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Run update-flakes - run: ./update-flake.sh - - - name: Get access token - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 - id: generate-token - with: - app_id: ${{ secrets.LICENSING_APP_ID }} - installation_retrieval_mode: "id" - installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }} - private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }} - - - name: Send pull request - uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5 - 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/update-webclient-prebuilt.yml b/.github/workflows/update-webclient-prebuilt.yml deleted file mode 100644 index a0ae95cd77ba4..0000000000000 --- a/.github/workflows/update-webclient-prebuilt.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: update-webclient-prebuilt - -on: - # manually triggered - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - update-webclient-prebuilt: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Run go get - run: | - ./tool/go version # build gocross if needed using regular GOPROXY - GOPROXY=direct ./tool/go get github.com/tailscale/web-client-prebuilt - ./tool/go mod tidy - - - name: Get access token - uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 - id: generate-token - with: - # TODO(will): this should use the code updater app rather than licensing. - # It has the same permissions, so not a big deal, but still. - app_id: ${{ secrets.LICENSING_APP_ID }} - installation_retrieval_mode: "id" - installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }} - private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }} - - - name: Send pull request - id: pull-request - uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5 - with: - token: ${{ steps.generate-token.outputs.token }} - author: OSS Updater - committer: OSS Updater - branch: actions/update-webclient-prebuilt - commit-message: "go.mod: update web-client-prebuilt module" - title: "go.mod: update web-client-prebuilt module" - body: Triggered by ${{ github.repository }}@${{ github.sha }} - signoff: true - delete-branch: true - reviewers: ${{ github.triggering_actor }} - - - name: Summary - if: ${{ steps.pull-request.outputs.pull-request-number }} - run: echo "${{ steps.pull-request.outputs.pull-request-operation}} ${{ steps.pull-request.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/webclient.yml b/.github/workflows/webclient.yml deleted file mode 100644 index 9afb7730d9a56..0000000000000 --- a/.github/workflows/webclient.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: webclient -on: - workflow_dispatch: - # For now, only run on requests, not the main branches. - pull_request: - branches: - - "*" - paths: - - "client/web/**" - - ".github/workflows/webclient.yml" - - "!**.md" - # TODO(soniaappasamy): enable for main branch after an initial waiting period. - #push: - # branches: - # - main - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - webclient: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Install deps - run: ./tool/yarn --cwd client/web - - name: Run lint - run: ./tool/yarn --cwd client/web run --silent lint - - name: Run test - run: ./tool/yarn --cwd client/web run --silent test - - name: Run formatter check - run: | - ./tool/yarn --cwd client/web run --silent format-check || ( \ - echo "Run this command on your local device to fix the error:" && \ - echo "" && \ - echo " ./tool/yarn --cwd client/web format" && \ - echo "" && exit 1) diff --git a/.gitignore b/.gitignore index 47d2bbe959ae1..84e51978db59e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ client/web/build/assets *.xcworkspacedata /tstest/tailmac/bin /tstest/tailmac/build + +/.idea/ \ No newline at end of file diff --git a/appc/appconnector.go b/appc/appconnector.go index 671ced9534406..142475c591c5a 100644 --- a/appc/appconnector.go +++ b/appc/appconnector.go @@ -18,15 +18,15 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/execqueue" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/slicesx" xmaps "golang.org/x/exp/maps" "golang.org/x/net/dns/dnsmessage" - "tailscale.com/types/logger" - "tailscale.com/types/views" - "tailscale.com/util/clientmetric" - "tailscale.com/util/dnsname" - "tailscale.com/util/execqueue" - "tailscale.com/util/mak" - "tailscale.com/util/slicesx" ) // rateLogger responds to calls to update by adding a count for the current period and diff --git a/appc/appconnector_test.go b/appc/appconnector_test.go deleted file mode 100644 index 7dba8cebd9e8c..0000000000000 --- a/appc/appconnector_test.go +++ /dev/null @@ -1,604 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package appc - -import ( - "context" - "net/netip" - "reflect" - "slices" - "testing" - "time" - - xmaps "golang.org/x/exp/maps" - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/appc/appctest" - "tailscale.com/tstest" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/must" -) - -func fakeStoreRoutes(*RouteInfo) error { return nil } - -func TestUpdateDomains(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil) - } - a.UpdateDomains([]string{"example.com"}) - - a.Wait(ctx) - if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - addr := netip.MustParseAddr("192.0.0.8") - a.domains["example.com"] = append(a.domains["example.com"], addr) - a.UpdateDomains([]string{"example.com"}) - a.Wait(ctx) - - if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - // domains are explicitly downcased on set. - a.UpdateDomains([]string{"UP.EXAMPLE.COM"}) - a.Wait(ctx) - if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - } -} - -func TestUpdateRoutes(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - rc := &appctest.RouteCollector{} - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - a.updateDomains([]string{"*.example.com"}) - - // This route should be collapsed into the range - a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")) - a.Wait(ctx) - - if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) { - t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) - } - - // This route should not be collapsed or removed - a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")) - a.Wait(ctx) - - routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")} - a.updateRoutes(routes) - - slices.SortFunc(rc.Routes(), prefixCompare) - rc.SetRoutes(slices.Compact(rc.Routes())) - slices.SortFunc(routes, prefixCompare) - - // Ensure that the non-matching /32 is preserved, even though it's in the domains table. - if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) { - t.Errorf("added routes: got %v, want %v", rc.Routes(), routes) - } - - // Ensure that the contained /32 is removed, replaced by the /24. - wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")} - if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) { - t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes()) - } - } -} - -func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - rc := &appctest.RouteCollector{} - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")}) - rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) - routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")} - a.updateRoutes(routes) - - if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) { - t.Fatalf("got %v, want %v", rc.Routes(), routes) - } - } -} - -func TestDomainRoutes(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - rc := &appctest.RouteCollector{} - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - a.updateDomains([]string{"example.com"}) - a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) - a.Wait(context.Background()) - - want := map[string][]netip.Addr{ - "example.com": {netip.MustParseAddr("192.0.0.8")}, - } - - if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) { - t.Fatalf("DomainRoutes: got %v, want %v", got, want) - } - } -} - -func TestObserveDNSResponse(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - rc := &appctest.RouteCollector{} - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - - // a has no domains configured, so it should not advertise any routes - a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) - if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} - - a.updateDomains([]string{"example.com"}) - a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) - a.Wait(ctx) - if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - // a CNAME record chain should result in a route being added if the chain - // matches a routed domain. - a.updateDomains([]string{"www.example.com", "example.com"}) - a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")) - a.Wait(ctx) - wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32")) - if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - // a CNAME record chain should result in a route being added if the chain - // even if only found in the middle of the chain - a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org.")) - a.Wait(ctx) - wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32")) - if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128")) - - a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")) - a.Wait(ctx) - if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - // don't re-advertise routes that have already been advertised - a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")) - a.Wait(ctx) - if !slices.Equal(rc.Routes(), wantRoutes) { - t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes) - } - - // don't advertise addresses that are already in a control provided route - pfx := netip.MustParsePrefix("192.0.2.0/24") - a.updateRoutes([]netip.Prefix{pfx}) - wantRoutes = append(wantRoutes, pfx) - a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1")) - a.Wait(ctx) - if !slices.Equal(rc.Routes(), wantRoutes) { - t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes) - } - if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) { - t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"]) - } - } -} - -func TestWildcardDomains(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - rc := &appctest.RouteCollector{} - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - - a.updateDomains([]string{"*.example.com"}) - a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")) - a.Wait(ctx) - if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) { - t.Errorf("routes: got %v; want %v", got, want) - } - if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) { - t.Errorf("wildcards: got %v; want %v", got, want) - } - - a.updateDomains([]string{"*.example.com", "example.com"}) - if _, ok := a.domains["foo.example.com"]; !ok { - t.Errorf("expected foo.example.com to be preserved in domains due to wildcard") - } - if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) { - t.Errorf("wildcards: got %v; want %v", got, want) - } - - // There was an early regression where the wildcard domain was added repeatedly, this guards against that. - a.updateDomains([]string{"*.example.com", "example.com"}) - if len(a.wildcards) != 1 { - t.Errorf("expected only one wildcard domain, got %v", a.wildcards) - } - } -} - -// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address -func dnsResponse(domain, address string) []byte { - addr := netip.MustParseAddr(address) - b := dnsmessage.NewBuilder(nil, dnsmessage.Header{}) - b.EnableCompression() - b.StartAnswers() - switch addr.BitLen() { - case 32: - b.AResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AResource{ - A: addr.As4(), - }, - ) - case 128: - b.AAAAResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeAAAA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AAAAResource{ - AAAA: addr.As16(), - }, - ) - default: - panic("invalid address length") - } - return must.Get(b.Finish()) -} - -func dnsCNAMEResponse(address string, domains ...string) []byte { - addr := netip.MustParseAddr(address) - b := dnsmessage.NewBuilder(nil, dnsmessage.Header{}) - b.EnableCompression() - b.StartAnswers() - - if len(domains) >= 2 { - for i, domain := range domains[:len(domains)-1] { - b.CNAMEResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeCNAME, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.CNAMEResource{ - CNAME: dnsmessage.MustNewName(domains[i+1]), - }, - ) - } - } - - domain := domains[len(domains)-1] - - switch addr.BitLen() { - case 32: - b.AResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AResource{ - A: addr.As4(), - }, - ) - case 128: - b.AAAAResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeAAAA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AAAAResource{ - AAAA: addr.As16(), - }, - ) - default: - panic("invalid address length") - } - return must.Get(b.Finish()) -} - -func prefixEqual(a, b netip.Prefix) bool { - return a == b -} - -func prefixCompare(a, b netip.Prefix) int { - if a.Addr().Compare(b.Addr()) == 0 { - return a.Bits() - b.Bits() - } - return a.Addr().Compare(b.Addr()) -} - -func prefixes(in ...string) []netip.Prefix { - toRet := make([]netip.Prefix, len(in)) - for i, s := range in { - toRet[i] = netip.MustParsePrefix(s) - } - return toRet -} - -func TestUpdateRouteRouteRemoval(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - rc := &appctest.RouteCollector{} - - assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) { - if !slices.Equal(routes, rc.Routes()) { - t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes()) - } - if !slices.Equal(removedRoutes, rc.RemovedRoutes()) { - t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes()) - } - } - - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - // nothing has yet been advertised - assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{}) - - a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.2/32")) - a.Wait(ctx) - // the routes passed to UpdateDomainsAndRoutes have been advertised - assertRoutes("simple update", prefixes("1.2.3.1/32", "1.2.3.2/32"), []netip.Prefix{}) - - // one route the same, one different - a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.3/32")) - a.Wait(ctx) - // old behavior: routes are not removed, resulting routes are both old and new - // (we have dupe 1.2.3.1 routes because the test RouteAdvertiser doesn't have the deduplication - // the real one does) - wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.1/32", "1.2.3.3/32") - wantRemovedRoutes := []netip.Prefix{} - if shouldStore { - // new behavior: routes are removed, resulting routes are new only - wantRoutes = prefixes("1.2.3.1/32", "1.2.3.1/32", "1.2.3.3/32") - wantRemovedRoutes = prefixes("1.2.3.2/32") - } - assertRoutes("removal", wantRoutes, wantRemovedRoutes) - } -} - -func TestUpdateDomainRouteRemoval(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - rc := &appctest.RouteCollector{} - - assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) { - if !slices.Equal(routes, rc.Routes()) { - t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes()) - } - if !slices.Equal(removedRoutes, rc.RemovedRoutes()) { - t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes()) - } - } - - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{}) - - a.UpdateDomainsAndRoutes([]string{"a.example.com", "b.example.com"}, []netip.Prefix{}) - a.Wait(ctx) - // adding domains doesn't immediately cause any routes to be advertised - assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{}) - - a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1")) - a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2")) - a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3")) - a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4")) - a.Wait(ctx) - // observing dns responses causes routes to be advertised - assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{}) - - a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{}) - a.Wait(ctx) - // old behavior, routes are not removed - wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32") - wantRemovedRoutes := []netip.Prefix{} - if shouldStore { - // new behavior, routes are removed for b.example.com - wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32") - wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32") - } - assertRoutes("removal", wantRoutes, wantRemovedRoutes) - } -} - -func TestUpdateWildcardRouteRemoval(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - rc := &appctest.RouteCollector{} - - assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) { - if !slices.Equal(routes, rc.Routes()) { - t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes()) - } - if !slices.Equal(removedRoutes, rc.RemovedRoutes()) { - t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes()) - } - } - - var a *AppConnector - if shouldStore { - a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes) - } else { - a = NewAppConnector(t.Logf, rc, nil, nil) - } - assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{}) - - a.UpdateDomainsAndRoutes([]string{"a.example.com", "*.b.example.com"}, []netip.Prefix{}) - a.Wait(ctx) - // adding domains doesn't immediately cause any routes to be advertised - assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{}) - - a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1")) - a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2")) - a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3")) - a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4")) - a.Wait(ctx) - // observing dns responses causes routes to be advertised - assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{}) - - a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{}) - a.Wait(ctx) - // old behavior, routes are not removed - wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32") - wantRemovedRoutes := []netip.Prefix{} - if shouldStore { - // new behavior, routes are removed for *.b.example.com - wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32") - wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32") - } - assertRoutes("removal", wantRoutes, wantRemovedRoutes) - } -} - -func TestRoutesWithout(t *testing.T) { - assert := func(msg string, got, want []netip.Prefix) { - if !slices.Equal(want, got) { - t.Errorf("%s: want %v, got %v", msg, want, got) - } - } - - assert("empty routes", routesWithout([]netip.Prefix{}, []netip.Prefix{}), []netip.Prefix{}) - assert("a empty", routesWithout([]netip.Prefix{}, prefixes("1.1.1.1/32", "1.1.1.2/32")), []netip.Prefix{}) - assert("b empty", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), []netip.Prefix{}), prefixes("1.1.1.1/32", "1.1.1.2/32")) - assert("no overlap", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.3/32", "1.1.1.4/32")), prefixes("1.1.1.1/32", "1.1.1.2/32")) - assert("a has fewer", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32")), []netip.Prefix{}) - assert("a has more", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32"), prefixes("1.1.1.1/32", "1.1.1.3/32")), prefixes("1.1.1.2/32", "1.1.1.4/32")) -} - -func TestRateLogger(t *testing.T) { - clock := tstest.Clock{} - wasCalled := false - rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time, _ int64) { - if count != 3 { - t.Fatalf("count for prev period: got %d, want 3", count) - } - wasCalled = true - }) - - for i := 0; i < 3; i++ { - clock.Advance(1 * time.Millisecond) - rl.update(0) - if wasCalled { - t.Fatalf("wasCalled: got true, want false") - } - } - - clock.Advance(1 * time.Second) - rl.update(0) - if !wasCalled { - t.Fatalf("wasCalled: got false, want true") - } - - wasCalled = false - rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time, _ int64) { - if count != 3 { - t.Fatalf("count for prev period: got %d, want 3", count) - } - wasCalled = true - }) - - for i := 0; i < 3; i++ { - clock.Advance(1 * time.Minute) - rl.update(0) - if wasCalled { - t.Fatalf("wasCalled: got true, want false") - } - } - - clock.Advance(1 * time.Hour) - rl.update(0) - if !wasCalled { - t.Fatalf("wasCalled: got false, want true") - } -} - -func TestRouteStoreMetrics(t *testing.T) { - metricStoreRoutes(1, 1) - metricStoreRoutes(1, 1) // the 1 buckets value should be 2 - metricStoreRoutes(5, 5) // the 5 buckets value should be 1 - metricStoreRoutes(6, 6) // the 10 buckets value should be 1 - metricStoreRoutes(10001, 10001) // the over buckets value should be 1 - wanted := map[string]int64{ - "appc_store_routes_n_routes_1": 2, - "appc_store_routes_rate_1": 2, - "appc_store_routes_n_routes_5": 1, - "appc_store_routes_rate_5": 1, - "appc_store_routes_n_routes_10": 1, - "appc_store_routes_rate_10": 1, - "appc_store_routes_n_routes_over": 1, - "appc_store_routes_rate_over": 1, - } - for _, x := range clientmetric.Metrics() { - if x.Value() != wanted[x.Name()] { - t.Errorf("%s: want: %d, got: %d", x.Name(), wanted[x.Name()], x.Value()) - } - } -} - -func TestMetricBucketsAreSorted(t *testing.T) { - if !slices.IsSorted(metricStoreRoutesRateBuckets) { - t.Errorf("metricStoreRoutesRateBuckets must be in order") - } - if !slices.IsSorted(metricStoreRoutesNBuckets) { - t.Errorf("metricStoreRoutesNBuckets must be in order") - } -} diff --git a/atomicfile/atomicfile_test.go b/atomicfile/atomicfile_test.go deleted file mode 100644 index 78c93e664f738..0000000000000 --- a/atomicfile/atomicfile_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// 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/chirp/chirp_test.go b/chirp/chirp_test.go deleted file mode 100644 index 2549c163fd819..0000000000000 --- a/chirp/chirp_test.go +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause -package chirp - -import ( - "bufio" - "errors" - "fmt" - "net" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -type fakeBIRD struct { - net.Listener - protocolsEnabled map[string]bool - sock string -} - -func newFakeBIRD(t *testing.T, protocols ...string) *fakeBIRD { - sock := filepath.Join(t.TempDir(), "sock") - l, err := net.Listen("unix", sock) - if err != nil { - t.Fatal(err) - } - pe := make(map[string]bool) - for _, p := range protocols { - pe[p] = false - } - return &fakeBIRD{ - Listener: l, - protocolsEnabled: pe, - sock: sock, - } -} - -func (fb *fakeBIRD) listen() error { - for { - c, err := fb.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) { - return nil - } - return err - } - go fb.handle(c) - } -} - -func (fb *fakeBIRD) handle(c net.Conn) { - fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.") - sc := bufio.NewScanner(c) - for sc.Scan() { - cmd := sc.Text() - args := strings.Split(cmd, " ") - switch args[0] { - case "enable": - en, ok := fb.protocolsEnabled[args[1]] - if !ok { - fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL") - } else if en { - fmt.Fprintf(c, "0010-%s: already enabled\n", args[1]) - } else { - fmt.Fprintf(c, "0011-%s: enabled\n", args[1]) - } - fmt.Fprintln(c, "0000 ") - fb.protocolsEnabled[args[1]] = true - case "disable": - en, ok := fb.protocolsEnabled[args[1]] - if !ok { - fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL") - } else if !en { - fmt.Fprintf(c, "0008-%s: already disabled\n", args[1]) - } else { - fmt.Fprintf(c, "0009-%s: disabled\n", args[1]) - } - fmt.Fprintln(c, "0000 ") - fb.protocolsEnabled[args[1]] = false - } - } -} - -func TestChirp(t *testing.T) { - fb := newFakeBIRD(t, "tailscale") - defer fb.Close() - go fb.listen() - c, err := New(fb.sock) - if err != nil { - t.Fatal(err) - } - if err := c.EnableProtocol("tailscale"); err != nil { - t.Fatal(err) - } - if err := c.EnableProtocol("tailscale"); err != nil { - t.Fatal(err) - } - if err := c.DisableProtocol("tailscale"); err != nil { - t.Fatal(err) - } - if err := c.DisableProtocol("tailscale"); err != nil { - t.Fatal(err) - } - if err := c.EnableProtocol("rando"); err == nil { - t.Fatalf("enabling %q succeeded", "rando") - } - if err := c.DisableProtocol("rando"); err == nil { - t.Fatalf("disabling %q succeeded", "rando") - } -} - -type hangingListener struct { - net.Listener - t *testing.T - done chan struct{} - wg sync.WaitGroup - sock string -} - -func newHangingListener(t *testing.T) *hangingListener { - sock := filepath.Join(t.TempDir(), "sock") - l, err := net.Listen("unix", sock) - if err != nil { - t.Fatal(err) - } - return &hangingListener{ - Listener: l, - t: t, - done: make(chan struct{}), - sock: sock, - } -} - -func (hl *hangingListener) Stop() { - hl.Close() - close(hl.done) - hl.wg.Wait() -} - -func (hl *hangingListener) listen() error { - for { - c, err := hl.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) { - return nil - } - return err - } - hl.wg.Add(1) - go hl.handle(c) - } -} - -func (hl *hangingListener) handle(c net.Conn) { - defer hl.wg.Done() - - // Write our fake first line of response so that we get into the read loop - fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.") - - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - hl.t.Logf("connection still hanging") - case <-hl.done: - return - } - } -} - -func TestChirpTimeout(t *testing.T) { - fb := newHangingListener(t) - defer fb.Stop() - go fb.listen() - - c, err := newWithTimeout(fb.sock, 500*time.Millisecond) - if err != nil { - t.Fatal(err) - } - - err = c.EnableProtocol("tailscale") - if err == nil { - t.Fatal("got err=nil, want timeout") - } - if !os.IsTimeout(err) { - t.Fatalf("got err=%v, want os.IsTimeout(err)=true", err) - } -} diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index b1c273a4fd462..44771434976aa 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -5,8 +5,8 @@ package apitype import ( - "tailscale.com/tailcfg" - "tailscale.com/types/dnstype" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/dnstype" ) // LocalAPIHost is the Host header value used by the LocalAPI. diff --git a/client/tailscale/devices.go b/client/tailscale/devices.go index 9008d4d0d0d54..48c1da09afe02 100644 --- a/client/tailscale/devices.go +++ b/client/tailscale/devices.go @@ -14,7 +14,7 @@ import ( "net/http" "net/url" - "tailscale.com/types/opt" + "github.com/sagernet/tailscale/types/opt" ) type GetDevicesResponse struct { diff --git a/client/tailscale/dns.go b/client/tailscale/dns.go index f198742b3ca51..3198e855d3704 100644 --- a/client/tailscale/dns.go +++ b/client/tailscale/dns.go @@ -12,7 +12,7 @@ import ( "fmt" "net/http" - "tailscale.com/client/tailscale/apitype" + "github.com/sagernet/tailscale/client/tailscale/apitype" ) // DNSNameServers is returned when retrieving the list of nameservers. diff --git a/client/tailscale/example/servetls/servetls.go b/client/tailscale/example/servetls/servetls.go index f48e90d163527..aa45214029007 100644 --- a/client/tailscale/example/servetls/servetls.go +++ b/client/tailscale/example/servetls/servetls.go @@ -11,7 +11,7 @@ import ( "log" "net/http" - "tailscale.com/client/tailscale" + "github.com/sagernet/tailscale/client/tailscale" ) func main() { diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 5eb66817698b7..3945dcd0000af 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -26,21 +26,21 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/client/tailscale/apitype" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/paths" + "github.com/sagernet/tailscale/safesocket" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/util/syspolicy/setting" "go4.org/mem" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/drive" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/netutil" - "tailscale.com/paths" - "tailscale.com/safesocket" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/types/dnstype" - "tailscale.com/types/key" - "tailscale.com/types/tkatype" - "tailscale.com/util/syspolicy/setting" ) // defaultLocalClient is the default LocalClient when using the legacy diff --git a/client/tailscale/localclient_test.go b/client/tailscale/localclient_test.go deleted file mode 100644 index 950a22f474c32..0000000000000 --- a/client/tailscale/localclient_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 - -package tailscale - -import ( - "context" - "net" - "net/http" - "net/http/httptest" - "testing" - - "tailscale.com/tstest/deptest" - "tailscale.com/types/key" -) - -func TestGetServeConfigFromJSON(t *testing.T) { - sc, err := getServeConfigFromJSON([]byte("null")) - if sc != nil { - t.Errorf("want nil for null") - } - if err != nil { - t.Errorf("reading null: %v", err) - } - - sc, err = getServeConfigFromJSON([]byte(`{"TCP":{}}`)) - if err != nil { - t.Errorf("reading object: %v", err) - } else if sc == nil { - t.Errorf("want non-nil for object") - } else if sc.TCP == nil { - t.Errorf("want non-nil TCP for object") - } -} - -func TestWhoIsPeerNotFound(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - })) - defer ts.Close() - - lc := &LocalClient{ - Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { - var std net.Dialer - return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String()) - }, - } - var k key.NodePublic - if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil { - t.Fatal(err) - } - res, err := lc.WhoIsNodeKey(context.Background(), k) - if err != ErrPeerNotFound { - t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err) - } - res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678") - if err != ErrPeerNotFound { - t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err) - } -} - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - BadDeps: map[string]string{ - // Make sure we don't again accidentally bring in a dependency on - // drive or its transitive dependencies - "testing": "do not use testing package in production code", - "tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631", - "github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631", - }, - }.Check(t) -} diff --git a/client/tailscale/tailnet.go b/client/tailscale/tailnet.go index 2539e7f235b0e..32bced4f2f566 100644 --- a/client/tailscale/tailnet.go +++ b/client/tailscale/tailnet.go @@ -11,7 +11,7 @@ import ( "net/http" "net/url" - "tailscale.com/util/httpm" + "github.com/sagernet/tailscale/util/httpm" ) // TailnetDeleteRequest handles sending a DELETE request for a tailnet to control. diff --git a/client/web/auth.go b/client/web/auth.go index 8b195a417f415..d6f2d09436e22 100644 --- a/client/web/auth.go +++ b/client/web/auth.go @@ -15,9 +15,9 @@ import ( "strings" "time" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" + "github.com/sagernet/tailscale/client/tailscale/apitype" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/tailcfg" ) const ( diff --git a/client/web/synology.go b/client/web/synology.go index 922489d78af16..8218534872182 100644 --- a/client/web/synology.go +++ b/client/web/synology.go @@ -13,7 +13,7 @@ import ( "os/exec" "strings" - "tailscale.com/util/groupmember" + "github.com/sagernet/tailscale/util/groupmember" ) // authorizeSynology authenticates the logged-in Synology user and verifies diff --git a/client/web/web.go b/client/web/web.go index 56c5c92e808bb..739ed9c8ed023 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -22,23 +22,23 @@ import ( "time" "github.com/gorilla/csrf" - "tailscale.com/client/tailscale" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/clientupdate" - "tailscale.com/envknob" - "tailscale.com/envknob/featureknob" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/licenses" - "tailscale.com/net/netutil" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/types/views" - "tailscale.com/util/httpm" - "tailscale.com/version" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/client/tailscale" + "github.com/sagernet/tailscale/client/tailscale/apitype" + "github.com/sagernet/tailscale/clientupdate" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/envknob/featureknob" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/licenses" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/httpm" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" ) // ListenPort is the static port used for the web client when run inside tailscaled. diff --git a/client/web/web_test.go b/client/web/web_test.go deleted file mode 100644 index 3c5543c12014c..0000000000000 --- a/client/web/web_test.go +++ /dev/null @@ -1,1479 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package web - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/netip" - "net/url" - "slices" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "tailscale.com/client/tailscale" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/memnet" - "tailscale.com/tailcfg" - "tailscale.com/types/views" - "tailscale.com/util/httpm" -) - -func TestQnapAuthnURL(t *testing.T) { - query := url.Values{ - "qtoken": []string{"token"}, - } - tests := []struct { - name string - in string - want string - }{ - { - name: "localhost http", - in: "http://localhost:8088/", - want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "localhost https", - in: "https://localhost:5000/", - want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "IP http", - in: "http://10.1.20.4:80/", - want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "IP6 https", - in: "https://[ff7d:0:1:2::1]/", - want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "hostname https", - in: "https://qnap.example.com/", - want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "invalid URL", - in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.", - want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "err != nil", - in: "http://192.168.0.%31/", - want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - u := qnapAuthnURL(tt.in, query) - if u != tt.want { - t.Errorf("expected url: %q, got: %q", tt.want, u) - } - }) - } -} - -// TestServeAPI tests the web client api's handling of -// 1. invalid endpoint errors -// 2. permissioning of api endpoints based on node capabilities -func TestServeAPI(t *testing.T) { - selfTags := views.SliceOf([]string{"tag:server"}) - self := &ipnstate.PeerStatus{ID: "self", Tags: &selfTags} - prefs := &ipn.Prefs{} - - remoteUser := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} - remoteIPWithAllCapabilities := "100.100.100.101" - remoteIPWithNoCapabilities := "100.100.100.102" - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, - map[string]*apitype.WhoIsResponse{ - remoteIPWithAllCapabilities: { - Node: &tailcfg.Node{StableID: "node1"}, - UserProfile: remoteUser, - CapMap: tailcfg.PeerCapMap{tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{"{\"canEdit\":[\"*\"]}"}}, - }, - remoteIPWithNoCapabilities: { - Node: &tailcfg.Node{StableID: "node2"}, - UserProfile: remoteUser, - }, - }, - func() *ipnstate.PeerStatus { return self }, - func() *ipn.Prefs { return prefs }, - nil, - ) - defer localapi.Close() - go localapi.Serve(lal) - - s := &Server{ - mode: ManageServerMode, - lc: &tailscale.LocalClient{Dial: lal.Dial}, - timeNow: time.Now, - } - - type requestTest struct { - remoteIP string - wantResponse string - wantStatus int - } - - tests := []struct { - reqPath string - reqMethod string - reqContentType string - reqBody string - tests []requestTest - }{{ - reqPath: "/not-an-endpoint", - reqMethod: httpm.POST, - tests: []requestTest{{ - remoteIP: remoteIPWithNoCapabilities, - wantResponse: "invalid endpoint", - wantStatus: http.StatusNotFound, - }, { - remoteIP: remoteIPWithAllCapabilities, - wantResponse: "invalid endpoint", - wantStatus: http.StatusNotFound, - }}, - }, { - reqPath: "/local/v0/not-an-endpoint", - reqMethod: httpm.POST, - tests: []requestTest{{ - remoteIP: remoteIPWithNoCapabilities, - wantResponse: "invalid endpoint", - wantStatus: http.StatusNotFound, - }, { - remoteIP: remoteIPWithAllCapabilities, - wantResponse: "invalid endpoint", - wantStatus: http.StatusNotFound, - }}, - }, { - reqPath: "/local/v0/logout", - reqMethod: httpm.POST, - tests: []requestTest{{ - remoteIP: remoteIPWithNoCapabilities, - wantResponse: "not allowed", // requesting node has insufficient permissions - wantStatus: http.StatusUnauthorized, - }, { - remoteIP: remoteIPWithAllCapabilities, - wantResponse: "success", // requesting node has sufficient permissions - wantStatus: http.StatusOK, - }}, - }, { - reqPath: "/exit-nodes", - reqMethod: httpm.GET, - tests: []requestTest{{ - remoteIP: remoteIPWithNoCapabilities, - wantResponse: "null", - wantStatus: http.StatusOK, // allowed, no additional capabilities required - }, { - remoteIP: remoteIPWithAllCapabilities, - wantResponse: "null", - wantStatus: http.StatusOK, - }}, - }, { - reqPath: "/routes", - reqMethod: httpm.POST, - reqBody: "{\"setExitNode\":true}", - tests: []requestTest{{ - remoteIP: remoteIPWithNoCapabilities, - wantResponse: "not allowed", - wantStatus: http.StatusUnauthorized, - }, { - remoteIP: remoteIPWithAllCapabilities, - wantStatus: http.StatusOK, - }}, - }, { - reqPath: "/local/v0/prefs", - reqMethod: httpm.PATCH, - reqBody: "{\"runSSHSet\":true}", - reqContentType: "application/json", - tests: []requestTest{{ - remoteIP: remoteIPWithNoCapabilities, - wantResponse: "not allowed", - wantStatus: http.StatusUnauthorized, - }, { - remoteIP: remoteIPWithAllCapabilities, - wantStatus: http.StatusOK, - }}, - }, { - reqPath: "/local/v0/prefs", - reqMethod: httpm.PATCH, - reqContentType: "multipart/form-data", - tests: []requestTest{{ - remoteIP: remoteIPWithNoCapabilities, - wantResponse: "invalid request", - wantStatus: http.StatusBadRequest, - }, { - remoteIP: remoteIPWithAllCapabilities, - wantResponse: "invalid request", - wantStatus: http.StatusBadRequest, - }}, - }} - for _, tt := range tests { - for _, req := range tt.tests { - t.Run(req.remoteIP+"_requesting_"+tt.reqPath, func(t *testing.T) { - var reqBody io.Reader - if tt.reqBody != "" { - reqBody = bytes.NewBuffer([]byte(tt.reqBody)) - } - r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, reqBody) - r.RemoteAddr = req.remoteIP - if tt.reqContentType != "" { - r.Header.Add("Content-Type", tt.reqContentType) - } - w := httptest.NewRecorder() - - s.serveAPI(w, r) - res := w.Result() - defer res.Body.Close() - if gotStatus := res.StatusCode; req.wantStatus != gotStatus { - t.Errorf("wrong status; want=%v, got=%v", req.wantStatus, gotStatus) - } - body, err := io.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline - if req.wantResponse != gotResp { - t.Errorf("wrong response; want=%q, got=%q", req.wantResponse, gotResp) - } - }) - } - } -} - -func TestGetTailscaleBrowserSession(t *testing.T) { - userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} - userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)} - - userANodeIP := "100.100.100.101" - userBNodeIP := "100.100.100.102" - taggedNodeIP := "100.100.100.103" - - var selfNode *ipnstate.PeerStatus - tags := views.SliceOf([]string{"tag:server"}) - tailnetNodes := map[string]*apitype.WhoIsResponse{ - userANodeIP: { - Node: &tailcfg.Node{ID: 1, StableID: "1"}, - UserProfile: userA, - }, - userBNodeIP: { - Node: &tailcfg.Node{ID: 2, StableID: "2"}, - UserProfile: userB, - }, - taggedNodeIP: { - Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()}, - }, - } - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil) - defer localapi.Close() - go localapi.Serve(lal) - - s := &Server{ - timeNow: time.Now, - lc: &tailscale.LocalClient{Dial: lal.Dial}, - } - - // Add some browser sessions to cache state. - userASession := &browserSession{ - ID: "cookie1", - SrcNode: 1, - SrcUser: userA.ID, - Created: time.Now(), - Authenticated: false, // not yet authenticated - } - userBSession := &browserSession{ - ID: "cookie2", - SrcNode: 2, - SrcUser: userB.ID, - Created: time.Now().Add(-2 * sessionCookieExpiry), - Authenticated: true, // expired - } - userASessionAuthorized := &browserSession{ - ID: "cookie3", - SrcNode: 1, - SrcUser: userA.ID, - Created: time.Now(), - Authenticated: true, // authenticated and not expired - } - s.browserSessions.Store(userASession.ID, userASession) - s.browserSessions.Store(userBSession.ID, userBSession) - s.browserSessions.Store(userASessionAuthorized.ID, userASessionAuthorized) - - tests := []struct { - name string - selfNode *ipnstate.PeerStatus - remoteAddr string - cookie string - - wantSession *browserSession - wantError error - wantIsAuthorized bool // response from session.isAuthorized - }{ - { - name: "not-connected-over-tailscale", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: "77.77.77.77", - wantSession: nil, - wantError: errNotUsingTailscale, - }, - { - name: "no-session-user-self-node", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userANodeIP, - cookie: "not-a-cookie", - wantSession: nil, - wantError: errNoSession, - }, - { - name: "no-session-tagged-self-node", - selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags}, - remoteAddr: userANodeIP, - wantSession: nil, - wantError: errNoSession, - }, - { - name: "not-owner", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userBNodeIP, - wantSession: nil, - wantError: errNotOwner, - }, - { - name: "tagged-remote-source", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: taggedNodeIP, - wantSession: nil, - wantError: errTaggedRemoteSource, - }, - { - name: "tagged-local-source", - selfNode: &ipnstate.PeerStatus{ID: "3"}, - remoteAddr: taggedNodeIP, // same node as selfNode - wantSession: nil, - wantError: errTaggedLocalSource, - }, - { - name: "not-tagged-local-source", - selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID}, - remoteAddr: userANodeIP, // same node as selfNode - cookie: userASession.ID, - wantSession: userASession, - wantError: nil, // should not error - }, - { - name: "has-session", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userANodeIP, - cookie: userASession.ID, - wantSession: userASession, - wantError: nil, - }, - { - name: "has-authorized-session", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userANodeIP, - cookie: userASessionAuthorized.ID, - wantSession: userASessionAuthorized, - wantError: nil, - wantIsAuthorized: true, - }, - { - name: "session-associated-with-different-source", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, - remoteAddr: userBNodeIP, - cookie: userASession.ID, - wantSession: nil, - wantError: errNoSession, - }, - { - name: "session-expired", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, - remoteAddr: userBNodeIP, - cookie: userBSession.ID, - wantSession: nil, - wantError: errNoSession, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - selfNode = tt.selfNode - r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}} - if tt.cookie != "" { - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) - } - session, _, _, err := s.getSession(r) - if !errors.Is(err, tt.wantError) { - t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) - } - if diff := cmp.Diff(session, tt.wantSession); diff != "" { - t.Errorf("wrong session; (-got+want):%v", diff) - } - if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized { - t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized) - } - }) - } -} - -// TestAuthorizeRequest tests the s.authorizeRequest function. -// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth). -func TestAuthorizeRequest(t *testing.T) { - // Create self and remoteNode owned by same user. - // See TestGetTailscaleBrowserSession for tests of - // browser sessions w/ different users. - user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} - self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID} - remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user} - remoteIP := "100.100.100.101" - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, - map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, - func() *ipnstate.PeerStatus { return self }, - nil, - nil, - ) - defer localapi.Close() - go localapi.Serve(lal) - - s := &Server{ - mode: ManageServerMode, - lc: &tailscale.LocalClient{Dial: lal.Dial}, - timeNow: time.Now, - } - validCookie := "ts-cookie" - s.browserSessions.Store(validCookie, &browserSession{ - ID: validCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: time.Now(), - Authenticated: true, - }) - - tests := []struct { - reqPath string - reqMethod string - - wantOkNotOverTailscale bool // simulates req over public internet - wantOkWithoutSession bool // simulates req over TS without valid browser session - wantOkWithSession bool // simulates req over TS with valid browser session - }{{ - reqPath: "/api/data", - reqMethod: httpm.GET, - wantOkNotOverTailscale: false, - wantOkWithoutSession: true, - wantOkWithSession: true, - }, { - reqPath: "/api/data", - reqMethod: httpm.POST, - wantOkNotOverTailscale: false, - wantOkWithoutSession: false, - wantOkWithSession: true, - }, { - reqPath: "/api/somethingelse", - reqMethod: httpm.GET, - wantOkNotOverTailscale: false, - wantOkWithoutSession: false, - wantOkWithSession: true, - }, { - reqPath: "/assets/styles.css", - wantOkNotOverTailscale: false, - wantOkWithoutSession: true, - wantOkWithSession: true, - }} - for _, tt := range tests { - t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) { - doAuthorize := func(remoteAddr string, cookie string) bool { - r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil) - r.RemoteAddr = remoteAddr - if cookie != "" { - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) - } - w := httptest.NewRecorder() - return s.authorizeRequest(w, r) - } - // Do request from non-Tailscale IP. - if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale { - t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk) - } - // Do request from Tailscale IP w/o associated session. - if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession { - t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk) - } - // Do request from Tailscale IP w/ associated session. - if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession { - t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk) - } - }) - } -} - -func TestServeAuth(t *testing.T) { - user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)} - self := &ipnstate.PeerStatus{ - ID: "self", - UserID: user.ID, - TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")}, - } - remoteIP := "100.100.100.101" - remoteNode := &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ - Name: "nodey", - ID: 1, - Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")}, - }, - UserProfile: user, - } - vi := &viewerIdentity{ - LoginName: user.LoginName, - NodeName: remoteNode.Node.Name, - NodeIP: remoteIP, - ProfilePicURL: user.ProfilePicURL, - Capabilities: peerCapabilities{capFeatureAll: true}, - } - - testControlURL := &defaultControlURL - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, - map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, - func() *ipnstate.PeerStatus { return self }, - func() *ipn.Prefs { - return &ipn.Prefs{ControlURL: *testControlURL} - }, - nil, - ) - defer localapi.Close() - go localapi.Serve(lal) - - timeNow := time.Now() - oneHourAgo := timeNow.Add(-time.Hour) - sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2) - - s := &Server{ - mode: ManageServerMode, - lc: &tailscale.LocalClient{Dial: lal.Dial}, - timeNow: func() time.Time { return timeNow }, - newAuthURL: mockNewAuthURL, - waitAuthURL: mockWaitAuthURL, - } - - successCookie := "ts-cookie-success" - s.browserSessions.Store(successCookie, &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - }) - failureCookie := "ts-cookie-failure" - s.browserSessions.Store(failureCookie, &browserSession{ - ID: failureCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathError, - AuthURL: *testControlURL + testAuthPathError, - }) - expiredCookie := "ts-cookie-expired" - s.browserSessions.Store(expiredCookie, &browserSession{ - ID: expiredCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: sixtyDaysAgo, - AuthID: "/a/old-auth-url", - AuthURL: *testControlURL + "/a/old-auth-url", - }) - - tests := []struct { - name string - - controlURL string // if empty, defaultControlURL is used - cookie string // cookie attached to request - wantNewCookie bool // want new cookie generated during request - wantSession *browserSession // session associated w/ cookie after request - - path string - wantStatus int - wantResp any - }{ - { - name: "no-session", - path: "/api/auth", - wantStatus: http.StatusOK, - wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode}, - wantNewCookie: false, - wantSession: nil, - }, - { - name: "new-session", - path: "/api/auth/session/new", - wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, - wantNewCookie: true, - wantSession: &browserSession{ - ID: "GENERATED_ID", // gets swapped for newly created ID by test - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: timeNow, - AuthID: testAuthPath, - AuthURL: *testControlURL + testAuthPath, - Authenticated: false, - }, - }, - { - name: "query-existing-incomplete-session", - path: "/api/auth", - cookie: successCookie, - wantStatus: http.StatusOK, - wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode}, - wantSession: &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: false, - }, - }, - { - name: "existing-session-used", - path: "/api/auth/session/new", // should not create new session - cookie: successCookie, - wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess}, - wantSession: &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: false, - }, - }, - { - name: "transition-to-successful-session", - path: "/api/auth/session/wait", - cookie: successCookie, - wantStatus: http.StatusOK, - wantResp: nil, - wantSession: &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: true, - }, - }, - { - name: "query-existing-complete-session", - path: "/api/auth", - cookie: successCookie, - wantStatus: http.StatusOK, - wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode}, - wantSession: &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: true, - }, - }, - { - name: "transition-to-failed-session", - path: "/api/auth/session/wait", - cookie: failureCookie, - wantStatus: http.StatusUnauthorized, - wantResp: nil, - wantSession: nil, // session deleted - }, - { - name: "failed-session-cleaned-up", - path: "/api/auth/session/new", - cookie: failureCookie, - wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, - wantNewCookie: true, - wantSession: &browserSession{ - ID: "GENERATED_ID", - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: timeNow, - AuthID: testAuthPath, - AuthURL: *testControlURL + testAuthPath, - Authenticated: false, - }, - }, - { - name: "expired-cookie-gets-new-session", - path: "/api/auth/session/new", - cookie: expiredCookie, - wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath}, - wantNewCookie: true, - wantSession: &browserSession{ - ID: "GENERATED_ID", - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: timeNow, - AuthID: testAuthPath, - AuthURL: *testControlURL + testAuthPath, - Authenticated: false, - }, - }, - { - name: "control-server-no-check-mode", - controlURL: "http://alternate-server.com/", - path: "/api/auth/session/new", - wantStatus: http.StatusOK, - wantResp: &newSessionAuthResponse{}, - wantNewCookie: true, - wantSession: &browserSession{ - ID: "GENERATED_ID", // gets swapped for newly created ID by test - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: timeNow, - Authenticated: true, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.controlURL != "" { - testControlURL = &tt.controlURL - } else { - testControlURL = &defaultControlURL - } - - r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil) - r.RemoteAddr = remoteIP - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) - w := httptest.NewRecorder() - s.serve(w, r) - res := w.Result() - defer res.Body.Close() - - // Validate response status/data. - if gotStatus := res.StatusCode; tt.wantStatus != gotStatus { - t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus) - } - var gotResp string - if res.StatusCode == http.StatusOK { - body, err := io.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - gotResp = strings.Trim(string(body), "\n") - } - var wantResp string - if tt.wantResp != nil { - b, _ := json.Marshal(tt.wantResp) - wantResp = string(b) - } - if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" { - t.Errorf("wrong response; (-got+want):%v", diff) - } - // Validate cookie creation. - sessionID := tt.cookie - var gotCookie bool - for _, c := range w.Result().Cookies() { - if c.Name == sessionCookieName { - gotCookie = true - sessionID = c.Value - break - } - } - if gotCookie != tt.wantNewCookie { - t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie) - } - // Validate browser session contents. - var gotSesson *browserSession - if s, ok := s.browserSessions.Load(sessionID); ok { - gotSesson = s.(*browserSession) - } - if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" { - // If requested, swap in the generated session ID before - // comparing got/want. - tt.wantSession.ID = sessionID - } - if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" { - t.Errorf("wrong session; (-got+want):%v", diff) - } - }) - } -} - -// TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function. -// For each given test case, we assert that the local API received a request to log the expected metric. -func TestServeAPIAuthMetricLogging(t *testing.T) { - user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)} - otherUser := &tailcfg.UserProfile{LoginName: "user2@example.com", ID: tailcfg.UserID(2)} - self := &ipnstate.PeerStatus{ - ID: "self", - UserID: user.ID, - TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")}, - } - remoteIP := "100.100.100.101" - remoteNode := &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ - Name: "remote-managed", - ID: 1, - Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")}, - }, - UserProfile: user, - } - remoteTaggedIP := "100.123.100.213" - remoteTaggedNode := &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ - Name: "remote-tagged", - ID: 2, - Addresses: []netip.Prefix{netip.MustParsePrefix(remoteTaggedIP + "/32")}, - Tags: []string{"dev-machine"}, - }, - UserProfile: user, - } - localIP := "100.1.2.3" - localNode := &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ - Name: "local-managed", - ID: 3, - StableID: "self", - Addresses: []netip.Prefix{netip.MustParsePrefix(localIP + "/32")}, - }, - UserProfile: user, - } - localTaggedIP := "100.1.2.133" - localTaggedNode := &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ - Name: "local-tagged", - ID: 4, - StableID: "self", - Addresses: []netip.Prefix{netip.MustParsePrefix(localTaggedIP + "/32")}, - Tags: []string{"prod-machine"}, - }, - UserProfile: user, - } - otherIP := "100.100.2.3" - otherNode := &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ - Name: "other-node", - ID: 5, - Addresses: []netip.Prefix{netip.MustParsePrefix(otherIP + "/32")}, - }, - UserProfile: otherUser, - } - nonTailscaleIP := "10.100.2.3" - - testControlURL := &defaultControlURL - var loggedMetrics []string - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, - map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode}, - func() *ipnstate.PeerStatus { return self }, - func() *ipn.Prefs { - return &ipn.Prefs{ControlURL: *testControlURL} - }, - func(metricName string) { - loggedMetrics = append(loggedMetrics, metricName) - }, - ) - defer localapi.Close() - go localapi.Serve(lal) - - timeNow := time.Now() - oneHourAgo := timeNow.Add(-time.Hour) - - s := &Server{ - mode: ManageServerMode, - lc: &tailscale.LocalClient{Dial: lal.Dial}, - timeNow: func() time.Time { return timeNow }, - newAuthURL: mockNewAuthURL, - waitAuthURL: mockWaitAuthURL, - } - - authenticatedRemoteNodeCookie := "ts-cookie-remote-node-authenticated" - s.browserSessions.Store(authenticatedRemoteNodeCookie, &browserSession{ - ID: authenticatedRemoteNodeCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: true, - }) - authenticatedLocalNodeCookie := "ts-cookie-local-node-authenticated" - s.browserSessions.Store(authenticatedLocalNodeCookie, &browserSession{ - ID: authenticatedLocalNodeCookie, - SrcNode: localNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: true, - }) - unauthenticatedRemoteNodeCookie := "ts-cookie-remote-node-unauthenticated" - s.browserSessions.Store(unauthenticatedRemoteNodeCookie, &browserSession{ - ID: unauthenticatedRemoteNodeCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: false, - }) - unauthenticatedLocalNodeCookie := "ts-cookie-local-node-unauthenticated" - s.browserSessions.Store(unauthenticatedLocalNodeCookie, &browserSession{ - ID: unauthenticatedLocalNodeCookie, - SrcNode: localNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: *testControlURL + testAuthPathSuccess, - Authenticated: false, - }) - - tests := []struct { - name string - cookie string // cookie attached to request - remoteAddr string // remote address to hit - - wantLoggedMetric string // expected metric to be logged - }{ - { - name: "managing-remote", - cookie: authenticatedRemoteNodeCookie, - remoteAddr: remoteIP, - wantLoggedMetric: "web_client_managing_remote", - }, - { - name: "managing-local", - cookie: authenticatedLocalNodeCookie, - remoteAddr: localIP, - wantLoggedMetric: "web_client_managing_local", - }, - { - name: "viewing-not-owner", - cookie: authenticatedRemoteNodeCookie, - remoteAddr: otherIP, - wantLoggedMetric: "web_client_viewing_not_owner", - }, - { - name: "viewing-local-tagged", - cookie: authenticatedLocalNodeCookie, - remoteAddr: localTaggedIP, - wantLoggedMetric: "web_client_viewing_local_tag", - }, - { - name: "viewing-remote-tagged", - cookie: authenticatedRemoteNodeCookie, - remoteAddr: remoteTaggedIP, - wantLoggedMetric: "web_client_viewing_remote_tag", - }, - { - name: "viewing-local-non-tailscale", - cookie: authenticatedLocalNodeCookie, - remoteAddr: nonTailscaleIP, - wantLoggedMetric: "web_client_viewing_local", - }, - { - name: "viewing-local-unauthenticated", - cookie: unauthenticatedLocalNodeCookie, - remoteAddr: localIP, - wantLoggedMetric: "web_client_viewing_local", - }, - { - name: "viewing-remote-unauthenticated", - cookie: unauthenticatedRemoteNodeCookie, - remoteAddr: remoteIP, - wantLoggedMetric: "web_client_viewing_remote", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testControlURL = &defaultControlURL - - r := httptest.NewRequest("GET", "http://100.1.2.3:5252/api/auth", nil) - r.RemoteAddr = tt.remoteAddr - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) - w := httptest.NewRecorder() - s.serveAPIAuth(w, r) - - if !slices.Contains(loggedMetrics, tt.wantLoggedMetric) { - t.Errorf("expected logged metrics to contain: '%s' but was: '%v'", tt.wantLoggedMetric, loggedMetrics) - } - loggedMetrics = []string{} - - res := w.Result() - defer res.Body.Close() - }) - } -} - -// TestPathPrefix tests that the provided path prefix is normalized correctly. -// If a leading '/' is missing, one should be added. -// If multiple leading '/' are present, they should be collapsed to one. -// Additionally verify that this prevents open redirects when enforcing the path prefix. -func TestPathPrefix(t *testing.T) { - tests := []struct { - name string - prefix string - wantPrefix string - wantLocation string - }{ - { - name: "no-leading-slash", - prefix: "javascript:alert(1)", - wantPrefix: "/javascript:alert(1)", - wantLocation: "/javascript:alert(1)/", - }, - { - name: "2-slashes", - prefix: "//evil.example.com/goat", - // We must also get the trailing slash added: - wantPrefix: "/evil.example.com/goat", - wantLocation: "/evil.example.com/goat/", - }, - { - name: "absolute-url", - prefix: "http://evil.example.com", - // We must also get the trailing slash added: - wantPrefix: "/http:/evil.example.com", - wantLocation: "/http:/evil.example.com/", - }, - { - name: "double-dot", - prefix: "/../.././etc/passwd", - // We must also get the trailing slash added: - wantPrefix: "/etc/passwd", - wantLocation: "/etc/passwd/", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - options := ServerOpts{ - Mode: LoginServerMode, - PathPrefix: tt.prefix, - CGIMode: true, - } - s, err := NewServer(options) - if err != nil { - t.Error(err) - } - - // verify provided prefix was normalized correctly - if s.pathPrefix != tt.wantPrefix { - t.Errorf("prefix was not normalized correctly; want=%q, got=%q", tt.wantPrefix, s.pathPrefix) - } - - s.logf = t.Logf - r := httptest.NewRequest(httpm.GET, "http://localhost/", nil) - w := httptest.NewRecorder() - s.ServeHTTP(w, r) - res := w.Result() - defer res.Body.Close() - - location := w.Header().Get("Location") - if location != tt.wantLocation { - t.Errorf("request got wrong location; want=%q, got=%q", tt.wantLocation, location) - } - }) - } -} - -func TestRequireTailscaleIP(t *testing.T) { - self := &ipnstate.PeerStatus{ - TailscaleIPs: []netip.Addr{ - netip.MustParseAddr("100.1.2.3"), - netip.MustParseAddr("fd7a:115c::1234"), - }, - } - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil) - defer localapi.Close() - go localapi.Serve(lal) - - s := &Server{ - mode: ManageServerMode, - lc: &tailscale.LocalClient{Dial: lal.Dial}, - timeNow: time.Now, - logf: t.Logf, - } - - tests := []struct { - name string - target string - wantHandled bool - wantLocation string - }{ - { - name: "localhost", - target: "http://localhost/", - wantHandled: true, - wantLocation: "http://100.1.2.3:5252/", - }, - { - name: "ipv4-no-port", - target: "http://100.1.2.3/", - wantHandled: true, - wantLocation: "http://100.1.2.3:5252/", - }, - { - name: "ipv4-correct-port", - target: "http://100.1.2.3:5252/", - wantHandled: false, - }, - { - name: "ipv6-no-port", - target: "http://[fd7a:115c::1234]/", - wantHandled: true, - wantLocation: "http://100.1.2.3:5252/", - }, - { - name: "ipv6-correct-port", - target: "http://[fd7a:115c::1234]:5252/", - wantHandled: false, - }, - { - name: "quad-100", - target: "http://100.100.100.100/", - wantHandled: false, - }, - { - name: "ipv6-service-addr", - target: "http://[fd7a:115c:a1e0::53]/", - wantHandled: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s.logf = t.Logf - r := httptest.NewRequest(httpm.GET, tt.target, nil) - w := httptest.NewRecorder() - handled := s.requireTailscaleIP(w, r) - - if handled != tt.wantHandled { - t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled) - } - - location := w.Header().Get("Location") - if location != tt.wantLocation { - t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location) - } - }) - } -} - -func TestPeerCapabilities(t *testing.T) { - userOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{UserID: tailcfg.UserID(1)}} - tags := views.SliceOf[string]([]string{"tag:server"}) - tagOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{Tags: &tags}} - - // Testing web.toPeerCapabilities - toPeerCapsTests := []struct { - name string - status *ipnstate.Status - whois *apitype.WhoIsResponse - wantCaps peerCapabilities - }{ - { - name: "empty-whois", - status: userOwnedStatus, - whois: nil, - wantCaps: peerCapabilities{}, - }, - { - name: "user-owned-node-non-owner-caps-ignored", - status: userOwnedStatus, - whois: &apitype.WhoIsResponse{ - UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)}, - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnets\"]}", - }, - }, - }, - wantCaps: peerCapabilities{}, - }, - { - name: "user-owned-node-owner-caps-ignored", - status: userOwnedStatus, - whois: &apitype.WhoIsResponse{ - UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)}, - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnets\"]}", - }, - }, - }, - wantCaps: peerCapabilities{capFeatureAll: true}, // should just have wildcard - }, - { - name: "tag-owned-no-webui-caps", - status: tagOwnedStatus, - whois: &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{}, - }, - }, - wantCaps: peerCapabilities{}, - }, - { - name: "tag-owned-one-webui-cap", - status: tagOwnedStatus, - whois: &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnets\"]}", - }, - }, - }, - wantCaps: peerCapabilities{ - capFeatureSSH: true, - capFeatureSubnets: true, - }, - }, - { - name: "tag-owned-multiple-webui-cap", - status: tagOwnedStatus, - whois: &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnets\"]}", - "{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}", - }, - }, - }, - wantCaps: peerCapabilities{ - capFeatureSSH: true, - capFeatureSubnets: true, - capFeatureExitNodes: true, - capFeatureAll: true, - }, - }, - { - name: "tag-owned-case-insensitive-caps", - status: tagOwnedStatus, - whois: &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"SSH\",\"sUBnets\"]}", - }, - }, - }, - wantCaps: peerCapabilities{ - capFeatureSSH: true, - capFeatureSubnets: true, - }, - }, - { - name: "tag-owned-random-canEdit-contents-get-dropped", - status: tagOwnedStatus, - whois: &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"unknown-feature\"]}", - }, - }, - }, - wantCaps: peerCapabilities{}, - }, - { - name: "tag-owned-no-canEdit-section", - status: tagOwnedStatus, - whois: &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canDoSomething\":[\"*\"]}", - }, - }, - }, - wantCaps: peerCapabilities{}, - }, - { - name: "tagged-source-caps-ignored", - status: tagOwnedStatus, - whois: &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()}, - CapMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnets\"]}", - }, - }, - }, - wantCaps: peerCapabilities{}, - }, - } - for _, tt := range toPeerCapsTests { - t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) { - got, err := toPeerCapabilities(tt.status, tt.whois) - if err != nil { - t.Fatalf("unexpected: %v", err) - } - if diff := cmp.Diff(got, tt.wantCaps); diff != "" { - t.Errorf("wrong caps; (-got+want):%v", diff) - } - }) - } - - // Testing web.peerCapabilities.canEdit - canEditTests := []struct { - name string - caps peerCapabilities - wantCanEdit map[capFeature]bool - }{ - { - name: "empty-caps", - caps: nil, - wantCanEdit: map[capFeature]bool{ - capFeatureAll: false, - capFeatureSSH: false, - capFeatureSubnets: false, - capFeatureExitNodes: false, - capFeatureAccount: false, - }, - }, - { - name: "some-caps", - caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true}, - wantCanEdit: map[capFeature]bool{ - capFeatureAll: false, - capFeatureSSH: true, - capFeatureSubnets: false, - capFeatureExitNodes: false, - capFeatureAccount: true, - }, - }, - { - name: "wildcard-in-caps", - caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true}, - wantCanEdit: map[capFeature]bool{ - capFeatureAll: true, - capFeatureSSH: true, - capFeatureSubnets: true, - capFeatureExitNodes: true, - capFeatureAccount: true, - }, - }, - } - for _, tt := range canEditTests { - t.Run("canEdit-"+tt.name, func(t *testing.T) { - for f, want := range tt.wantCanEdit { - if got := tt.caps.canEdit(f); got != want { - t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want) - } - } - }) - } -} - -var ( - defaultControlURL = "https://controlplane.tailscale.com" - testAuthPath = "/a/12345" - testAuthPathSuccess = "/a/will-succeed" - testAuthPathError = "/a/will-error" -) - -// mockLocalAPI constructs a test localapi handler that can be used -// to simulate localapi responses without a functioning tailnet. -// -// self accepts a function that resolves to a self node status, -// so that tests may swap out the /localapi/v0/status response -// as desired. -func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server { - return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/localapi/v0/whois": - addr := r.URL.Query().Get("addr") - if addr == "" { - t.Fatalf("/whois call missing \"addr\" query") - } - if node := whoIs[addr]; node != nil { - writeJSON(w, &node) - return - } - http.Error(w, "not a node", http.StatusUnauthorized) - return - case "/localapi/v0/status": - writeJSON(w, ipnstate.Status{Self: self()}) - return - case "/localapi/v0/prefs": - writeJSON(w, prefs()) - return - case "/localapi/v0/upload-client-metrics": - type metricName struct { - Name string `json:"name"` - } - - var metricNames []metricName - if err := json.NewDecoder(r.Body).Decode(&metricNames); err != nil { - http.Error(w, "invalid JSON body", http.StatusBadRequest) - return - } - metricCapture(metricNames[0].Name) - writeJSON(w, struct{}{}) - return - case "/localapi/v0/logout": - fmt.Fprintf(w, "success") - return - default: - t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) - } - })} -} - -func mockNewAuthURL(_ context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { - // Create new dummy auth URL. - return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: defaultControlURL + testAuthPath}, nil -} - -func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { - switch id { - case testAuthPathSuccess: // successful auth URL - return &tailcfg.WebClientAuthResponse{Complete: true}, nil - case testAuthPathError: // error auth URL - return nil, errors.New("authenticated as wrong user") - default: - return nil, errors.New("unknown id") - } -} diff --git a/client/web/yarn.lock b/client/web/yarn.lock index 2c8fca5e53e9d..3114cb444e607 100644 --- a/client/web/yarn.lock +++ b/client/web/yarn.lock @@ -2621,9 +2621,9 @@ cosmiconfig@^8.1.3: path-type "^4.0.0" cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index 7fa84d67f9d8a..7e63027932ca0 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -27,10 +27,10 @@ import ( "strconv" "strings" - "tailscale.com/types/logger" - "tailscale.com/util/cmpver" - "tailscale.com/version" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/cmpver" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" ) const ( diff --git a/clientupdate/clientupdate_downloads.go b/clientupdate/clientupdate_downloads.go index 18d3176b42afe..23ca6d1efc824 100644 --- a/clientupdate/clientupdate_downloads.go +++ b/clientupdate/clientupdate_downloads.go @@ -8,7 +8,7 @@ package clientupdate import ( "context" - "tailscale.com/clientupdate/distsign" + "github.com/sagernet/tailscale/clientupdate/distsign" ) func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) { diff --git a/clientupdate/clientupdate_test.go b/clientupdate/clientupdate_test.go deleted file mode 100644 index b265d56411bdc..0000000000000 --- a/clientupdate/clientupdate_test.go +++ /dev/null @@ -1,952 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package clientupdate - -import ( - "archive/tar" - "compress/gzip" - "fmt" - "io/fs" - "maps" - "os" - "path/filepath" - "slices" - "sort" - "strings" - "testing" -) - -func TestUpdateDebianAptSourcesListBytes(t *testing.T) { - tests := []struct { - name string - toTrack string - in string - want string // empty means want no change - wantErr string - }{ - { - name: "stable-to-unstable", - toTrack: UnstableTrack, - in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n", - want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n", - }, - { - name: "stable-unchanged", - toTrack: StableTrack, - in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n", - }, - { - name: "if-both-stable-and-unstable-dont-change", - toTrack: StableTrack, - in: "# Tailscale packages for debian buster\n" + - "deb https://pkgs.tailscale.com/stable/debian bullseye main\n" + - "deb https://pkgs.tailscale.com/unstable/debian bullseye main\n", - }, - { - name: "if-both-stable-and-unstable-dont-change-unstable", - toTrack: UnstableTrack, - in: "# Tailscale packages for debian buster\n" + - "deb https://pkgs.tailscale.com/stable/debian bullseye main\n" + - "deb https://pkgs.tailscale.com/unstable/debian bullseye main\n", - }, - { - name: "signed-by-form", - toTrack: UnstableTrack, - in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n", - want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n", - }, - { - name: "unsupported-lines", - toTrack: UnstableTrack, - in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n", - wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - newContent, err := updateDebianAptSourcesListBytes([]byte(tt.in), tt.toTrack) - if err != nil { - if err.Error() != tt.wantErr { - t.Fatalf("error = %v; want %q", err, tt.wantErr) - } - return - } - if tt.wantErr != "" { - t.Fatalf("got no error; want %q", tt.wantErr) - } - var gotChange string - if string(newContent) != tt.in { - gotChange = string(newContent) - } - if gotChange != tt.want { - t.Errorf("wrong result\n got: %q\nwant: %q", gotChange, tt.want) - } - }) - } -} - -func TestUpdateYUMRepoTrack(t *testing.T) { - tests := []struct { - desc string - before string - track string - after string - rewrote bool - wantErr bool - }{ - { - desc: "same track", - before: ` -[tailscale-stable] -name=Tailscale stable -baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch -enabled=1 -type=rpm -repo_gpgcheck=1 -gpgcheck=0 -gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg -`, - track: StableTrack, - after: ` -[tailscale-stable] -name=Tailscale stable -baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch -enabled=1 -type=rpm -repo_gpgcheck=1 -gpgcheck=0 -gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg -`, - }, - { - desc: "change track", - before: ` -[tailscale-stable] -name=Tailscale stable -baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch -enabled=1 -type=rpm -repo_gpgcheck=1 -gpgcheck=0 -gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg -`, - track: UnstableTrack, - after: ` -[tailscale-unstable] -name=Tailscale unstable -baseurl=https://pkgs.tailscale.com/unstable/fedora/$basearch -enabled=1 -type=rpm -repo_gpgcheck=1 -gpgcheck=0 -gpgkey=https://pkgs.tailscale.com/unstable/fedora/repo.gpg -`, - rewrote: true, - }, - { - desc: "non-tailscale repo file", - before: ` -[fedora] -name=Fedora $releasever - $basearch -#baseurl=http://download.example/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/ -metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearch -enabled=1 -countme=1 -metadata_expire=7d -repo_gpgcheck=0 -type=rpm -gpgcheck=1 -gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch -skip_if_unavailable=False -`, - track: StableTrack, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "tailscale.repo") - if err := os.WriteFile(path, []byte(tt.before), 0644); err != nil { - t.Fatal(err) - } - - rewrote, err := updateYUMRepoTrack(path, tt.track) - if err == nil && tt.wantErr { - t.Fatal("got nil error, want non-nil") - } - if err != nil && !tt.wantErr { - t.Fatalf("got error %q, want nil", err) - } - if err != nil { - return - } - if rewrote != tt.rewrote { - t.Errorf("got rewrote flag %v, want %v", rewrote, tt.rewrote) - } - - after, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(after) != tt.after { - t.Errorf("got repo file after update:\n%swant:\n%s", after, tt.after) - } - }) - } -} - -func TestParseAlpinePackageVersion(t *testing.T) { - tests := []struct { - desc string - out string - want string - wantErr bool - }{ - { - desc: "valid version", - out: ` -tailscale-1.44.2-r0 description: -The easiest, most secure way to use WireGuard and 2FA - -tailscale-1.44.2-r0 webpage: -https://tailscale.com/ - -tailscale-1.44.2-r0 installed size: -32 MiB -`, - want: "1.44.2", - }, - { - desc: "wrong package output", - out: ` -busybox-1.36.1-r0 description: -Size optimized toolbox of many common UNIX utilities - -busybox-1.36.1-r0 webpage: -https://busybox.net/ - -busybox-1.36.1-r0 installed size: -924 KiB -`, - wantErr: true, - }, - { - desc: "missing version", - out: ` -tailscale description: -The easiest, most secure way to use WireGuard and 2FA - -tailscale webpage: -https://tailscale.com/ - -tailscale installed size: -32 MiB -`, - wantErr: true, - }, - { - desc: "empty output", - out: "", - wantErr: true, - }, - { - desc: "multiple versions", - out: ` -tailscale-1.54.1-r0 description: -The easiest, most secure way to use WireGuard and 2FA - -tailscale-1.54.1-r0 webpage: -https://tailscale.com/ - -tailscale-1.54.1-r0 installed size: -34 MiB - -tailscale-1.58.2-r0 description: -The easiest, most secure way to use WireGuard and 2FA - -tailscale-1.58.2-r0 webpage: -https://tailscale.com/ - -tailscale-1.58.2-r0 installed size: -35 MiB -`, - want: "1.58.2", - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - got, err := parseAlpinePackageVersion([]byte(tt.out)) - if err == nil && tt.wantErr { - t.Fatalf("got nil error and version %q, want non-nil error", got) - } - if err != nil && !tt.wantErr { - t.Fatalf("got error: %q, want nil", err) - } - if got != tt.want { - t.Fatalf("got version: %q, want %q", got, tt.want) - } - }) - } -} - -func TestSynoArch(t *testing.T) { - tests := []struct { - goarch string - synoinfoUnique string - want string - wantErr bool - }{ - {goarch: "amd64", synoinfoUnique: "synology_x86_224", want: "x86_64"}, - {goarch: "arm64", synoinfoUnique: "synology_armv8_124", want: "armv8"}, - {goarch: "386", synoinfoUnique: "synology_i686_415play", want: "i686"}, - {goarch: "arm", synoinfoUnique: "synology_88f6281_213air", want: "88f6281"}, - {goarch: "arm", synoinfoUnique: "synology_88f6282_413j", want: "88f6282"}, - {goarch: "arm", synoinfoUnique: "synology_hi3535_NVR1218", want: "hi3535"}, - {goarch: "arm", synoinfoUnique: "synology_alpine_1517", want: "alpine"}, - {goarch: "arm", synoinfoUnique: "synology_armada370_216se", want: "armada370"}, - {goarch: "arm", synoinfoUnique: "synology_armada375_115", want: "armada375"}, - {goarch: "arm", synoinfoUnique: "synology_armada38x_419slim", want: "armada38x"}, - {goarch: "arm", synoinfoUnique: "synology_armadaxp_RS815", want: "armadaxp"}, - {goarch: "arm", synoinfoUnique: "synology_comcerto2k_414j", want: "comcerto2k"}, - {goarch: "arm", synoinfoUnique: "synology_monaco_216play", want: "monaco"}, - {goarch: "ppc64", synoinfoUnique: "synology_qoriq_413", wantErr: true}, - } - - for _, tt := range tests { - t.Run(fmt.Sprintf("%s-%s", tt.goarch, tt.synoinfoUnique), func(t *testing.T) { - synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf") - if err := os.WriteFile( - synoinfoConfPath, - []byte(fmt.Sprintf("unique=%q\n", tt.synoinfoUnique)), - 0600, - ); err != nil { - t.Fatal(err) - } - got, err := synoArch(tt.goarch, synoinfoConfPath) - if err != nil { - if !tt.wantErr { - t.Fatalf("got unexpected error %v", err) - } - return - } - if tt.wantErr { - t.Fatalf("got %q, expected an error", got) - } - if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestParseSynoinfo(t *testing.T) { - tests := []struct { - desc string - content string - want string - wantErr bool - }{ - { - desc: "double-quoted", - content: ` -company_title="Synology" -unique="synology_88f6281_213air" -`, - want: "88f6281", - }, - { - desc: "single-quoted", - content: ` -company_title="Synology" -unique='synology_88f6281_213air' -`, - want: "88f6281", - }, - { - desc: "unquoted", - content: ` -company_title="Synology" -unique=synology_88f6281_213air -`, - want: "88f6281", - }, - { - desc: "missing unique", - content: ` -company_title="Synology" -`, - wantErr: true, - }, - { - desc: "empty unique", - content: ` -company_title="Synology" -unique= -`, - wantErr: true, - }, - { - desc: "empty unique double-quoted", - content: ` -company_title="Synology" -unique="" -`, - wantErr: true, - }, - { - desc: "empty unique single-quoted", - content: ` -company_title="Synology" -unique='' -`, - wantErr: true, - }, - { - desc: "malformed unique", - content: ` -company_title="Synology" -unique="synology_88f6281" -`, - wantErr: true, - }, - { - desc: "empty file", - content: ``, - wantErr: true, - }, - { - desc: "empty lines and comments", - content: ` - -# In a file named synoinfo? Shocking! -company_title="Synology" - - -# unique= is_a_field_that_follows -unique="synology_88f6281_213air" - -`, - want: "88f6281", - }, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf") - if err := os.WriteFile(synoinfoConfPath, []byte(tt.content), 0600); err != nil { - t.Fatal(err) - } - got, err := parseSynoinfo(synoinfoConfPath) - if err != nil { - if !tt.wantErr { - t.Fatalf("got unexpected error %v", err) - } - return - } - if tt.wantErr { - t.Fatalf("got %q, expected an error", got) - } - if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestUnpackLinuxTarball(t *testing.T) { - oldBinaryPaths := binaryPaths - t.Cleanup(func() { binaryPaths = oldBinaryPaths }) - - tests := []struct { - desc string - tarball map[string]string - before map[string]string - after map[string]string - wantErr bool - }{ - { - desc: "success", - before: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - tarball: map[string]string{ - "/usr/bin/tailscale": "v2", - "/usr/bin/tailscaled": "v2", - }, - after: map[string]string{ - "tailscale": "v2", - "tailscaled": "v2", - }, - }, - { - desc: "don't touch unrelated files", - before: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - "foo": "bar", - }, - tarball: map[string]string{ - "/usr/bin/tailscale": "v2", - "/usr/bin/tailscaled": "v2", - }, - after: map[string]string{ - "tailscale": "v2", - "tailscaled": "v2", - "foo": "bar", - }, - }, - { - desc: "unmodified", - before: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - tarball: map[string]string{ - "/usr/bin/tailscale": "v1", - "/usr/bin/tailscaled": "v1", - }, - after: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - }, - { - desc: "ignore extra tarball files", - before: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - tarball: map[string]string{ - "/usr/bin/tailscale": "v2", - "/usr/bin/tailscaled": "v2", - "/systemd/tailscaled.service": "v2", - }, - after: map[string]string{ - "tailscale": "v2", - "tailscaled": "v2", - }, - }, - { - desc: "tarball missing tailscaled", - before: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - tarball: map[string]string{ - "/usr/bin/tailscale": "v2", - }, - after: map[string]string{ - "tailscale": "v1", - "tailscale.new": "v2", - "tailscaled": "v1", - }, - wantErr: true, - }, - { - desc: "duplicate tailscale binary", - before: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - tarball: map[string]string{ - "/usr/bin/tailscale": "v2", - "/usr/sbin/tailscale": "v2", - "/usr/bin/tailscaled": "v2", - }, - after: map[string]string{ - "tailscale": "v1", - "tailscale.new": "v2", - "tailscaled": "v1", - "tailscaled.new": "v2", - }, - wantErr: true, - }, - { - desc: "empty archive", - before: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - tarball: map[string]string{}, - after: map[string]string{ - "tailscale": "v1", - "tailscaled": "v1", - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - // Swap out binaryPaths function to point at dummy file paths. - tmp := t.TempDir() - tailscalePath := filepath.Join(tmp, "tailscale") - tailscaledPath := filepath.Join(tmp, "tailscaled") - binaryPaths = func() (string, string, error) { - return tailscalePath, tailscaledPath, nil - } - for name, content := range tt.before { - if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0755); err != nil { - t.Fatal(err) - } - } - tarPath := filepath.Join(tmp, "tailscale.tgz") - genTarball(t, tarPath, tt.tarball) - - up := &Updater{Arguments: Arguments{Logf: t.Logf}} - err := up.unpackLinuxTarball(tarPath) - if err != nil { - if !tt.wantErr { - t.Fatalf("unexpected error: %v", err) - } - } else if tt.wantErr { - t.Fatalf("unpack succeeded, expected an error") - } - - gotAfter := make(map[string]string) - err = filepath.WalkDir(tmp, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.Type().IsDir() { - return nil - } - if path == tarPath { - return nil - } - content, err := os.ReadFile(path) - if err != nil { - return err - } - path = filepath.ToSlash(path) - base := filepath.ToSlash(tmp) - gotAfter[strings.TrimPrefix(path, base+"/")] = string(content) - return nil - }) - if err != nil { - t.Fatal(err) - } - - if !maps.Equal(gotAfter, tt.after) { - t.Errorf("files after unpack: %+v, want %+v", gotAfter, tt.after) - } - }) - } -} - -func genTarball(t *testing.T, path string, files map[string]string) { - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - defer f.Close() - gw := gzip.NewWriter(f) - defer gw.Close() - tw := tar.NewWriter(gw) - defer tw.Close() - for file, content := range files { - if err := tw.WriteHeader(&tar.Header{ - Name: file, - Size: int64(len(content)), - Mode: 0755, - }); err != nil { - t.Fatal(err) - } - if _, err := tw.Write([]byte(content)); err != nil { - t.Fatal(err) - } - } -} - -func TestWriteFileOverwrite(t *testing.T) { - path := filepath.Join(t.TempDir(), "test") - for i := range 2 { - content := fmt.Sprintf("content %d", i) - if err := writeFile(strings.NewReader(content), path, 0600); err != nil { - t.Fatal(err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(got) != content { - t.Errorf("got content: %q, want: %q", got, content) - } - } -} - -func TestWriteFileSymlink(t *testing.T) { - // Test for a malicious symlink at the destination path. - // f2 points to f1 and writeFile(f2) should not end up overwriting f1. - tmp := t.TempDir() - f1 := filepath.Join(tmp, "f1") - if err := os.WriteFile(f1, []byte("old"), 0600); err != nil { - t.Fatal(err) - } - f2 := filepath.Join(tmp, "f2") - if err := os.Symlink(f1, f2); err != nil { - t.Fatal(err) - } - - if err := writeFile(strings.NewReader("new"), f2, 0600); err != nil { - t.Errorf("writeFile(%q) failed: %v", f2, err) - } - want := map[string]string{ - f1: "old", - f2: "new", - } - for f, content := range want { - got, err := os.ReadFile(f) - if err != nil { - t.Fatal(err) - } - if string(got) != content { - t.Errorf("%q: got content %q, want %q", f, got, content) - } - } -} - -func TestCleanupOldDownloads(t *testing.T) { - tests := []struct { - desc string - before []string - symlinks map[string]string - glob string - after []string - }{ - { - desc: "MSIs", - before: []string{ - "MSICache/tailscale-1.0.0.msi", - "MSICache/tailscale-1.1.0.msi", - "MSICache/readme.txt", - }, - glob: "MSICache/*.msi", - after: []string{ - "MSICache/readme.txt", - }, - }, - { - desc: "SPKs", - before: []string{ - "tmp/tailscale-update-1/tailscale-1.0.0.spk", - "tmp/tailscale-update-2/tailscale-1.1.0.spk", - "tmp/readme.txt", - "tmp/tailscale-update-3", - "tmp/tailscale-update-4/tailscale-1.3.0", - }, - glob: "tmp/tailscale-update*/*.spk", - after: []string{ - "tmp/readme.txt", - "tmp/tailscale-update-3", - "tmp/tailscale-update-4/tailscale-1.3.0", - }, - }, - { - desc: "empty-target", - before: []string{}, - glob: "tmp/tailscale-update*/*.spk", - after: []string{}, - }, - { - desc: "keep-dirs", - before: []string{ - "tmp/tailscale-update-1/tailscale-1.0.0.spk", - }, - glob: "tmp/tailscale-update*", - after: []string{ - "tmp/tailscale-update-1/tailscale-1.0.0.spk", - }, - }, - { - desc: "no-follow-symlinks", - before: []string{ - "MSICache/tailscale-1.0.0.msi", - "MSICache/tailscale-1.1.0.msi", - "MSICache/readme.txt", - }, - symlinks: map[string]string{ - "MSICache/tailscale-1.3.0.msi": "MSICache/tailscale-1.0.0.msi", - "MSICache/tailscale-1.4.0.msi": "MSICache/readme.txt", - }, - glob: "MSICache/*.msi", - after: []string{ - "MSICache/tailscale-1.3.0.msi", - "MSICache/tailscale-1.4.0.msi", - "MSICache/readme.txt", - }, - }, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - dir := t.TempDir() - for _, p := range tt.before { - if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, p), []byte(tt.desc), 0600); err != nil { - t.Fatal(err) - } - } - for from, to := range tt.symlinks { - if err := os.Symlink(filepath.Join(dir, to), filepath.Join(dir, from)); err != nil { - t.Fatal(err) - } - } - - up := &Updater{Arguments: Arguments{Logf: t.Logf}} - up.cleanupOldDownloads(filepath.Join(dir, tt.glob)) - - var after []string - if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if !d.IsDir() { - after = append(after, strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(dir)+"/")) - } - return nil - }); err != nil { - t.Fatal(err) - } - - sort.Strings(after) - sort.Strings(tt.after) - if !slices.Equal(after, tt.after) { - t.Errorf("got files after cleanup: %q, want: %q", after, tt.after) - } - }) - } -} - -func TestParseUnraidPluginVersion(t *testing.T) { - tests := []struct { - plgPath string - wantVer string - wantErr string - }{ - {plgPath: "testdata/tailscale-1.52.0.plg", wantVer: "1.52.0"}, - {plgPath: "testdata/tailscale-1.54.0.plg", wantVer: "1.54.0"}, - {plgPath: "testdata/tailscale-nover.plg", wantErr: "version not found in plg file"}, - {plgPath: "testdata/tailscale-nover-path-mentioned.plg", wantErr: "version not found in plg file"}, - } - for _, tt := range tests { - t.Run(tt.plgPath, func(t *testing.T) { - got, err := parseUnraidPluginVersion(tt.plgPath) - if got != tt.wantVer { - t.Errorf("got version: %q, want %q", got, tt.wantVer) - } - var gotErr string - if err != nil { - gotErr = err.Error() - } - if gotErr != tt.wantErr { - t.Errorf("got error: %q, want %q", gotErr, tt.wantErr) - } - }) - } -} - -func TestConfirm(t *testing.T) { - curTrack := CurrentTrack - defer func() { CurrentTrack = curTrack }() - - tests := []struct { - desc string - fromTrack string - toTrack string - fromVer string - toVer string - confirm func(string) bool - want bool - }{ - { - desc: "on latest stable", - fromTrack: StableTrack, - toTrack: StableTrack, - fromVer: "1.66.0", - toVer: "1.66.0", - want: false, - }, - { - desc: "stable upgrade", - fromTrack: StableTrack, - toTrack: StableTrack, - fromVer: "1.66.0", - toVer: "1.68.0", - want: true, - }, - { - desc: "unstable upgrade", - fromTrack: UnstableTrack, - toTrack: UnstableTrack, - fromVer: "1.67.1", - toVer: "1.67.2", - want: true, - }, - { - desc: "from stable to unstable", - fromTrack: StableTrack, - toTrack: UnstableTrack, - fromVer: "1.66.0", - toVer: "1.67.1", - want: true, - }, - { - desc: "from unstable to stable", - fromTrack: UnstableTrack, - toTrack: StableTrack, - fromVer: "1.67.1", - toVer: "1.66.0", - want: true, - }, - { - desc: "confirm callback rejects", - fromTrack: StableTrack, - toTrack: StableTrack, - fromVer: "1.66.0", - toVer: "1.66.1", - confirm: func(string) bool { - return false - }, - want: false, - }, - { - desc: "confirm callback allows", - fromTrack: StableTrack, - toTrack: StableTrack, - fromVer: "1.66.0", - toVer: "1.66.1", - confirm: func(string) bool { - return true - }, - want: true, - }, - { - desc: "downgrade", - fromTrack: StableTrack, - toTrack: StableTrack, - fromVer: "1.66.1", - toVer: "1.66.0", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - CurrentTrack = tt.fromTrack - up := Updater{ - currentVersion: tt.fromVer, - Arguments: Arguments{ - Track: tt.toTrack, - Confirm: tt.confirm, - Logf: t.Logf, - }, - } - - if got := up.confirm(tt.toVer); got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} diff --git a/clientupdate/clientupdate_windows.go b/clientupdate/clientupdate_windows.go index 9737229745332..1a873f7bbc74a 100644 --- a/clientupdate/clientupdate_windows.go +++ b/clientupdate/clientupdate_windows.go @@ -18,9 +18,9 @@ import ( "strings" "github.com/google/uuid" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/util/winutil/authenticode" "golang.org/x/sys/windows" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/authenticode" ) const ( diff --git a/clientupdate/distsign/distsign.go b/clientupdate/distsign/distsign.go index eba4b9267b119..41e74d328b0bf 100644 --- a/clientupdate/distsign/distsign.go +++ b/clientupdate/distsign/distsign.go @@ -54,11 +54,11 @@ import ( "time" "github.com/hdevalence/ed25519consensus" + "github.com/sagernet/tailscale/net/tshttpproxy" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/httpm" + "github.com/sagernet/tailscale/util/must" "golang.org/x/crypto/blake2s" - "tailscale.com/net/tshttpproxy" - "tailscale.com/types/logger" - "tailscale.com/util/httpm" - "tailscale.com/util/must" ) const ( diff --git a/clientupdate/distsign/distsign_test.go b/clientupdate/distsign/distsign_test.go deleted file mode 100644 index 09a701f499198..0000000000000 --- a/clientupdate/distsign/distsign_test.go +++ /dev/null @@ -1,585 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package distsign - -import ( - "bytes" - "context" - "crypto/ed25519" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "golang.org/x/crypto/blake2s" -) - -func TestDownload(t *testing.T) { - srv := newTestServer(t) - c := srv.client(t) - - tests := []struct { - desc string - before func(*testing.T) - src string - want []byte - wantErr bool - }{ - { - desc: "missing file", - before: func(*testing.T) {}, - src: "hello", - wantErr: true, - }, - { - desc: "success", - before: func(*testing.T) { - srv.addSigned("hello", []byte("world")) - }, - src: "hello", - want: []byte("world"), - }, - { - desc: "no signature", - before: func(*testing.T) { - srv.add("hello", []byte("world")) - }, - src: "hello", - wantErr: true, - }, - { - desc: "bad signature", - before: func(*testing.T) { - srv.add("hello", []byte("world")) - srv.add("hello.sig", []byte("potato")) - }, - src: "hello", - wantErr: true, - }, - { - desc: "signed with untrusted key", - before: func(t *testing.T) { - srv.add("hello", []byte("world")) - srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world"))) - }, - src: "hello", - wantErr: true, - }, - { - desc: "signed with root key", - before: func(t *testing.T) { - srv.add("hello", []byte("world")) - srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world"))) - }, - src: "hello", - wantErr: true, - }, - { - desc: "bad signing key signature", - before: func(t *testing.T) { - srv.add("distsign.pub.sig", []byte("potato")) - srv.addSigned("hello", []byte("world")) - }, - src: "hello", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - srv.reset() - tt.before(t) - - dst := filepath.Join(t.TempDir(), tt.src) - t.Cleanup(func() { - os.Remove(dst) - }) - err := c.Download(context.Background(), tt.src, dst) - if err != nil { - if tt.wantErr { - return - } - t.Fatalf("unexpected error from Download(%q): %v", tt.src, err) - } - if tt.wantErr { - t.Fatalf("Download(%q) succeeded, expected an error", tt.src) - } - got, err := os.ReadFile(dst) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(tt.want, got) { - t.Errorf("Download(%q): got %q, want %q", tt.src, got, tt.want) - } - }) - } -} - -func TestValidateLocalBinary(t *testing.T) { - srv := newTestServer(t) - c := srv.client(t) - - tests := []struct { - desc string - before func(*testing.T) - src string - wantErr bool - }{ - { - desc: "missing file", - before: func(*testing.T) {}, - src: "hello", - wantErr: true, - }, - { - desc: "success", - before: func(*testing.T) { - srv.addSigned("hello", []byte("world")) - }, - src: "hello", - }, - { - desc: "contents changed", - before: func(*testing.T) { - srv.addSigned("hello", []byte("new world")) - }, - src: "hello", - wantErr: true, - }, - { - desc: "no signature", - before: func(*testing.T) { - srv.add("hello", []byte("world")) - }, - src: "hello", - wantErr: true, - }, - { - desc: "bad signature", - before: func(*testing.T) { - srv.add("hello", []byte("world")) - srv.add("hello.sig", []byte("potato")) - }, - src: "hello", - wantErr: true, - }, - { - desc: "signed with untrusted key", - before: func(t *testing.T) { - srv.add("hello", []byte("world")) - srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world"))) - }, - src: "hello", - wantErr: true, - }, - { - desc: "signed with root key", - before: func(t *testing.T) { - srv.add("hello", []byte("world")) - srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world"))) - }, - src: "hello", - wantErr: true, - }, - { - desc: "bad signing key signature", - before: func(t *testing.T) { - srv.add("distsign.pub.sig", []byte("potato")) - srv.addSigned("hello", []byte("world")) - }, - src: "hello", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - srv.reset() - - // First just do a successful Download. - want := []byte("world") - srv.addSigned("hello", want) - dst := filepath.Join(t.TempDir(), tt.src) - err := c.Download(context.Background(), tt.src, dst) - if err != nil { - t.Fatalf("unexpected error from Download(%q): %v", tt.src, err) - } - got, err := os.ReadFile(dst) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(want, got) { - t.Errorf("Download(%q): got %q, want %q", tt.src, got, want) - } - - // Now we reset srv with the test case and validate against the local dst. - srv.reset() - tt.before(t) - - err = c.ValidateLocalBinary(tt.src, dst) - if err != nil { - if tt.wantErr { - return - } - t.Fatalf("unexpected error from ValidateLocalBinary(%q): %v", tt.src, err) - } - if tt.wantErr { - t.Fatalf("ValidateLocalBinary(%q) succeeded, expected an error", tt.src) - } - }) - } -} - -func TestRotateRoot(t *testing.T) { - srv := newTestServer(t) - c1 := srv.client(t) - ctx := context.Background() - - srv.addSigned("hello", []byte("world")) - if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed on a fresh server: %v", err) - } - - // Remove first root and replace it with a new key. - srv.roots = append(srv.roots[1:], newRootKeyPair(t)) - - // Old client can still download files because it still trusts the old - // root key. - if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after root rotation on old client: %v", err) - } - // New client should fail download because current signing key is signed by - // the revoked root that new client doesn't trust. - c2 := srv.client(t) - if err := c2.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err == nil { - t.Fatalf("Download succeeded on new client, but signing key is signed with revoked root key") - } - // Re-sign signing key with another valid root that client still trusts. - srv.resignSigningKeys() - // Both old and new clients should now be able to download. - // - // Note: we don't need to re-sign the "hello" file because signing key - // didn't change (only signing key's signature). - if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after root rotation on old client with re-signed signing key: %v", err) - } - if err := c2.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after root rotation on new client with re-signed signing key: %v", err) - } -} - -func TestRotateSigning(t *testing.T) { - srv := newTestServer(t) - c := srv.client(t) - ctx := context.Background() - - srv.addSigned("hello", []byte("world")) - if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed on a fresh server: %v", err) - } - - // Replace signing key but don't publish it yet. - srv.sign = append(srv.sign, newSigningKeyPair(t)) - if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after new signing key added but before publishing it: %v", err) - } - - // Publish new signing key bundle with both keys. - srv.resignSigningKeys() - if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after new signing key was published: %v", err) - } - - // Re-sign the "hello" file with new signing key. - srv.add("hello.sig", srv.sign[1].sign([]byte("world"))) - if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after re-signing with new signing key: %v", err) - } - - // Drop the old signing key. - srv.sign = srv.sign[1:] - srv.resignSigningKeys() - if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after removing old signing key: %v", err) - } - - // Add another key and re-sign the file with it *before* publishing. - srv.sign = append(srv.sign, newSigningKeyPair(t)) - srv.add("hello.sig", srv.sign[1].sign([]byte("world"))) - if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err == nil { - t.Fatalf("Download succeeded when signed with a not-yet-published signing key") - } - // Fix this by publishing the new key. - srv.resignSigningKeys() - if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil { - t.Fatalf("Download failed after publishing new signing key: %v", err) - } -} - -func TestParseRootKey(t *testing.T) { - tests := []struct { - desc string - generate func() ([]byte, []byte, error) - wantErr bool - }{ - { - desc: "valid", - generate: GenerateRootKey, - }, - { - desc: "signing", - generate: GenerateSigningKey, - wantErr: true, - }, - { - desc: "nil", - generate: func() ([]byte, []byte, error) { return nil, nil, nil }, - wantErr: true, - }, - { - desc: "invalid PEM tag", - generate: func() ([]byte, []byte, error) { - priv, pub, err := GenerateRootKey() - priv = bytes.Replace(priv, []byte("ROOT "), nil, -1) - return priv, pub, err - }, - wantErr: true, - }, - { - desc: "not PEM", - generate: func() ([]byte, []byte, error) { return []byte("s3cr3t"), nil, nil }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - priv, _, err := tt.generate() - if err != nil { - t.Fatal(err) - } - r, err := ParseRootKey(priv) - if err != nil { - if tt.wantErr { - return - } - t.Fatalf("unexpected error: %v", err) - } - if tt.wantErr { - t.Fatal("expected non-nil error") - } - if r == nil { - t.Errorf("got nil error and nil RootKey") - } - }) - } -} - -func TestParseSigningKey(t *testing.T) { - tests := []struct { - desc string - generate func() ([]byte, []byte, error) - wantErr bool - }{ - { - desc: "valid", - generate: GenerateSigningKey, - }, - { - desc: "root", - generate: GenerateRootKey, - wantErr: true, - }, - { - desc: "nil", - generate: func() ([]byte, []byte, error) { return nil, nil, nil }, - wantErr: true, - }, - { - desc: "invalid PEM tag", - generate: func() ([]byte, []byte, error) { - priv, pub, err := GenerateSigningKey() - priv = bytes.Replace(priv, []byte("SIGNING "), nil, -1) - return priv, pub, err - }, - wantErr: true, - }, - { - desc: "not PEM", - generate: func() ([]byte, []byte, error) { return []byte("s3cr3t"), nil, nil }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - priv, _, err := tt.generate() - if err != nil { - t.Fatal(err) - } - r, err := ParseSigningKey(priv) - if err != nil { - if tt.wantErr { - return - } - t.Fatalf("unexpected error: %v", err) - } - if tt.wantErr { - t.Fatal("expected non-nil error") - } - if r == nil { - t.Errorf("got nil error and nil SigningKey") - } - }) - } -} - -type testServer struct { - roots []rootKeyPair - sign []signingKeyPair - files map[string][]byte - srv *httptest.Server -} - -func newTestServer(t *testing.T) *testServer { - var roots []rootKeyPair - for range 3 { - roots = append(roots, newRootKeyPair(t)) - } - - ts := &testServer{ - roots: roots, - sign: []signingKeyPair{newSigningKeyPair(t)}, - } - ts.reset() - ts.srv = httptest.NewServer(ts) - t.Cleanup(ts.srv.Close) - return ts -} - -func (s *testServer) client(t *testing.T) *Client { - roots := make([]ed25519.PublicKey, 0, len(s.roots)) - for _, r := range s.roots { - pub, err := parseSinglePublicKey(r.pubRaw, pemTypeRootPublic) - if err != nil { - t.Fatalf("parsePublicKey: %v", err) - } - roots = append(roots, pub) - } - u, err := url.Parse(s.srv.URL) - if err != nil { - t.Fatal(err) - } - return &Client{ - logf: t.Logf, - roots: roots, - pkgsAddr: u, - } -} - -func (s *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/") - data, ok := s.files[path] - if !ok { - http.NotFound(w, r) - return - } - w.Write(data) -} - -func (s *testServer) addSigned(name string, data []byte) { - s.files[name] = data - s.files[name+".sig"] = s.sign[0].sign(data) -} - -func (s *testServer) add(name string, data []byte) { - s.files[name] = data -} - -func (s *testServer) reset() { - s.files = make(map[string][]byte) - s.resignSigningKeys() -} - -func (s *testServer) resignSigningKeys() { - var pubs [][]byte - for _, k := range s.sign { - pubs = append(pubs, k.pubRaw) - } - bundle := bytes.Join(pubs, []byte("\n")) - sig := s.roots[0].sign(bundle) - s.files["distsign.pub"] = bundle - s.files["distsign.pub.sig"] = sig -} - -type rootKeyPair struct { - *RootKey - keyPair -} - -func newRootKeyPair(t *testing.T) rootKeyPair { - privRaw, pubRaw, err := GenerateRootKey() - if err != nil { - t.Fatalf("GenerateRootKey: %v", err) - } - kp := keyPair{ - privRaw: privRaw, - pubRaw: pubRaw, - } - priv, err := parsePrivateKey(kp.privRaw, pemTypeRootPrivate) - if err != nil { - t.Fatalf("parsePrivateKey: %v", err) - } - return rootKeyPair{ - RootKey: &RootKey{k: priv}, - keyPair: kp, - } -} - -func (s rootKeyPair) sign(bundle []byte) []byte { - sig, err := s.SignSigningKeys(bundle) - if err != nil { - panic(err) - } - return sig -} - -type signingKeyPair struct { - *SigningKey - keyPair -} - -func newSigningKeyPair(t *testing.T) signingKeyPair { - privRaw, pubRaw, err := GenerateSigningKey() - if err != nil { - t.Fatalf("GenerateSigningKey: %v", err) - } - kp := keyPair{ - privRaw: privRaw, - pubRaw: pubRaw, - } - priv, err := parsePrivateKey(kp.privRaw, pemTypeSigningPrivate) - if err != nil { - t.Fatalf("parsePrivateKey: %v", err) - } - return signingKeyPair{ - SigningKey: &SigningKey{k: priv}, - keyPair: kp, - } -} - -func (s signingKeyPair) sign(blob []byte) []byte { - hash := blake2s.Sum256(blob) - sig, err := s.SignPackageHash(hash[:], int64(len(blob))) - if err != nil { - panic(err) - } - return sig -} - -type keyPair struct { - privRaw []byte - pubRaw []byte -} diff --git a/clientupdate/distsign/roots_test.go b/clientupdate/distsign/roots_test.go deleted file mode 100644 index 7a94529538ef1..0000000000000 --- a/clientupdate/distsign/roots_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package distsign - -import "testing" - -func TestParseRoots(t *testing.T) { - roots, err := parseRoots() - if err != nil { - t.Fatal(err) - } - if len(roots) == 0 { - t.Error("parseRoots returned no root keys") - } -} diff --git a/cmd/addlicense/main.go b/cmd/addlicense/main.go deleted file mode 100644 index a8fd9dd4ab96a..0000000000000 --- a/cmd/addlicense/main.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Program addlicense adds a license header to a file. -// It is intended for use with 'go generate', -// so it has a slightly weird usage. -package main - -import ( - "flag" - "fmt" - "os" - "os/exec" -) - -var ( - file = flag.String("file", "", "file to modify") -) - -func usage() { - fmt.Fprintf(os.Stderr, ` -usage: addlicense -file FILE -`[1:]) - - flag.PrintDefaults() - fmt.Fprintf(os.Stderr, ` -addlicense adds a Tailscale license to the beginning of file. - -It is intended for use with 'go generate', so it also runs a subcommand, -which presumably creates the file. - -Sample usage: - -addlicense -file pull_strings.go stringer -type=pull -`[1:]) - os.Exit(2) -} - -func main() { - flag.Usage = usage - flag.Parse() - if len(flag.Args()) == 0 { - flag.Usage() - } - cmd := exec.Command(flag.Arg(0), flag.Args()[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() - check(err) - b, err := os.ReadFile(*file) - check(err) - f, err := os.OpenFile(*file, os.O_TRUNC|os.O_WRONLY, 0644) - check(err) - _, err = fmt.Fprint(f, license) - check(err) - _, err = f.Write(b) - check(err) - err = f.Close() - check(err) -} - -func check(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -var license = ` -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -`[1:] diff --git a/cmd/build-webclient/build-webclient.go b/cmd/build-webclient/build-webclient.go deleted file mode 100644 index f92c0858fae25..0000000000000 --- a/cmd/build-webclient/build-webclient.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The build-webclient tool generates the static resources needed for the -// web client (code at client/web). -// -// # Running -// -// Meant to be invoked from the tailscale/web-client-prebuilt repo when -// updating the production built web client assets. To run it manually, -// you can use `./tool/go run ./misc/build-webclient` -package main - -import ( - "flag" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - - "tailscale.com/util/precompress" -) - -var ( - outDir = flag.String("outDir", "build/", "path to output directory") -) - -func main() { - flag.Parse() - - // The toolDir flag is relative to the current working directory, - // so we need to resolve it to an absolute path. - toolDir, err := filepath.Abs("./tool") - if err != nil { - log.Fatalf("Cannot resolve tool-dir: %v", err) - } - - if err := build(toolDir, "client/web"); err != nil { - log.Fatalf("%v", err) - } -} - -func build(toolDir, appDir string) error { - if err := os.Chdir(appDir); err != nil { - return fmt.Errorf("Cannot change cwd: %w", err) - } - - if err := yarn(toolDir); err != nil { - return fmt.Errorf("install failed: %w", err) - } - - if err := yarn(toolDir, "lint"); err != nil { - return fmt.Errorf("lint failed: %w", err) - } - - if err := yarn(toolDir, "build", "--outDir="+*outDir, "--emptyOutDir"); err != nil { - return fmt.Errorf("build failed: %w", err) - } - - var compressedFiles []string - if err := precompress.PrecompressDir(*outDir, precompress.Options{ - ProgressFn: func(path string) { - log.Printf("Pre-compressing %v\n", path) - compressedFiles = append(compressedFiles, path) - }, - }); err != nil { - return fmt.Errorf("Cannot precompress: %w", err) - } - - // Cleanup pre-compressed files. - for _, f := range compressedFiles { - if err := os.Remove(f); err != nil { - log.Printf("Failed to cleanup %q: %v", f, err) - } - // Removing intermediate ".br" version, we use ".gz" asset. - if err := os.Remove(f + ".br"); err != nil { - log.Printf("Failed to cleanup %q: %v", f+".gz", err) - } - } - - return nil -} - -func yarn(toolDir string, args ...string) error { - args = append([]string{"--silent", "--non-interactive"}, args...) - return run(filepath.Join(toolDir, "yarn"), args...) -} - -func run(name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} diff --git a/cmd/cloner/cloner.go b/cmd/cloner/cloner.go deleted file mode 100644 index a1ffc30feafb2..0000000000000 --- a/cmd/cloner/cloner.go +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Cloner is a tool to automate the creation of a Clone method. -// -// The result of the Clone method aliases no memory that can be edited -// with the original. -// -// This tool makes lots of implicit assumptions about the types you feed it. -// In particular, it can only write relatively "shallow" Clone methods. -// That is, if a type contains another named struct type, cloner assumes that -// named type will also have a Clone method. -package main - -import ( - "bytes" - "flag" - "fmt" - "go/types" - "log" - "os" - "strings" - - "tailscale.com/util/codegen" -) - -var ( - flagTypes = flag.String("type", "", "comma-separated list of types; required") - flagBuildTags = flag.String("tags", "", "compiler build tags to apply") - flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func") -) - -func main() { - log.SetFlags(0) - log.SetPrefix("cloner: ") - flag.Parse() - if len(*flagTypes) == 0 { - flag.Usage() - os.Exit(2) - } - typeNames := strings.Split(*flagTypes, ",") - - pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".") - if err != nil { - log.Fatal(err) - } - it := codegen.NewImportTracker(pkg.Types) - buf := new(bytes.Buffer) - for _, typeName := range typeNames { - typ, ok := namedTypes[typeName].(*types.Named) - if !ok { - log.Fatalf("could not find type %s", typeName) - } - gen(buf, it, typ) - } - - w := func(format string, args ...any) { - fmt.Fprintf(buf, format+"\n", args...) - } - if *flagCloneFunc { - w("// Clone duplicates src into dst and reports whether it succeeded.") - w("// To succeed, must be of types <*T, *T> or <*T, **T>,") - w("// where T is one of %s.", *flagTypes) - w("func Clone(dst, src any) bool {") - w(" switch src := src.(type) {") - for _, typeName := range typeNames { - w(" case *%s:", typeName) - w(" switch dst := dst.(type) {") - w(" case *%s:", typeName) - w(" *dst = *src.Clone()") - w(" return true") - w(" case **%s:", typeName) - w(" *dst = src.Clone()") - w(" return true") - w(" }") - } - w(" }") - w(" return false") - w("}") - } - cloneOutput := pkg.Name + "_clone" - if *flagBuildTags == "test" { - cloneOutput += "_test" - } - cloneOutput += ".go" - if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil { - log.Fatal(err) - } -} - -func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) { - t, ok := typ.Underlying().(*types.Struct) - if !ok { - return - } - - name := typ.Obj().Name() - typeParams := typ.Origin().TypeParams() - _, typeParamNames := codegen.FormatTypeParams(typeParams, it) - nameWithParams := name + typeParamNames - fmt.Fprintf(buf, "// Clone makes a deep copy of %s.\n", name) - fmt.Fprintf(buf, "// The result aliases no memory with the original.\n") - fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", nameWithParams, nameWithParams) - writef := func(format string, args ...any) { - fmt.Fprintf(buf, "\t"+format+"\n", args...) - } - writef("if src == nil {") - writef("\treturn nil") - writef("}") - writef("dst := new(%s)", nameWithParams) - writef("*dst = *src") - for i := range t.NumFields() { - fname := t.Field(i).Name() - ft := t.Field(i).Type() - if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) { - continue - } - if named, _ := codegen.NamedTypeOf(ft); named != nil { - if codegen.IsViewType(ft) { - writef("dst.%s = src.%s", fname, fname) - continue - } - if !hasBasicUnderlying(ft) { - writef("dst.%s = *src.%s.Clone()", fname, fname) - continue - } - } - switch ft := ft.Underlying().(type) { - case *types.Slice: - if codegen.ContainsPointers(ft.Elem()) { - n := it.QualifiedName(ft.Elem()) - writef("if src.%s != nil {", fname) - writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname) - writef("for i := range dst.%s {", fname) - if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr { - writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname) - if codegen.ContainsPointers(ptr.Elem()) { - if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface { - it.Import("tailscale.com/types/ptr") - writef("\tdst.%s[i] = ptr.To((*src.%s[i]).Clone())", fname, fname) - } else { - writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname) - } - } else { - it.Import("tailscale.com/types/ptr") - writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname) - } - writef("}") - } else if ft.Elem().String() == "encoding/json.RawMessage" { - writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname) - } else if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface { - writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname) - } else { - writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname) - } - writef("}") - writef("}") - } else { - writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname) - } - case *types.Pointer: - base := ft.Elem() - hasPtrs := codegen.ContainsPointers(base) - if named, _ := codegen.NamedTypeOf(base); named != nil && hasPtrs { - writef("dst.%s = src.%s.Clone()", fname, fname) - continue - } - it.Import("tailscale.com/types/ptr") - writef("if dst.%s != nil {", fname) - if _, isIface := base.Underlying().(*types.Interface); isIface && hasPtrs { - writef("\tdst.%s = ptr.To((*src.%s).Clone())", fname, fname) - } else if !hasPtrs { - writef("\tdst.%s = ptr.To(*src.%s)", fname, fname) - } else { - writef("\t" + `panic("TODO pointers in pointers")`) - } - writef("}") - case *types.Map: - elem := ft.Elem() - if sliceType, isSlice := elem.(*types.Slice); isSlice { - n := it.QualifiedName(sliceType.Elem()) - writef("if dst.%s != nil {", fname) - writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem)) - writef("\tfor k := range src.%s {", fname) - // use zero-length slice instead of nil to ensure - // the key is always copied. - writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname) - writef("\t}") - writef("}") - } else if codegen.ContainsPointers(elem) { - writef("if dst.%s != nil {", fname) - writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem)) - writef("\tfor k, v := range src.%s {", fname) - - switch elem := elem.Underlying().(type) { - case *types.Pointer: - writef("\t\tif v == nil { dst.%s[k] = nil } else {", fname) - if base := elem.Elem().Underlying(); codegen.ContainsPointers(base) { - if _, isIface := base.(*types.Interface); isIface { - it.Import("tailscale.com/types/ptr") - writef("\t\t\tdst.%s[k] = ptr.To((*v).Clone())", fname) - } else { - writef("\t\t\tdst.%s[k] = v.Clone()", fname) - } - } else { - it.Import("tailscale.com/types/ptr") - writef("\t\t\tdst.%s[k] = ptr.To(*v)", fname) - } - writef("}") - case *types.Interface: - if cloneResultType := methodResultType(elem, "Clone"); cloneResultType != nil { - if _, isPtr := cloneResultType.(*types.Pointer); isPtr { - writef("\t\tdst.%s[k] = *(v.Clone())", fname) - } else { - writef("\t\tdst.%s[k] = v.Clone()", fname) - } - } else { - writef(`panic("%s (%v) does not have a Clone method")`, fname, elem) - } - default: - writef("\t\tdst.%s[k] = *(v.Clone())", fname) - } - - writef("\t}") - writef("}") - } else { - it.Import("maps") - writef("\tdst.%s = maps.Clone(src.%s)", fname, fname) - } - case *types.Interface: - // If ft is an interface with a "Clone() ft" method, it can be used to clone the field. - // This includes scenarios where ft is a constrained type parameter. - if cloneResultType := methodResultType(ft, "Clone"); cloneResultType.Underlying() == ft { - writef("dst.%s = src.%s.Clone()", fname, fname) - continue - } - writef(`panic("%s (%v) does not have a compatible Clone method")`, fname, ft) - default: - writef(`panic("TODO: %s (%T)")`, fname, ft) - } - } - writef("return dst") - fmt.Fprintf(buf, "}\n\n") - - buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it)) -} - -// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map. -func hasBasicUnderlying(typ types.Type) bool { - switch typ.Underlying().(type) { - case *types.Slice, *types.Map: - return true - default: - return false - } -} - -func methodResultType(typ types.Type, method string) types.Type { - viewMethod := codegen.LookupMethod(typ, method) - if viewMethod == nil { - return nil - } - sig, ok := viewMethod.Type().(*types.Signature) - if !ok || sig.Results().Len() != 1 { - return nil - } - return sig.Results().At(0).Type() -} diff --git a/cmd/cloner/cloner_test.go b/cmd/cloner/cloner_test.go deleted file mode 100644 index d8d5df3cb040c..0000000000000 --- a/cmd/cloner/cloner_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause -package main - -import ( - "reflect" - "testing" - - "tailscale.com/cmd/cloner/clonerex" -) - -func TestSliceContainer(t *testing.T) { - num := 5 - examples := []struct { - name string - in *clonerex.SliceContainer - }{ - { - name: "nil", - in: nil, - }, - { - name: "zero", - in: &clonerex.SliceContainer{}, - }, - { - name: "empty", - in: &clonerex.SliceContainer{ - Slice: []*int{}, - }, - }, - { - name: "nils", - in: &clonerex.SliceContainer{ - Slice: []*int{nil, nil, nil, nil, nil}, - }, - }, - { - name: "one", - in: &clonerex.SliceContainer{ - Slice: []*int{&num}, - }, - }, - { - name: "several", - in: &clonerex.SliceContainer{ - Slice: []*int{&num, &num, &num, &num, &num}, - }, - }, - } - - for _, ex := range examples { - t.Run(ex.name, func(t *testing.T) { - out := ex.in.Clone() - if !reflect.DeepEqual(ex.in, out) { - t.Errorf("Clone() = %v, want %v", out, ex.in) - } - }) - } -} diff --git a/cmd/cloner/clonerex/clonerex.go b/cmd/cloner/clonerex/clonerex.go deleted file mode 100644 index 96bf8a0bd6e9d..0000000000000 --- a/cmd/cloner/clonerex/clonerex.go +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer - -// Package clonerex is an example package for the cloner tool. -package clonerex - -type SliceContainer struct { - Slice []*int -} diff --git a/cmd/cloner/clonerex/clonerex_clone.go b/cmd/cloner/clonerex/clonerex_clone.go deleted file mode 100644 index e334a4e3a1bf4..0000000000000 --- a/cmd/cloner/clonerex/clonerex_clone.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT. - -package clonerex - -import ( - "tailscale.com/types/ptr" -) - -// Clone makes a deep copy of SliceContainer. -// The result aliases no memory with the original. -func (src *SliceContainer) Clone() *SliceContainer { - if src == nil { - return nil - } - dst := new(SliceContainer) - *dst = *src - if src.Slice != nil { - dst.Slice = make([]*int, len(src.Slice)) - for i := range dst.Slice { - if src.Slice[i] == nil { - dst.Slice[i] = nil - } else { - dst.Slice[i] = ptr.To(*src.Slice[i]) - } - } - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _SliceContainerCloneNeedsRegeneration = SliceContainer(struct { - Slice []*int -}{}) - -// Clone duplicates src into dst and reports whether it succeeded. -// To succeed, must be of types <*T, *T> or <*T, **T>, -// where T is one of SliceContainer. -func Clone(dst, src any) bool { - switch src := src.(type) { - case *SliceContainer: - switch dst := dst.(type) { - case *SliceContainer: - *dst = *src.Clone() - return true - case **SliceContainer: - *dst = src.Clone() - return true - } - } - return false -} diff --git a/cmd/connector-gen/README.md b/cmd/connector-gen/README.md deleted file mode 100644 index 071c3ee1fb899..0000000000000 --- a/cmd/connector-gen/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# connector-gen - -Generate Tailscale app connector configuration details from third party data. - -Tailscale app connectors are used to dynamically route traffic for domain names -via specific nodes on a tailnet. For larger upstream domains this may involve a -large number of domains or routes, and fully dynamic discovery may be slower or -involve more manual labor than ideal. This can be accelerated by -pre-configuration of the associated routes, based on data provided by the -target providers, which can be used to set precise `autoApprovers` routes, and -also to pre-populate the subnet routes via `--advertise-routes` avoiding -frequent routing reconfiguration that may otherwise occur while routes are -first being discovered and advertised by the connectors. - - diff --git a/cmd/connector-gen/advertise-routes.go b/cmd/connector-gen/advertise-routes.go deleted file mode 100644 index 446f4906a4d65..0000000000000 --- a/cmd/connector-gen/advertise-routes.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "fmt" - "strings" - - "go4.org/netipx" -) - -func advertiseRoutes(set *netipx.IPSet) { - fmt.Println() - prefixes := set.Prefixes() - pfxs := make([]string, 0, len(prefixes)) - for _, pfx := range prefixes { - pfxs = append(pfxs, pfx.String()) - } - fmt.Printf("--advertise-routes=%s", strings.Join(pfxs, ",")) - fmt.Println() -} diff --git a/cmd/connector-gen/aws.go b/cmd/connector-gen/aws.go deleted file mode 100644 index bd2632ae27960..0000000000000 --- a/cmd/connector-gen/aws.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "net/netip" - - "go4.org/netipx" -) - -// See https://docs.aws.amazon.com/vpc/latest/userguide/aws-ip-ranges.html - -type AWSMeta struct { - SyncToken string `json:"syncToken"` - CreateDate string `json:"createDate"` - Prefixes []struct { - IPPrefix string `json:"ip_prefix"` - Region string `json:"region"` - Service string `json:"service"` - NetworkBorderGroup string `json:"network_border_group"` - } `json:"prefixes"` - Ipv6Prefixes []struct { - Ipv6Prefix string `json:"ipv6_prefix"` - Region string `json:"region"` - Service string `json:"service"` - NetworkBorderGroup string `json:"network_border_group"` - } `json:"ipv6_prefixes"` -} - -func aws() { - r, err := http.Get("https://ip-ranges.amazonaws.com/ip-ranges.json") - if err != nil { - log.Fatal(err) - } - defer r.Body.Close() - - var aws AWSMeta - if err := json.NewDecoder(r.Body).Decode(&aws); err != nil { - log.Fatal(err) - } - - var ips netipx.IPSetBuilder - - for _, prefix := range aws.Prefixes { - ips.AddPrefix(netip.MustParsePrefix(prefix.IPPrefix)) - } - for _, prefix := range aws.Ipv6Prefixes { - ips.AddPrefix(netip.MustParsePrefix(prefix.Ipv6Prefix)) - } - - set, err := ips.IPSet() - if err != nil { - log.Fatal(err) - } - - fmt.Println(`"routes": [`) - for _, pfx := range set.Prefixes() { - fmt.Printf(`"%s": ["tag:connector"],%s`, pfx.String(), "\n") - } - fmt.Println(`]`) - - advertiseRoutes(set) -} diff --git a/cmd/connector-gen/connector-gen.go b/cmd/connector-gen/connector-gen.go deleted file mode 100644 index 6947f6410a96f..0000000000000 --- a/cmd/connector-gen/connector-gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// connector-gen is a tool to generate app connector configuration and flags from service provider address data. -package main - -import ( - "fmt" - "os" -) - -func help() { - fmt.Fprintf(os.Stderr, "Usage: %s [help|github|aws] [subcommand-arguments]\n", os.Args[0]) -} - -func main() { - if len(os.Args) < 2 { - help() - os.Exit(128) - } - - switch os.Args[1] { - case "help", "-h", "--help": - help() - os.Exit(0) - case "github": - github() - case "aws": - aws() - default: - help() - os.Exit(128) - } -} diff --git a/cmd/connector-gen/github.go b/cmd/connector-gen/github.go deleted file mode 100644 index def40872d52c1..0000000000000 --- a/cmd/connector-gen/github.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "net/netip" - "slices" - "strings" - - "go4.org/netipx" -) - -// See https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-githubs-ip-addresses - -type GithubMeta struct { - VerifiablePasswordAuthentication bool `json:"verifiable_password_authentication"` - SSHKeyFingerprints struct { - Sha256Ecdsa string `json:"SHA256_ECDSA"` - Sha256Ed25519 string `json:"SHA256_ED25519"` - Sha256Rsa string `json:"SHA256_RSA"` - } `json:"ssh_key_fingerprints"` - SSHKeys []string `json:"ssh_keys"` - Hooks []string `json:"hooks"` - Web []string `json:"web"` - API []string `json:"api"` - Git []string `json:"git"` - GithubEnterpriseImporter []string `json:"github_enterprise_importer"` - Packages []string `json:"packages"` - Pages []string `json:"pages"` - Importer []string `json:"importer"` - Actions []string `json:"actions"` - Dependabot []string `json:"dependabot"` - Domains struct { - Website []string `json:"website"` - Codespaces []string `json:"codespaces"` - Copilot []string `json:"copilot"` - Packages []string `json:"packages"` - } `json:"domains"` -} - -func github() { - r, err := http.Get("https://api.github.com/meta") - if err != nil { - log.Fatal(err) - } - - var ghm GithubMeta - - if err := json.NewDecoder(r.Body).Decode(&ghm); err != nil { - log.Fatal(err) - } - r.Body.Close() - - var ips netipx.IPSetBuilder - - var lists []string - lists = append(lists, ghm.Hooks...) - lists = append(lists, ghm.Web...) - lists = append(lists, ghm.API...) - lists = append(lists, ghm.Git...) - lists = append(lists, ghm.GithubEnterpriseImporter...) - lists = append(lists, ghm.Packages...) - lists = append(lists, ghm.Pages...) - lists = append(lists, ghm.Importer...) - lists = append(lists, ghm.Actions...) - lists = append(lists, ghm.Dependabot...) - - for _, s := range lists { - ips.AddPrefix(netip.MustParsePrefix(s)) - } - - set, err := ips.IPSet() - if err != nil { - log.Fatal(err) - } - - fmt.Println(`"routes": [`) - for _, pfx := range set.Prefixes() { - fmt.Printf(`"%s": ["tag:connector"],%s`, pfx.String(), "\n") - } - fmt.Println(`]`) - - fmt.Println() - - var domains []string - domains = append(domains, ghm.Domains.Website...) - domains = append(domains, ghm.Domains.Codespaces...) - domains = append(domains, ghm.Domains.Copilot...) - domains = append(domains, ghm.Domains.Packages...) - slices.Sort(domains) - domains = slices.Compact(domains) - - var bareDomains []string - for _, domain := range domains { - trimmed := strings.TrimPrefix(domain, "*.") - if trimmed != domain { - bareDomains = append(bareDomains, trimmed) - } - } - domains = append(domains, bareDomains...) - slices.Sort(domains) - domains = slices.Compact(domains) - - fmt.Println(`"domains": [`) - for _, domain := range domains { - fmt.Printf(`"%s",%s`, domain, "\n") - } - fmt.Println(`]`) - - advertiseRoutes(set) -} diff --git a/cmd/containerboot/forwarding.go b/cmd/containerboot/forwarding.go deleted file mode 100644 index 04d34836c92d8..0000000000000 --- a/cmd/containerboot/forwarding.go +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "context" - "fmt" - "log" - "net" - "net/netip" - "os" - "path/filepath" - "strings" - - "tailscale.com/util/linuxfw" -) - -// ensureIPForwarding enables IPv4/IPv6 forwarding for the container. -func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error { - var ( - v4Forwarding, v6Forwarding bool - ) - if clusterProxyTargetIP != "" { - proxyIP, err := netip.ParseAddr(clusterProxyTargetIP) - if err != nil { - return fmt.Errorf("invalid cluster destination IP: %v", err) - } - if proxyIP.Is4() { - v4Forwarding = true - } else { - v6Forwarding = true - } - } - if tailnetTargetIP != "" { - proxyIP, err := netip.ParseAddr(tailnetTargetIP) - if err != nil { - return fmt.Errorf("invalid tailnet destination IP: %v", err) - } - if proxyIP.Is4() { - v4Forwarding = true - } else { - v6Forwarding = true - } - } - // Currently we only proxy traffic to the IPv4 address of the tailnet - // target. - if tailnetTargetFQDN != "" { - v4Forwarding = true - } - if routes != nil && *routes != "" { - for _, route := range strings.Split(*routes, ",") { - cidr, err := netip.ParsePrefix(route) - if err != nil { - return fmt.Errorf("invalid subnet route: %v", err) - } - if cidr.Addr().Is4() { - v4Forwarding = true - } else { - v6Forwarding = true - } - } - } - return enableIPForwarding(v4Forwarding, v6Forwarding, root) -} - -func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error { - var paths []string - if v4Forwarding { - paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward")) - } - if v6Forwarding { - paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding")) - } - - // In some common configurations (e.g. default docker, - // kubernetes), the container environment denies write access to - // most sysctls, including IP forwarding controls. Check the - // sysctl values before trying to change them, so that we - // gracefully do nothing if the container's already been set up - // properly by e.g. a k8s initContainer. - for _, path := range paths { - bs, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("reading %q: %w", path, err) - } - if v := strings.TrimSpace(string(bs)); v != "1" { - if err := os.WriteFile(path, []byte("1"), 0644); err != nil { - return fmt.Errorf("enabling %q: %w", path, err) - } - } - } - return nil -} - -func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error { - dst, err := netip.ParseAddr(dstStr) - if err != nil { - return err - } - var local netip.Addr - for _, pfx := range tsIPs { - if !pfx.IsSingleIP() { - continue - } - if pfx.Addr().Is4() != dst.Is4() { - continue - } - local = pfx.Addr() - break - } - if !local.IsValid() { - return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs) - } - if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil { - return fmt.Errorf("installing egress proxy rules: %w", err) - } - if err := nfr.EnsureSNATForDst(local, dst); err != nil { - return fmt.Errorf("installing egress proxy rules: %w", err) - } - if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil { - return fmt.Errorf("installing egress proxy rules: %w", err) - } - return nil -} - -// installTSForwardingRuleForDestination accepts a destination address and a -// list of node's tailnet addresses, sets up rules to forward traffic for -// destination to the tailnet IP matching the destination IP family. -// Destination can be Pod IP of this node. -func installTSForwardingRuleForDestination(_ context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error { - dst, err := netip.ParseAddr(dstFilter) - if err != nil { - return err - } - var local netip.Addr - for _, pfx := range tsIPs { - if !pfx.IsSingleIP() { - continue - } - if pfx.Addr().Is4() != dst.Is4() { - continue - } - local = pfx.Addr() - break - } - if !local.IsValid() { - return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs) - } - if err := nfr.AddDNATRule(dst, local); err != nil { - return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err) - } - return nil -} - -func installIngressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error { - dst, err := netip.ParseAddr(dstStr) - if err != nil { - return err - } - var local netip.Addr - proxyHasIPv4Address := false - for _, pfx := range tsIPs { - if !pfx.IsSingleIP() { - continue - } - if pfx.Addr().Is4() { - proxyHasIPv4Address = true - } - if pfx.Addr().Is4() != dst.Is4() { - continue - } - local = pfx.Addr() - break - } - if proxyHasIPv4Address && dst.Is6() { - log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156") - } - if !local.IsValid() { - return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs) - } - if err := nfr.AddDNATRule(local, dst); err != nil { - return fmt.Errorf("installing ingress proxy rules: %w", err) - } - if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil { - return fmt.Errorf("installing ingress proxy rules: %w", err) - } - return nil -} - -func installIngressForwardingRuleForDNSTarget(_ context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error { - var ( - tsv4 netip.Addr - tsv6 netip.Addr - v4Backends []netip.Addr - v6Backends []netip.Addr - ) - for _, pfx := range tsIPs { - if pfx.IsSingleIP() && pfx.Addr().Is4() { - tsv4 = pfx.Addr() - continue - } - if pfx.IsSingleIP() && pfx.Addr().Is6() { - tsv6 = pfx.Addr() - continue - } - } - // TODO: log if more than one backend address is found and firewall is - // in nftables mode that only the first IP will be used. - for _, ip := range backendAddrs { - if ip.To4() != nil { - v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4()))) - } - if ip.To16() != nil { - v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16()))) - } - } - - // Enable IP forwarding here as opposed to at the start of containerboot - // as the IPv4/IPv6 requirements might have changed. - // For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is - // enabled by an init container, so in practice enabling forwarding here - // is only needed if this proxy has been configured by manually setting - // TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance. - if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil { - log.Printf("[unexpected] failed to ensure IP forwarding: %v", err) - } - - updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error { - if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil { - return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err) - } - // The backend might advertize MSS higher than that of the - // tailscale interfaces. Clamp MSS of packets going out via - // tailscale0 interface to its MTU to prevent broken connections - // in environments where path MTU discovery is not working. - if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil { - return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err) - } - return nil - } - - if len(v4Backends) != 0 { - if !tsv4.IsValid() { - log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs) - } else if err := updateFirewall(tsv4, v4Backends); err != nil { - return fmt.Errorf("Installing IPv4 firewall rules: %w", err) - } - } - if len(v6Backends) != 0 && !tsv6.IsValid() { - if !tsv6.IsValid() { - log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs) - } else if !nfr.HasIPV6NAT() { - log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs) - } else if err := updateFirewall(tsv6, v6Backends); err != nil { - return fmt.Errorf("Installing IPv6 firewall rules: %w", err) - } - } - return nil -} diff --git a/cmd/containerboot/healthz.go b/cmd/containerboot/healthz.go deleted file mode 100644 index fb7fccd968816..0000000000000 --- a/cmd/containerboot/healthz.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "log" - "net" - "net/http" - "sync" -) - -// healthz is a simple health check server, if enabled it returns 200 OK if -// this tailscale node currently has at least one tailnet IP address else -// returns 503. -type healthz struct { - sync.Mutex - hasAddrs bool -} - -func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) { - h.Lock() - defer h.Unlock() - if h.hasAddrs { - w.Write([]byte("ok")) - } else { - http.Error(w, "node currently has no tailscale IPs", http.StatusInternalServerError) - } -} - -// runHealthz runs a simple HTTP health endpoint on /healthz, listening on the -// provided address. A containerized tailscale instance is considered healthy if -// it has at least one tailnet IP address. -func runHealthz(addr string, h *healthz) { - lis, err := net.Listen("tcp", addr) - if err != nil { - log.Fatalf("error listening on the provided health endpoint address %q: %v", addr, err) - } - mux := http.NewServeMux() - mux.Handle("/healthz", h) - log.Printf("Running healthcheck endpoint at %s/healthz", addr) - hs := &http.Server{Handler: mux} - - go func() { - if err := hs.Serve(lis); err != nil { - log.Fatalf("failed running health endpoint: %v", err) - } - }() -} diff --git a/cmd/containerboot/kube.go b/cmd/containerboot/kube.go deleted file mode 100644 index 5a726c20b33e9..0000000000000 --- a/cmd/containerboot/kube.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "net/netip" - "os" - - "tailscale.com/kube/kubeapi" - "tailscale.com/kube/kubeclient" - "tailscale.com/tailcfg" -) - -// storeDeviceID writes deviceID to 'device_id' data field of the named -// Kubernetes Secret. -func storeDeviceID(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID) error { - s := &kubeapi.Secret{ - Data: map[string][]byte{ - "device_id": []byte(deviceID), - }, - } - return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container") -} - -// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields -// 'device_ips', 'device_fqdn' of the named Kubernetes Secret. -func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, addresses []netip.Prefix) error { - var ips []string - for _, addr := range addresses { - ips = append(ips, addr.Addr().String()) - } - deviceIPs, err := json.Marshal(ips) - if err != nil { - return err - } - - s := &kubeapi.Secret{ - Data: map[string][]byte{ - "device_fqdn": []byte(fqdn), - "device_ips": deviceIPs, - }, - } - return kc.StrategicMergePatchSecret(ctx, secretName, s, "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 := []kubeclient.JSONPatch{ - { - Op: "remove", - Path: "/data/authkey", - }, - } - if err := kc.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, m); err != nil { - if s, ok := err.(*kubeapi.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 - } - return err - } - return nil -} - -var kc kubeclient.Client - -func initKubeClient(root string) { - if root != "/" { - // If we are running in a test, we need to set the root path to the fake - // service account directory. - kubeclient.SetRootPathForTesting(root) - } - var err error - kc, err = kubeclient.New("tailscale-container") - if err != nil { - log.Fatalf("Error creating kube client: %v", err) - } - if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" { - // Derive the API server address from the environment variables - // Used to set http server in tests, or optionally enabled by flag - kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS"))) - } -} diff --git a/cmd/containerboot/kube_test.go b/cmd/containerboot/kube_test.go deleted file mode 100644 index 1a5730548838f..0000000000000 --- a/cmd/containerboot/kube_test.go +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "context" - "errors" - "testing" - - "github.com/google/go-cmp/cmp" - "tailscale.com/kube/kubeapi" - "tailscale.com/kube/kubeclient" -) - -func TestSetupKube(t *testing.T) { - tests := []struct { - name string - cfg *settings - wantErr bool - wantCfg *settings - kc kubeclient.Client - }{ - { - name: "TS_AUTHKEY set, state Secret exists", - cfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, false, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return nil, nil - }, - }, - wantCfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - }, - { - name: "TS_AUTHKEY set, state Secret does not exist, we have permissions to create it", - cfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, true, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return nil, &kubeapi.Status{Code: 404} - }, - }, - wantCfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - }, - { - name: "TS_AUTHKEY set, state Secret does not exist, we do not have permissions to create it", - cfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, false, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return nil, &kubeapi.Status{Code: 404} - }, - }, - wantCfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - wantErr: true, - }, - { - name: "TS_AUTHKEY set, we encounter a non-404 error when trying to retrieve the state Secret", - cfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, false, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return nil, &kubeapi.Status{Code: 403} - }, - }, - wantCfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - wantErr: true, - }, - { - name: "TS_AUTHKEY set, we encounter a non-404 error when trying to check Secret permissions", - cfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - wantCfg: &settings{ - AuthKey: "foo", - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, false, errors.New("broken") - }, - }, - wantErr: true, - }, - { - // Interactive login using URL in Pod logs - name: "TS_AUTHKEY not set, state Secret does not exist, we have permissions to create it", - cfg: &settings{ - KubeSecret: "foo", - }, - wantCfg: &settings{ - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, true, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return nil, &kubeapi.Status{Code: 404} - }, - }, - }, - { - // Interactive login using URL in Pod logs - name: "TS_AUTHKEY not set, state Secret exists, but does not contain auth key", - cfg: &settings{ - KubeSecret: "foo", - }, - wantCfg: &settings{ - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, false, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return &kubeapi.Secret{}, nil - }, - }, - }, - { - name: "TS_AUTHKEY not set, state Secret contains auth key, we do not have RBAC to patch it", - cfg: &settings{ - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return false, false, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil - }, - }, - wantCfg: &settings{ - KubeSecret: "foo", - }, - wantErr: true, - }, - { - name: "TS_AUTHKEY not set, state Secret contains auth key, we have RBAC to patch it", - cfg: &settings{ - KubeSecret: "foo", - }, - kc: &kubeclient.FakeClient{ - CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) { - return true, false, nil - }, - GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) { - return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil - }, - }, - wantCfg: &settings{ - KubeSecret: "foo", - AuthKey: "foo", - KubernetesCanPatch: true, - }, - }, - } - - for _, tt := range tests { - kc = tt.kc - t.Run(tt.name, func(t *testing.T) { - if err := tt.cfg.setupKube(context.Background()); (err != nil) != tt.wantErr { - t.Errorf("settings.setupKube() error = %v, wantErr %v", err, tt.wantErr) - } - if diff := cmp.Diff(*tt.cfg, *tt.wantCfg); diff != "" { - t.Errorf("unexpected contents of settings after running settings.setupKube()\n(-got +want):\n%s", diff) - } - }) - } -} diff --git a/cmd/containerboot/main.go b/cmd/containerboot/main.go deleted file mode 100644 index 17131faae08b8..0000000000000 --- a/cmd/containerboot/main.go +++ /dev/null @@ -1,745 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -// The containerboot binary is a wrapper for starting tailscaled in a container. -// It handles reading the desired mode of operation out of environment -// variables, bringing up and authenticating Tailscale, and any other -// kubernetes-specific side jobs. -// -// As with most container things, configuration is passed through environment -// variables. All configuration is optional. -// -// - TS_AUTHKEY: the authkey to use for login. -// - TS_HOSTNAME: the hostname to request for the node. -// - TS_ROUTES: subnet routes to advertise. Explicitly setting it to an empty -// value will cause containerboot to stop acting as a subnet router for any -// previously advertised routes. To accept routes, use TS_EXTRA_ARGS to pass -// in --accept-routes. -// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given -// destination defined by an IP address. -// - TS_EXPERIMENTAL_DEST_DNS_NAME: proxy all incoming Tailscale traffic to the given -// destination defined by a DNS name. The DNS name will be periodically resolved and firewall rules updated accordingly. -// This is currently intended to be used by the Kubernetes operator (ExternalName Services). -// This is an experimental env var and will likely change in the future. -// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given -// destination defined by an IP. -// - TS_TAILNET_TARGET_FQDN: proxy all incoming non-Tailscale traffic to the given -// destination defined by a MagicDNS name. -// - TS_TAILSCALED_EXTRA_ARGS: extra arguments to 'tailscaled'. -// - TS_EXTRA_ARGS: extra arguments to 'tailscale up'. -// - TS_USERSPACE: run with userspace networking (the default) -// instead of kernel networking. -// - TS_STATE_DIR: the directory in which to store tailscaled -// state. The data should persist across container -// restarts. -// - TS_ACCEPT_DNS: whether to use the tailnet's DNS configuration. -// - TS_KUBE_SECRET: the name of the Kubernetes secret in which to -// store tailscaled state. -// - TS_SOCKS5_SERVER: the address on which to listen for SOCKS5 -// proxying into the tailnet. -// - TS_OUTBOUND_HTTP_PROXY_LISTEN: the address on which to listen -// for HTTP proxying into the tailnet. -// - TS_SOCKET: the path where the tailscaled LocalAPI socket should -// be created. -// - TS_AUTH_ONCE: if true, only attempt to log in if not already -// logged in. If false (the default, for backwards -// compatibility), forcibly log in every time the -// container starts. -// - TS_SERVE_CONFIG: if specified, is the file path where the ipn.ServeConfig is located. -// It will be applied once tailscaled is up and running. If the file contains -// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN. -// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes, -// and will be re-applied when it changes. -// - TS_HEALTHCHECK_ADDR_PORT: if specified, an HTTP health endpoint will be -// served at /healthz at the provided address, which should be in form [
]:. -// If not set, no health check will be run. If set to :, addr will default to 0.0.0.0 -// The health endpoint will return 200 OK if this node has at least one tailnet IP address, -// otherwise returns 503. -// NB: the health criteria might change in the future. -// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a -// directory that containers tailscaled config in file. The config file needs to be -// named cap-.hujson. If this is set, TS_HOSTNAME, -// TS_EXTRA_ARGS, TS_AUTHKEY, -// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set, -// containerboot only runs `tailscaled --config ` -// and not `tailscale up` or `tailscale set`. -// The config file contents are currently read once on container start. -// NB: This env var is currently experimental and the logic will likely change! -// TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS: set to true to -// autoconfigure the default network interface for optimal performance for -// Tailscale subnet router/exit node. -// https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes -// NB: This env var is currently experimental and the logic will likely change! -// - EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS: if set to true -// and if this containerboot instance is an L7 ingress proxy (created by -// the Kubernetes operator), set up rules to allow proxying cluster traffic, -// received on the Pod IP of this node, to the ingress target in the cluster. -// This, in conjunction with MagicDNS name resolution in cluster, can be -// useful for cases where a cluster workload needs to access a target in -// cluster using the same hostname (in this case, the MagicDNS name of the ingress proxy) -// as a non-cluster workload on tailnet. -// This is only meant to be configured by the Kubernetes operator. -// -// When running on Kubernetes, containerboot defaults to storing state in the -// "tailscale" kube secret. To store state on local disk instead, set -// TS_KUBE_SECRET="" and TS_STATE_DIR=/path/to/storage/dir. The state dir should -// be persistent storage. -// -// Additionally, if TS_AUTHKEY is not set and the TS_KUBE_SECRET contains an -// "authkey" field, that key is used as the tailscale authkey. -package main - -import ( - "context" - "errors" - "fmt" - "io/fs" - "log" - "math" - "net" - "net/netip" - "os" - "os/signal" - "path/filepath" - "slices" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - - "golang.org/x/sys/unix" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - kubeutils "tailscale.com/k8s-operator" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/types/ptr" - "tailscale.com/util/deephash" - "tailscale.com/util/linuxfw" -) - -func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) { - if defaultBool("TS_TEST_FAKE_NETFILTER", false) { - return linuxfw.NewFakeIPTablesRunner(), nil - } - return linuxfw.New(logf, "") -} - -func main() { - log.SetPrefix("boot: ") - tailscale.I_Acknowledge_This_API_Is_Unstable = true - - cfg, err := configFromEnv() - if err != nil { - log.Fatalf("invalid configuration: %v", err) - } - - if !cfg.UserspaceMode { - if err := ensureTunFile(cfg.Root); err != nil { - log.Fatalf("Unable to create tuntap device file: %v", err) - } - if cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.Routes != nil || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" { - if err := ensureIPForwarding(cfg.Root, cfg.ProxyTargetIP, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil { - log.Printf("Failed to enable IP forwarding: %v", err) - log.Printf("To run tailscale as a proxy or router container, IP forwarding must be enabled.") - if cfg.InKubernetes { - log.Fatalf("You can either set the sysctls as a privileged initContainer, or run the tailscale container with privileged=true.") - } else { - log.Fatalf("You can fix this by running the container with privileged=true, or the equivalent in your container runtime that permits access to sysctls.") - } - } - } - } - - // Context is used for all setup stuff until we're in steady - // state, so that if something is hanging we eventually time out - // and crashloop the container. - bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if cfg.InKubernetes { - initKubeClient(cfg.Root) - if err := cfg.setupKube(bootCtx); err != nil { - log.Fatalf("error setting up for running on Kubernetes: %v", err) - } - } - - client, daemonProcess, err := startTailscaled(bootCtx, cfg) - if err != nil { - log.Fatalf("failed to bring up tailscale: %v", err) - } - killTailscaled := func() { - if err := daemonProcess.Signal(unix.SIGTERM); err != nil { - log.Fatalf("error shutting tailscaled down: %v", err) - } - } - defer killTailscaled() - - if cfg.EnableForwardingOptimizations { - if err := client.SetUDPGROForwarding(bootCtx); err != nil { - log.Printf("[unexpected] error enabling UDP GRO forwarding: %v", err) - } - } - - w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState) - if err != nil { - log.Fatalf("failed to watch tailscaled for updates: %v", err) - } - - // Now that we've started tailscaled, we can symlink the socket to the - // default location if needed. - const defaultTailscaledSocketPath = "/var/run/tailscale/tailscaled.sock" - if cfg.Socket != "" && cfg.Socket != defaultTailscaledSocketPath { - // If we were given a socket path, symlink it to the default location so - // that the CLI can find it without any extra flags. - // See #6849. - - dir := filepath.Dir(defaultTailscaledSocketPath) - err := os.MkdirAll(dir, 0700) - if err == nil { - err = syscall.Symlink(cfg.Socket, defaultTailscaledSocketPath) - } - if err != nil { - log.Printf("[warning] failed to symlink socket: %v\n\tTo interact with the Tailscale CLI please use `tailscale --socket=%q`", err, cfg.Socket) - } - } - - // Because we're still shelling out to `tailscale up` to get access to its - // flag parser, we have to stop watching the IPN bus so that we can block on - // the subcommand without stalling anything. Then once it's done, we resume - // watching the bus. - // - // Depending on the requested mode of operation, this auth step happens at - // different points in containerboot's lifecycle, hence the helper function. - didLogin := false - authTailscale := func() error { - if didLogin { - return nil - } - didLogin = true - w.Close() - if err := tailscaleUp(bootCtx, cfg); err != nil { - return fmt.Errorf("failed to auth tailscale: %v", err) - } - w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState) - if err != nil { - return fmt.Errorf("rewatching tailscaled for updates after auth: %v", err) - } - return nil - } - - if isTwoStepConfigAlwaysAuth(cfg) { - if err := authTailscale(); err != nil { - log.Fatalf("failed to auth tailscale: %v", err) - } - } - -authLoop: - for { - n, err := w.Next() - if err != nil { - log.Fatalf("failed to read from tailscaled: %v", err) - } - - if n.State != nil { - switch *n.State { - case ipn.NeedsLogin: - if isOneStepConfig(cfg) { - // This could happen if this is the first time tailscaled was run for this - // device and the auth key was not passed via the configfile. - log.Fatalf("invalid state: tailscaled daemon started with a config file, but tailscale is not logged in: ensure you pass a valid auth key in the config file.") - } - if err := authTailscale(); err != nil { - log.Fatalf("failed to auth tailscale: %v", err) - } - case ipn.NeedsMachineAuth: - log.Printf("machine authorization required, please visit the admin panel") - case ipn.Running: - // Technically, all we want is to keep monitoring the bus for - // netmap updates. However, in order to make the container crash - // if tailscale doesn't initially come up, the watch has a - // startup deadline on it. So, we have to break out of this - // watch loop, cancel the watch, and watch again with no - // deadline to continue monitoring for changes. - break authLoop - default: - log.Printf("tailscaled in state %q, waiting", *n.State) - } - } - } - - w.Close() - - ctx, cancel := contextWithExitSignalWatch() - defer cancel() - - if isTwoStepConfigAuthOnce(cfg) { - // Now that we are authenticated, we can set/reset any of the - // settings that we need to. - if err := tailscaleSet(ctx, cfg); err != nil { - log.Fatalf("failed to auth tailscale: %v", err) - } - } - - if cfg.ServeConfigPath != "" { - // Remove any serve config that may have been set by a previous run of - // containerboot, but only if we're providing a new one. - if err := client.SetServeConfig(ctx, new(ipn.ServeConfig)); err != nil { - log.Fatalf("failed to unset serve config: %v", err) - } - } - - if hasKubeStateStore(cfg) && isTwoStepConfigAuthOnce(cfg) { - // We were told to only auth once, so any secret-bound - // authkey is no longer needed. We don't strictly need to - // wipe it, but it's good hygiene. - log.Printf("Deleting authkey from kube secret") - if err := deleteAuthKey(ctx, cfg.KubeSecret); err != nil { - log.Fatalf("deleting authkey from kube secret: %v", err) - } - } - - w, err = client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState) - if err != nil { - log.Fatalf("rewatching tailscaled for updates after auth: %v", err) - } - - var ( - startupTasksDone = false - currentIPs deephash.Sum // tailscale IPs assigned to device - currentDeviceID deephash.Sum // device ID - currentDeviceEndpoints deephash.Sum // device FQDN and IPs - - currentEgressIPs deephash.Sum - - addrs []netip.Prefix - backendAddrs []net.IP - - certDomain = new(atomic.Pointer[string]) - certDomainChanged = make(chan bool, 1) - - h = &healthz{} // http server for the healthz endpoint - healthzRunner = sync.OnceFunc(func() { runHealthz(cfg.HealthCheckAddrPort, h) }) - ) - if cfg.ServeConfigPath != "" { - go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client) - } - var nfr linuxfw.NetfilterRunner - if isL3Proxy(cfg) { - nfr, err = newNetfilterRunner(log.Printf) - if err != nil { - log.Fatalf("error creating new netfilter runner: %v", err) - } - } - - // Setup for proxies that are configured to proxy to a target specified - // by a DNS name (TS_EXPERIMENTAL_DEST_DNS_NAME). - const defaultCheckPeriod = time.Minute * 10 // how often to check what IPs the DNS name resolves to - var ( - tc = make(chan string, 1) - failedResolveAttempts int - t *time.Timer = time.AfterFunc(defaultCheckPeriod, func() { - if cfg.ProxyTargetDNSName != "" { - tc <- "recheck" - } - }) - ) - // egressSvcsErrorChan will get an error sent to it if this containerboot instance is configured to expose 1+ - // egress services in HA mode and errored. - var egressSvcsErrorChan = make(chan error) - defer t.Stop() - // resetTimer resets timer for when to next attempt to resolve the DNS - // name for the proxy configured with TS_EXPERIMENTAL_DEST_DNS_NAME. The - // timer gets reset to 10 minutes from now unless the last resolution - // attempt failed. If one or more consecutive previous resolution - // attempts failed, the next resolution attempt will happen after the smallest - // of (10 minutes, 2 ^ number-of-consecutive-failed-resolution-attempts - // seconds) i.e 2s, 4s, 8s ... 10 minutes. - resetTimer := func(lastResolveFailed bool) { - if !lastResolveFailed { - log.Printf("reconfigureTimer: next DNS resolution attempt in %s", defaultCheckPeriod) - t.Reset(defaultCheckPeriod) - failedResolveAttempts = 0 - return - } - minDelay := 2 // 2 seconds - nextTick := time.Second * time.Duration(math.Pow(float64(minDelay), float64(failedResolveAttempts))) - if nextTick > defaultCheckPeriod { - nextTick = defaultCheckPeriod // cap at 10 minutes - } - log.Printf("reconfigureTimer: last DNS resolution attempt failed, next DNS resolution attempt in %v", nextTick) - t.Reset(nextTick) - failedResolveAttempts++ - } - - var egressSvcsNotify chan ipn.Notify - notifyChan := make(chan ipn.Notify) - errChan := make(chan error) - go func() { - for { - n, err := w.Next() - if err != nil { - errChan <- err - break - } else { - notifyChan <- n - } - } - }() - var wg sync.WaitGroup - -runLoop: - for { - select { - case <-ctx.Done(): - // Although killTailscaled() is deferred earlier, if we - // have started the reaper defined below, we need to - // kill tailscaled and let reaper clean up child - // processes. - killTailscaled() - break runLoop - case err := <-errChan: - log.Fatalf("failed to read from tailscaled: %v", err) - case n := <-notifyChan: - if n.State != nil && *n.State != ipn.Running { - // Something's gone wrong and we've left the authenticated state. - // Our container image never recovered gracefully from this, and the - // control flow required to make it work now is hard. So, just crash - // the container and rely on the container runtime to restart us, - // whereupon we'll go through initial auth again. - log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State) - } - if n.NetMap != nil { - addrs = n.NetMap.SelfNode.Addresses().AsSlice() - newCurrentIPs := deephash.Hash(&addrs) - ipsHaveChanged := newCurrentIPs != currentIPs - - // Store device ID in a Kubernetes Secret before - // setting up any routing rules. This ensures - // that, for containerboot instances that are - // Kubernetes operator proxies, the operator is - // able to retrieve the device ID from the - // Kubernetes Secret to clean up tailnet nodes - // for proxies whose route setup continuously - // fails. - deviceID := n.NetMap.SelfNode.StableID() - if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) { - if err := storeDeviceID(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID()); err != nil { - log.Fatalf("storing device ID in Kubernetes Secret: %v", err) - } - } - if cfg.TailnetTargetFQDN != "" { - var ( - egressAddrs []netip.Prefix - newCurentEgressIPs deephash.Sum - egressIPsHaveChanged bool - node tailcfg.NodeView - nodeFound bool - ) - for _, n := range n.NetMap.Peers { - if strings.EqualFold(n.Name(), cfg.TailnetTargetFQDN) { - node = n - nodeFound = true - break - } - } - if !nodeFound { - log.Printf("Tailscale node %q not found; it either does not exist, or not reachable because of ACLs", cfg.TailnetTargetFQDN) - break - } - egressAddrs = node.Addresses().AsSlice() - newCurentEgressIPs = deephash.Hash(&egressAddrs) - egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs - // The firewall rules get (re-)installed: - // - on startup - // - when the tailnet IPs of the tailnet target have changed - // - when the tailnet IPs of this node have changed - if (egressIPsHaveChanged || ipsHaveChanged) && len(egressAddrs) != 0 { - var rulesInstalled bool - for _, egressAddr := range egressAddrs { - ea := egressAddr.Addr() - if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) { - rulesInstalled = true - log.Printf("Installing forwarding rules for destination %v", ea.String()) - if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil { - log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err) - } - } - } - if !rulesInstalled { - log.Fatalf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT()) - } - } - currentEgressIPs = newCurentEgressIPs - } - if cfg.ProxyTargetIP != "" && len(addrs) != 0 && ipsHaveChanged { - log.Printf("Installing proxy rules") - if err := installIngressForwardingRule(ctx, cfg.ProxyTargetIP, addrs, nfr); err != nil { - log.Fatalf("installing ingress proxy rules: %v", err) - } - } - if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged { - newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName) - if err != nil { - log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err) - resetTimer(true) - continue - } - backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool { - return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) }) - })) - if backendsHaveChanged { - log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs) - if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil { - log.Fatalf("error installing ingress proxy rules: %v", err) - } - } - resetTimer(false) - backendAddrs = newBackendAddrs - } - if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) != 0 { - cd := n.NetMap.DNS.CertDomains[0] - prev := certDomain.Swap(ptr.To(cd)) - if prev == nil || *prev != cd { - select { - case certDomainChanged <- true: - default: - } - } - } - if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) != 0 { - log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP) - if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil { - log.Fatalf("installing egress proxy rules: %v", err) - } - } - // If this is a L7 cluster ingress proxy (set up - // by Kubernetes operator) and proxying of - // cluster traffic to the ingress target is - // enabled, set up proxy rule each time the - // tailnet IPs of this node change (including - // the first time they become available). - if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) != 0 { - log.Printf("installing rules to forward traffic for %s to node's tailnet IP", cfg.PodIP) - if err := installTSForwardingRuleForDestination(ctx, cfg.PodIP, addrs, nfr); err != nil { - log.Fatalf("installing rules to forward traffic to node's tailnet IP: %v", err) - } - } - currentIPs = newCurrentIPs - - // Only store device FQDN and IP addresses to - // Kubernetes Secret when any required proxy - // route setup has succeeded. IPs and FQDN are - // read from the Secret by the Tailscale - // Kubernetes operator and, for some proxy - // types, such as Tailscale Ingress, advertized - // on the Ingress status. Writing them to the - // Secret only after the proxy routing has been - // set up ensures that the operator does not - // advertize endpoints of broken proxies. - // TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'. - deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()} - if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) { - if err := storeDeviceEndpoints(ctx, cfg.KubeSecret, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil { - log.Fatalf("storing device IPs and FQDN in Kubernetes Secret: %v", err) - } - } - - if cfg.HealthCheckAddrPort != "" { - h.Lock() - h.hasAddrs = len(addrs) != 0 - h.Unlock() - healthzRunner() - } - if egressSvcsNotify != nil { - egressSvcsNotify <- n - } - } - if !startupTasksDone { - // For containerboot instances that act as TCP proxies (proxying traffic to an endpoint - // passed via one of the env vars that containerboot reads) and store state in a - // Kubernetes Secret, we consider startup tasks done at the point when device info has - // been successfully stored to state Secret. For all other containerboot instances, if - // we just get to this point the startup tasks can be considered done. - if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) { - // This log message is used in tests to detect when all - // post-auth configuration is done. - log.Println("Startup complete, waiting for shutdown signal") - startupTasksDone = true - - // Configure egress proxy. Egress proxy will set up firewall rules to proxy - // traffic to tailnet targets configured in the provided configuration file. It - // will then continuously monitor the config file and netmap updates and - // reconfigure the firewall rules as needed. If any of its operations fail, it - // will crash this node. - if cfg.EgressSvcsCfgPath != "" { - log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressSvcsCfgPath) - egressSvcsNotify = make(chan ipn.Notify) - ep := egressProxy{ - cfgPath: cfg.EgressSvcsCfgPath, - nfr: nfr, - kc: kc, - stateSecret: cfg.KubeSecret, - netmapChan: egressSvcsNotify, - podIPv4: cfg.PodIPv4, - tailnetAddrs: addrs, - } - go func() { - if err := ep.run(ctx, n); err != nil { - egressSvcsErrorChan <- err - } - }() - } - - // Wait on tailscaled process. It won't be cleaned up by default when the - // container exits as it is not PID1. TODO (irbekrm): perhaps we can replace the - // reaper by a running cmd.Wait in a goroutine immediately after starting - // tailscaled? - reaper := func() { - defer wg.Done() - for { - var status unix.WaitStatus - _, err := unix.Wait4(daemonProcess.Pid, &status, 0, nil) - if errors.Is(err, unix.EINTR) { - continue - } - if err != nil { - log.Fatalf("Waiting for tailscaled to exit: %v", err) - } - log.Print("tailscaled exited") - os.Exit(0) - } - } - wg.Add(1) - go reaper() - } - } - case <-tc: - newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName) - if err != nil { - log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err) - resetTimer(true) - continue - } - backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool { - return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) }) - })) - if backendsHaveChanged && len(addrs) != 0 { - log.Printf("Backend address change detected, installing proxy rules for backends %v", newBackendAddrs) - if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil { - log.Fatalf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err) - } - } - backendAddrs = newBackendAddrs - resetTimer(false) - case e := <-egressSvcsErrorChan: - log.Fatalf("egress proxy failed: %v", e) - } - } - wg.Wait() -} - -// ensureTunFile checks that /dev/net/tun exists, creating it if -// missing. -func ensureTunFile(root string) error { - // Verify that /dev/net/tun exists, in some container envs it - // needs to be mknod-ed. - if _, err := os.Stat(filepath.Join(root, "dev/net")); errors.Is(err, fs.ErrNotExist) { - if err := os.MkdirAll(filepath.Join(root, "dev/net"), 0755); err != nil { - return err - } - } - if _, err := os.Stat(filepath.Join(root, "dev/net/tun")); errors.Is(err, fs.ErrNotExist) { - dev := unix.Mkdev(10, 200) // tuntap major and minor - if err := unix.Mknod(filepath.Join(root, "dev/net/tun"), 0600|unix.S_IFCHR, int(dev)); err != nil { - return err - } - } - return nil -} - -func resolveDNS(ctx context.Context, name string) ([]net.IP, error) { - // TODO (irbekrm): look at using recursive.Resolver instead to resolve - // the DNS names as well as retrieve TTLs. It looks though that this - // seems to return very short TTLs (shorter than on the actual records). - ip4s, err := net.DefaultResolver.LookupIP(ctx, "ip4", name) - if err != nil { - if e, ok := err.(*net.DNSError); !(ok && e.IsNotFound) { - return nil, fmt.Errorf("error looking up IPv4 addresses: %v", err) - } - } - ip6s, err := net.DefaultResolver.LookupIP(ctx, "ip6", name) - if err != nil { - if e, ok := err.(*net.DNSError); !(ok && e.IsNotFound) { - return nil, fmt.Errorf("error looking up IPv6 addresses: %v", err) - } - } - if len(ip4s) == 0 && len(ip6s) == 0 { - return nil, fmt.Errorf("no IPv4 or IPv6 addresses found for host: %s", name) - } - return append(ip4s, ip6s...), nil -} - -// contextWithExitSignalWatch watches for SIGTERM/SIGINT signals. It returns a -// context that gets cancelled when a signal is received and a cancel function -// that can be called to free the resources when the watch should be stopped. -func contextWithExitSignalWatch() (context.Context, func()) { - closeChan := make(chan string) - ctx, cancel := context.WithCancel(context.Background()) - signalChan := make(chan os.Signal, 1) - signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) - go func() { - select { - case <-signalChan: - cancel() - case <-closeChan: - return - } - }() - f := func() { - closeChan <- "goodbye" - } - return ctx, f -} - -// tailscaledConfigFilePath returns the path to the tailscaled config file that -// should be used for the current capability version. It is determined by the -// TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR environment variable and looks for a -// file named cap-.hujson in the directory. It searches for -// the highest capability version that is less than or equal to the current -// capability version. -func tailscaledConfigFilePath() string { - dir := os.Getenv("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR") - if dir == "" { - return "" - } - fe, err := os.ReadDir(dir) - if err != nil { - log.Fatalf("error reading tailscaled config directory %q: %v", dir, err) - } - maxCompatVer := tailcfg.CapabilityVersion(-1) - for _, e := range fe { - // We don't check if type if file as in most cases this will - // come from a mounted kube Secret, where the directory contents - // will be various symlinks. - if e.Type().IsDir() { - continue - } - cv, err := kubeutils.CapVerFromFileName(e.Name()) - if err != nil { - continue - } - if cv > maxCompatVer && cv <= tailcfg.CurrentCapabilityVersion { - maxCompatVer = cv - } - } - if maxCompatVer == -1 { - log.Fatalf("no tailscaled config file found in %q for current capability version %d", dir, tailcfg.CurrentCapabilityVersion) - } - filePath := filepath.Join(dir, kubeutils.TailscaledConfigFileName(maxCompatVer)) - log.Printf("Using tailscaled config file %q to match current capability version %d", filePath, tailcfg.CurrentCapabilityVersion) - return filePath -} diff --git a/cmd/containerboot/main_test.go b/cmd/containerboot/main_test.go deleted file mode 100644 index 5c92787ce6079..0000000000000 --- a/cmd/containerboot/main_test.go +++ /dev/null @@ -1,1159 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "bytes" - _ "embed" - "encoding/base64" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "io" - "io/fs" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "golang.org/x/sys/unix" - "tailscale.com/ipn" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/types/netmap" - "tailscale.com/types/ptr" -) - -func TestContainerBoot(t *testing.T) { - d := t.TempDir() - - lapi := localAPI{FSRoot: d} - if err := lapi.Start(); err != nil { - t.Fatal(err) - } - defer lapi.Close() - - kube := kubeServer{FSRoot: d} - if err := kube.Start(); err != nil { - t.Fatal(err) - } - defer kube.Close() - - tailscaledConf := &ipn.ConfigVAlpha{AuthKey: ptr.To("foo"), Version: "alpha0"} - tailscaledConfBytes, err := json.Marshal(tailscaledConf) - if err != nil { - t.Fatalf("error unmarshaling tailscaled config: %v", err) - } - - dirs := []string{ - "var/lib", - "usr/bin", - "tmp", - "dev/net", - "proc/sys/net/ipv4", - "proc/sys/net/ipv6/conf/all", - "etc/tailscaled", - } - for _, path := range dirs { - if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil { - t.Fatal(err) - } - } - files := map[string][]byte{ - "usr/bin/tailscaled": fakeTailscaled, - "usr/bin/tailscale": fakeTailscale, - "usr/bin/iptables": fakeTailscale, - "usr/bin/ip6tables": fakeTailscale, - "dev/net/tun": []byte(""), - "proc/sys/net/ipv4/ip_forward": []byte("0"), - "proc/sys/net/ipv6/conf/all/forwarding": []byte("0"), - "etc/tailscaled/cap-95.hujson": tailscaledConfBytes, - } - resetFiles := func() { - for path, content := range files { - // Making everything executable is a little weird, but the - // stuff that doesn't need to be executable doesn't care if we - // do make it executable. - if err := os.WriteFile(filepath.Join(d, path), content, 0700); err != nil { - t.Fatal(err) - } - } - } - resetFiles() - - boot := filepath.Join(d, "containerboot") - if err := exec.Command("go", "build", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil { - t.Fatalf("Building containerboot: %v", err) - } - - argFile := filepath.Join(d, "args") - runningSockPath := filepath.Join(d, "tmp/tailscaled.sock") - - type phase struct { - // If non-nil, send this IPN bus notification (and remember it as the - // initial update for any future new watchers, then wait for all the - // Waits below to be true before proceeding to the next phase. - Notify *ipn.Notify - - // WantCmds is the commands that containerboot should run in this phase. - WantCmds []string - // WantKubeSecret is the secret keys/values that should exist in the - // kube secret. - WantKubeSecret map[string]string - // WantFiles files that should exist in the container and their - // contents. - WantFiles map[string]string - // WantFatalLog is the fatal log message we expect from containerboot. - // If set for a phase, the test will finish on that phase. - WantFatalLog string - } - runningNotify := &ipn.Notify{ - State: ptr.To(ipn.Running), - NetMap: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - StableID: tailcfg.StableNodeID("myID"), - Name: "test-node.test.ts.net", - Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, - }).View(), - }, - } - tests := []struct { - Name string - Env map[string]string - KubeSecret map[string]string - KubeDenyPatch bool - Phases []phase - }{ - { - // Out of the box default: runs in userspace mode, ephemeral storage, interactive login. - Name: "no_args", - Env: nil, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - // Userspace mode, ephemeral storage, authkey provided on every run. - Name: "authkey", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - // Userspace mode, ephemeral storage, authkey provided on every run. - Name: "authkey-old-flag", - Env: map[string]string{ - "TS_AUTH_KEY": "tskey-key", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - Name: "authkey_disk_state", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_STATE_DIR": filepath.Join(d, "tmp"), - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - Name: "routes", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_ROUTES": "1.2.3.0/24,10.20.30.0/24", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24", - }, - }, - { - Notify: runningNotify, - WantFiles: map[string]string{ - "proc/sys/net/ipv4/ip_forward": "0", - "proc/sys/net/ipv6/conf/all/forwarding": "0", - }, - }, - }, - }, - { - Name: "empty routes", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_ROUTES": "", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=", - }, - }, - { - Notify: runningNotify, - WantFiles: map[string]string{ - "proc/sys/net/ipv4/ip_forward": "0", - "proc/sys/net/ipv6/conf/all/forwarding": "0", - }, - }, - }, - }, - { - Name: "routes_kernel_ipv4", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_ROUTES": "1.2.3.0/24,10.20.30.0/24", - "TS_USERSPACE": "false", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24", - }, - }, - { - Notify: runningNotify, - WantFiles: map[string]string{ - "proc/sys/net/ipv4/ip_forward": "1", - "proc/sys/net/ipv6/conf/all/forwarding": "0", - }, - }, - }, - }, - { - Name: "routes_kernel_ipv6", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_ROUTES": "::/64,1::/64", - "TS_USERSPACE": "false", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1::/64", - }, - }, - { - Notify: runningNotify, - WantFiles: map[string]string{ - "proc/sys/net/ipv4/ip_forward": "0", - "proc/sys/net/ipv6/conf/all/forwarding": "1", - }, - }, - }, - }, - { - Name: "routes_kernel_all_families", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_ROUTES": "::/64,1.2.3.0/24", - "TS_USERSPACE": "false", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1.2.3.0/24", - }, - }, - { - Notify: runningNotify, - WantFiles: map[string]string{ - "proc/sys/net/ipv4/ip_forward": "1", - "proc/sys/net/ipv6/conf/all/forwarding": "1", - }, - }, - }, - }, - { - Name: "ingress proxy", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_DEST_IP": "1.2.3.4", - "TS_USERSPACE": "false", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - Name: "egress proxy", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_TAILNET_TARGET_IP": "100.99.99.99", - "TS_USERSPACE": "false", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - WantFiles: map[string]string{ - "proc/sys/net/ipv4/ip_forward": "1", - "proc/sys/net/ipv6/conf/all/forwarding": "0", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - Name: "egress_proxy_fqdn_ipv6_target_on_ipv4_host", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_TAILNET_TARGET_FQDN": "ipv6-node.test.ts.net", // resolves to IPv6 address - "TS_USERSPACE": "false", - "TS_TEST_FAKE_NETFILTER_6": "false", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - WantFiles: map[string]string{ - "proc/sys/net/ipv4/ip_forward": "1", - "proc/sys/net/ipv6/conf/all/forwarding": "0", - }, - }, - { - Notify: &ipn.Notify{ - State: ptr.To(ipn.Running), - NetMap: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - StableID: tailcfg.StableNodeID("myID"), - Name: "test-node.test.ts.net", - Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - StableID: tailcfg.StableNodeID("ipv6ID"), - Name: "ipv6-node.test.ts.net", - Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")}, - }).View(), - }, - }, - }, - WantFatalLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false", - }, - }, - }, - { - Name: "authkey_once", - Env: map[string]string{ - "TS_AUTHKEY": "tskey-key", - "TS_AUTH_ONCE": "true", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - }, - }, - { - Notify: &ipn.Notify{ - State: ptr.To(ipn.NeedsLogin), - }, - WantCmds: []string{ - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - }, - { - Notify: runningNotify, - WantCmds: []string{ - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false", - }, - }, - }, - }, - { - Name: "kube_storage", - Env: map[string]string{ - "KUBERNETES_SERVICE_HOST": kube.Host, - "KUBERNETES_SERVICE_PORT_HTTPS": kube.Port, - }, - KubeSecret: map[string]string{ - "authkey": "tskey-key", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - WantKubeSecret: map[string]string{ - "authkey": "tskey-key", - }, - }, - { - Notify: runningNotify, - WantKubeSecret: map[string]string{ - "authkey": "tskey-key", - "device_fqdn": "test-node.test.ts.net", - "device_id": "myID", - "device_ips": `["100.64.0.1"]`, - }, - }, - }, - }, - { - Name: "kube_disk_storage", - Env: map[string]string{ - "KUBERNETES_SERVICE_HOST": kube.Host, - "KUBERNETES_SERVICE_PORT_HTTPS": kube.Port, - // Explicitly set to an empty value, to override the default of "tailscale". - "TS_KUBE_SECRET": "", - "TS_STATE_DIR": filepath.Join(d, "tmp"), - "TS_AUTHKEY": "tskey-key", - }, - KubeSecret: map[string]string{}, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - WantKubeSecret: map[string]string{}, - }, - { - Notify: runningNotify, - WantKubeSecret: map[string]string{}, - }, - }, - }, - { - Name: "kube_storage_no_patch", - Env: map[string]string{ - "KUBERNETES_SERVICE_HOST": kube.Host, - "KUBERNETES_SERVICE_PORT_HTTPS": kube.Port, - "TS_AUTHKEY": "tskey-key", - }, - KubeSecret: map[string]string{}, - KubeDenyPatch: true, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - WantKubeSecret: map[string]string{}, - }, - { - Notify: runningNotify, - WantKubeSecret: map[string]string{}, - }, - }, - }, - { - // Same as previous, but deletes the authkey from the kube secret. - Name: "kube_storage_auth_once", - Env: map[string]string{ - "KUBERNETES_SERVICE_HOST": kube.Host, - "KUBERNETES_SERVICE_PORT_HTTPS": kube.Port, - "TS_AUTH_ONCE": "true", - }, - KubeSecret: map[string]string{ - "authkey": "tskey-key", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking", - }, - WantKubeSecret: map[string]string{ - "authkey": "tskey-key", - }, - }, - { - Notify: &ipn.Notify{ - State: ptr.To(ipn.NeedsLogin), - }, - WantCmds: []string{ - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - WantKubeSecret: map[string]string{ - "authkey": "tskey-key", - }, - }, - { - Notify: runningNotify, - WantCmds: []string{ - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false", - }, - WantKubeSecret: map[string]string{ - "device_fqdn": "test-node.test.ts.net", - "device_id": "myID", - "device_ips": `["100.64.0.1"]`, - }, - }, - }, - }, - { - Name: "kube_storage_updates", - Env: map[string]string{ - "KUBERNETES_SERVICE_HOST": kube.Host, - "KUBERNETES_SERVICE_PORT_HTTPS": kube.Port, - }, - KubeSecret: map[string]string{ - "authkey": "tskey-key", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key", - }, - WantKubeSecret: map[string]string{ - "authkey": "tskey-key", - }, - }, - { - Notify: runningNotify, - WantKubeSecret: map[string]string{ - "authkey": "tskey-key", - "device_fqdn": "test-node.test.ts.net", - "device_id": "myID", - "device_ips": `["100.64.0.1"]`, - }, - }, - { - Notify: &ipn.Notify{ - State: ptr.To(ipn.Running), - NetMap: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - StableID: tailcfg.StableNodeID("newID"), - Name: "new-name.test.ts.net", - Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, - }).View(), - }, - }, - WantKubeSecret: map[string]string{ - "authkey": "tskey-key", - "device_fqdn": "new-name.test.ts.net", - "device_id": "newID", - "device_ips": `["100.64.0.1"]`, - }, - }, - }, - }, - { - Name: "proxies", - Env: map[string]string{ - "TS_SOCKS5_SERVER": "localhost:1080", - "TS_OUTBOUND_HTTP_PROXY_LISTEN": "localhost:8080", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --socks5-server=localhost:1080 --outbound-http-proxy-listen=localhost:8080", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - Name: "dns", - Env: map[string]string{ - "TS_ACCEPT_DNS": "true", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=true", - }, - }, - { - Notify: runningNotify, - }, - }, - }, - { - Name: "extra_args", - Env: map[string]string{ - "TS_EXTRA_ARGS": "--widget=rotated", - "TS_TAILSCALED_EXTRA_ARGS": "--experiments=widgets", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --experiments=widgets", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --widget=rotated", - }, - }, { - Notify: runningNotify, - }, - }, - }, - { - Name: "extra_args_accept_routes", - Env: map[string]string{ - "TS_EXTRA_ARGS": "--accept-routes", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --accept-routes", - }, - }, { - Notify: runningNotify, - }, - }, - }, - { - Name: "hostname", - Env: map[string]string{ - "TS_HOSTNAME": "my-server", - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", - "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --hostname=my-server", - }, - }, { - Notify: runningNotify, - }, - }, - }, - { - Name: "experimental tailscaled config path", - Env: map[string]string{ - "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR": filepath.Join(d, "etc/tailscaled/"), - }, - Phases: []phase{ - { - WantCmds: []string{ - "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled/cap-95.hujson", - }, - }, { - Notify: runningNotify, - }, - }, - }, - } - - for _, test := range tests { - t.Run(test.Name, func(t *testing.T) { - lapi.Reset() - kube.Reset() - os.Remove(argFile) - os.Remove(runningSockPath) - resetFiles() - - for k, v := range test.KubeSecret { - kube.SetSecret(k, v) - } - kube.SetPatching(!test.KubeDenyPatch) - - cmd := exec.Command(boot) - cmd.Env = []string{ - fmt.Sprintf("PATH=%s/usr/bin:%s", d, os.Getenv("PATH")), - fmt.Sprintf("TS_TEST_RECORD_ARGS=%s", argFile), - fmt.Sprintf("TS_TEST_SOCKET=%s", lapi.Path), - fmt.Sprintf("TS_SOCKET=%s", runningSockPath), - fmt.Sprintf("TS_TEST_ONLY_ROOT=%s", d), - fmt.Sprint("TS_TEST_FAKE_NETFILTER=true"), - } - for k, v := range test.Env { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) - } - cbOut := &lockingBuffer{} - defer func() { - if t.Failed() { - t.Logf("containerboot output:\n%s", cbOut.String()) - } - }() - cmd.Stderr = cbOut - if err := cmd.Start(); err != nil { - t.Fatalf("starting containerboot: %v", err) - } - defer func() { - cmd.Process.Signal(unix.SIGTERM) - cmd.Process.Wait() - }() - - var wantCmds []string - for i, p := range test.Phases { - lapi.Notify(p.Notify) - if p.WantFatalLog != "" { - err := tstest.WaitFor(2*time.Second, func() error { - state, err := cmd.Process.Wait() - if err != nil { - return err - } - if state.ExitCode() != 1 { - return fmt.Errorf("process exited with code %d but wanted %d", state.ExitCode(), 1) - } - waitLogLine(t, time.Second, cbOut, p.WantFatalLog) - return nil - }) - if err != nil { - t.Fatal(err) - } - - // Early test return, we don't expect the successful startup log message. - return - } - wantCmds = append(wantCmds, p.WantCmds...) - waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n")) - err := tstest.WaitFor(2*time.Second, func() error { - if p.WantKubeSecret != nil { - got := kube.Secret() - if diff := cmp.Diff(got, p.WantKubeSecret); diff != "" { - return fmt.Errorf("unexpected kube secret data (-got+want):\n%s", diff) - } - } else { - got := kube.Secret() - if len(got) > 0 { - return fmt.Errorf("kube secret unexpectedly not empty, got %#v", got) - } - } - return nil - }) - if err != nil { - t.Fatalf("phase %d: %v", i, err) - } - err = tstest.WaitFor(2*time.Second, func() error { - for path, want := range p.WantFiles { - gotBs, err := os.ReadFile(filepath.Join(d, path)) - if err != nil { - return fmt.Errorf("reading wanted file %q: %v", path, err) - } - if got := strings.TrimSpace(string(gotBs)); got != want { - return fmt.Errorf("wrong file contents for %q, got %q want %q", path, got, want) - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } - } - waitLogLine(t, 2*time.Second, cbOut, "Startup complete, waiting for shutdown signal") - }) - } -} - -type lockingBuffer struct { - sync.Mutex - b bytes.Buffer -} - -func (b *lockingBuffer) Write(bs []byte) (int, error) { - b.Lock() - defer b.Unlock() - return b.b.Write(bs) -} - -func (b *lockingBuffer) String() string { - b.Lock() - defer b.Unlock() - return b.b.String() -} - -// waitLogLine looks for want in the contents of b. -// -// Only lines starting with 'boot: ' (the output of containerboot -// itself) are considered, and the logged timestamp is ignored. -// -// waitLogLine fails the entire test if path doesn't contain want -// before the timeout. -func waitLogLine(t *testing.T, timeout time.Duration, b *lockingBuffer, want string) { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - for _, line := range strings.Split(b.String(), "\n") { - if !strings.HasPrefix(line, "boot: ") { - continue - } - if strings.HasSuffix(line, " "+want) { - return - } - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("timed out waiting for wanted output line %q. Output:\n%s", want, b.String()) -} - -// waitArgs waits until the contents of path matches wantArgs, a set -// of command lines recorded by test_tailscale.sh and -// test_tailscaled.sh. -// -// All occurrences of removeStr are removed from the file prior to -// comparison. This is used to remove the varying temporary root -// directory name from recorded commandlines, so that wantArgs can be -// a constant value. -// -// waitArgs fails the entire test if path doesn't contain wantArgs -// before the timeout. -func waitArgs(t *testing.T, timeout time.Duration, removeStr, path, wantArgs string) { - t.Helper() - wantArgs = strings.TrimSpace(wantArgs) - deadline := time.Now().Add(timeout) - var got string - for time.Now().Before(deadline) { - bs, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - // Don't bother logging that the file doesn't exist, it - // should start existing soon. - goto loop - } else if err != nil { - t.Logf("reading %q: %v", path, err) - goto loop - } - got = strings.TrimSpace(string(bs)) - got = strings.ReplaceAll(got, removeStr, "") - if got == wantArgs { - return - } - loop: - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("waiting for args file %q to have expected output, got:\n%s\n\nWant: %s", path, got, wantArgs) -} - -//go:embed test_tailscaled.sh -var fakeTailscaled []byte - -//go:embed test_tailscale.sh -var fakeTailscale []byte - -// localAPI is a minimal fake tailscaled LocalAPI server that presents -// just enough functionality for containerboot to function -// correctly. In practice this means it only supports querying -// tailscaled status, and panics on all other uses to make it very -// obvious that something unexpected happened. -type localAPI struct { - FSRoot string - Path string // populated by Start - - srv *http.Server - - sync.Mutex - cond *sync.Cond - notify *ipn.Notify -} - -func (l *localAPI) Start() error { - path := filepath.Join(l.FSRoot, "tmp/tailscaled.sock.fake") - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - - ln, err := net.Listen("unix", path) - if err != nil { - return err - } - - l.srv = &http.Server{ - Handler: l, - } - l.Path = path - l.cond = sync.NewCond(&l.Mutex) - go l.srv.Serve(ln) - return nil -} - -func (l *localAPI) Close() { - l.srv.Close() -} - -func (l *localAPI) Reset() { - l.Lock() - defer l.Unlock() - l.notify = nil - l.cond.Broadcast() -} - -func (l *localAPI) Notify(n *ipn.Notify) { - if n == nil { - return - } - l.Lock() - defer l.Unlock() - l.notify = n - l.cond.Broadcast() -} - -func (l *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/localapi/v0/serve-config": - if r.Method != "POST" { - panic(fmt.Sprintf("unsupported method %q", r.Method)) - } - return - case "/localapi/v0/watch-ipn-bus": - if r.Method != "GET" { - panic(fmt.Sprintf("unsupported method %q", r.Method)) - } - default: - panic(fmt.Sprintf("unsupported path %q", r.URL.Path)) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - enc := json.NewEncoder(w) - l.Lock() - defer l.Unlock() - for { - if l.notify != nil { - if err := enc.Encode(l.notify); err != nil { - // Usually broken pipe as the test client disconnects. - return - } - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - } - l.cond.Wait() - } -} - -// kubeServer is a minimal fake Kubernetes server that presents just -// enough functionality for containerboot to function correctly. In -// practice this means it only supports reading and modifying a single -// kube secret, and panics on all other uses to make it very obvious -// that something unexpected happened. -type kubeServer struct { - FSRoot string - Host, Port string // populated by Start - - srv *httptest.Server - - sync.Mutex - secret map[string]string - canPatch bool -} - -func (k *kubeServer) Secret() map[string]string { - k.Lock() - defer k.Unlock() - ret := map[string]string{} - for k, v := range k.secret { - ret[k] = v - } - return ret -} - -func (k *kubeServer) SetSecret(key, val string) { - k.Lock() - defer k.Unlock() - k.secret[key] = val -} - -func (k *kubeServer) SetPatching(canPatch bool) { - k.Lock() - defer k.Unlock() - k.canPatch = canPatch -} - -func (k *kubeServer) Reset() { - k.Lock() - defer k.Unlock() - k.secret = map[string]string{} -} - -func (k *kubeServer) Start() error { - root := filepath.Join(k.FSRoot, "var/run/secrets/kubernetes.io/serviceaccount") - - if err := os.MkdirAll(root, 0700); err != nil { - return err - } - - if err := os.WriteFile(filepath.Join(root, "namespace"), []byte("default"), 0600); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(root, "token"), []byte("bearer_token"), 0600); err != nil { - return err - } - - k.srv = httptest.NewTLSServer(k) - k.Host = k.srv.Listener.Addr().(*net.TCPAddr).IP.String() - k.Port = strconv.Itoa(k.srv.Listener.Addr().(*net.TCPAddr).Port) - - var cert bytes.Buffer - if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: k.srv.Certificate().Raw}); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(root, "ca.crt"), cert.Bytes(), 0600); err != nil { - return err - } - - return nil -} - -func (k *kubeServer) Close() { - k.srv.Close() -} - -func (k *kubeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer bearer_token" { - panic("client didn't provide bearer token in request") - } - switch r.URL.Path { - case "/api/v1/namespaces/default/secrets/tailscale": - k.serveSecret(w, r) - case "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews": - k.serveSSAR(w, r) - default: - panic(fmt.Sprintf("unhandled fake kube api path %q", r.URL.Path)) - } -} - -func (k *kubeServer) serveSSAR(w http.ResponseWriter, r *http.Request) { - var req struct { - Spec struct { - ResourceAttributes struct { - Verb string `json:"verb"` - } `json:"resourceAttributes"` - } `json:"spec"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - panic(fmt.Sprintf("decoding SSAR request: %v", err)) - } - ok := true - if req.Spec.ResourceAttributes.Verb == "patch" { - k.Lock() - defer k.Unlock() - ok = k.canPatch - } - // Just say yes to all SARs, we don't enforce RBAC. - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"status":{"allowed":%v}}`, ok) -} - -func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) { - bs, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, fmt.Sprintf("reading request body: %v", err), http.StatusInternalServerError) - return - } - - switch r.Method { - case "GET": - w.Header().Set("Content-Type", "application/json") - ret := map[string]map[string]string{ - "data": {}, - } - k.Lock() - defer k.Unlock() - for k, v := range k.secret { - v := base64.StdEncoding.EncodeToString([]byte(v)) - ret["data"][k] = v - } - if err := json.NewEncoder(w).Encode(ret); err != nil { - panic("encode failed") - } - case "PATCH": - k.Lock() - defer k.Unlock() - if !k.canPatch { - panic("containerboot tried to patch despite not being allowed") - } - switch r.Header.Get("Content-Type") { - case "application/json-patch+json": - req := []struct { - Op string `json:"op"` - Path string `json:"path"` - }{} - if err := json.Unmarshal(bs, &req); err != nil { - panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs))) - } - for _, op := range req { - if op.Op != "remove" { - panic(fmt.Sprintf("unsupported json-patch op %q", op.Op)) - } - if !strings.HasPrefix(op.Path, "/data/") { - panic(fmt.Sprintf("unsupported json-patch path %q", op.Path)) - } - delete(k.secret, strings.TrimPrefix(op.Path, "/data/")) - } - case "application/strategic-merge-patch+json": - req := struct { - 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] = string(val) - } - default: - panic(fmt.Sprintf("unknown content type %q", r.Header.Get("Content-Type"))) - } - default: - panic(fmt.Sprintf("unhandled HTTP method %q", r.Method)) - } -} diff --git a/cmd/containerboot/serve.go b/cmd/containerboot/serve.go deleted file mode 100644 index 6c22b3eeb651e..0000000000000 --- a/cmd/containerboot/serve.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "bytes" - "context" - "encoding/json" - "log" - "os" - "path/filepath" - "reflect" - "sync/atomic" - "time" - - "github.com/fsnotify/fsnotify" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" -) - -// watchServeConfigChanges watches path for changes, and when it sees one, reads -// the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and -// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that -// is written to when the certDomain changes, causing the serve config to be -// re-read and applied. -func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *tailscale.LocalClient) { - if certDomainAtomic == nil { - panic("cd must not be nil") - } - var tickChan <-chan time.Time - var eventChan <-chan fsnotify.Event - if w, err := fsnotify.NewWatcher(); err != nil { - log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - tickChan = ticker.C - } else { - defer w.Close() - if err := w.Add(filepath.Dir(path)); err != nil { - log.Fatalf("failed to add fsnotify watch: %v", err) - } - eventChan = w.Events - } - - var certDomain string - var prevServeConfig *ipn.ServeConfig - for { - select { - case <-ctx.Done(): - return - case <-cdChanged: - certDomain = *certDomainAtomic.Load() - case <-tickChan: - case <-eventChan: - // We can't do any reasonable filtering on the event because of how - // k8s handles these mounts. So just re-read the file and apply it - // if it's changed. - } - if certDomain == "" { - continue - } - sc, err := readServeConfig(path, certDomain) - if err != nil { - log.Fatalf("failed to read serve config: %v", err) - } - if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) { - continue - } - log.Printf("Applying serve config") - if err := lc.SetServeConfig(ctx, sc); err != nil { - log.Fatalf("failed to set serve config: %v", err) - } - prevServeConfig = sc - } -} - -// readServeConfig reads the ipn.ServeConfig from path, replacing -// ${TS_CERT_DOMAIN} with certDomain. -func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) { - if path == "" { - return nil, nil - } - j, err := os.ReadFile(path) - if err != nil { - return nil, err - } - j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain)) - var sc ipn.ServeConfig - if err := json.Unmarshal(j, &sc); err != nil { - return nil, err - } - return &sc, nil -} diff --git a/cmd/containerboot/services.go b/cmd/containerboot/services.go deleted file mode 100644 index aed00250d001e..0000000000000 --- a/cmd/containerboot/services.go +++ /dev/null @@ -1,571 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "net/netip" - "os" - "path/filepath" - "reflect" - "strings" - "time" - - "github.com/fsnotify/fsnotify" - "tailscale.com/ipn" - "tailscale.com/kube/egressservices" - "tailscale.com/kube/kubeclient" - "tailscale.com/tailcfg" - "tailscale.com/util/linuxfw" - "tailscale.com/util/mak" -) - -const tailscaleTunInterface = "tailscale0" - -// This file contains functionality to run containerboot as a proxy that can -// route cluster traffic to one or more tailnet targets, based on portmapping -// rules read from a configfile. Currently (9/2024) this is only used for the -// Kubernetes operator egress proxies. - -// egressProxy knows how to configure firewall rules to route cluster traffic to -// one or more tailnet services. -type egressProxy struct { - cfgPath string // path to egress service config file - - nfr linuxfw.NetfilterRunner // never nil - - kc kubeclient.Client // never nil - stateSecret string // name of the kube state Secret - - netmapChan chan ipn.Notify // chan to receive netmap updates on - - podIPv4 string // never empty string, currently only IPv4 is supported - - // tailnetFQDNs is the egress service FQDN to tailnet IP mappings that - // were last used to configure firewall rules for this proxy. - // TODO(irbekrm): target addresses are also stored in the state Secret. - // Evaluate whether we should retrieve them from there and not store in - // memory at all. - targetFQDNs map[string][]netip.Prefix - - // used to configure firewall rules. - tailnetAddrs []netip.Prefix -} - -// run configures egress proxy firewall rules and ensures that the firewall rules are reconfigured when: -// - the mounted egress config has changed -// - the proxy's tailnet IP addresses have changed -// - tailnet IPs have changed for any backend targets specified by tailnet FQDN -func (ep *egressProxy) run(ctx context.Context, n ipn.Notify) error { - var tickChan <-chan time.Time - var eventChan <-chan fsnotify.Event - // TODO (irbekrm): take a look if this can be pulled into a single func - // shared with serve config loader. - if w, err := fsnotify.NewWatcher(); err != nil { - log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - tickChan = ticker.C - } else { - defer w.Close() - if err := w.Add(filepath.Dir(ep.cfgPath)); err != nil { - return fmt.Errorf("failed to add fsnotify watch: %w", err) - } - eventChan = w.Events - } - - if err := ep.sync(ctx, n); err != nil { - return err - } - for { - var err error - select { - case <-ctx.Done(): - return nil - case <-tickChan: - err = ep.sync(ctx, n) - case <-eventChan: - log.Printf("config file change detected, ensuring firewall config is up to date...") - err = ep.sync(ctx, n) - case n = <-ep.netmapChan: - shouldResync := ep.shouldResync(n) - if shouldResync { - log.Printf("netmap change detected, ensuring firewall config is up to date...") - err = ep.sync(ctx, n) - } - } - if err != nil { - return fmt.Errorf("error syncing egress service config: %w", err) - } - } -} - -// sync triggers an egress proxy config resync. The resync calculates the diff between config and status to determine if -// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current -// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such -// as failed firewall update -func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error { - cfgs, err := ep.getConfigs() - if err != nil { - return fmt.Errorf("error retrieving egress service configs: %w", err) - } - status, err := ep.getStatus(ctx) - if err != nil { - return fmt.Errorf("error retrieving current egress proxy status: %w", err) - } - newStatus, err := ep.syncEgressConfigs(cfgs, status, n) - if err != nil { - return fmt.Errorf("error syncing egress service configs: %w", err) - } - if !servicesStatusIsEqual(newStatus, status) { - if err := ep.setStatus(ctx, newStatus, n); err != nil { - return fmt.Errorf("error setting egress proxy status: %w", err) - } - } - return nil -} - -// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node. -// Netmap must not be nil. -func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool { - return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses()) -} - -// syncEgressConfigs adds and deletes firewall rules to match the desired -// configuration. It uses the provided status to determine what is currently -// applied and updates the status after a successful sync. -func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) { - if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) { - return nil, nil - } - - // Delete unnecessary services. - if err := ep.deleteUnnecessaryServices(cfgs, status); err != nil { - return nil, fmt.Errorf("error deleting services: %w", err) - - } - newStatus := &egressservices.Status{} - if !wantsServicesConfigured(cfgs) { - return newStatus, nil - } - - // Add new services, update rules for any that have changed. - rulesPerSvcToAdd := make(map[string][]rule, 0) - rulesPerSvcToDelete := make(map[string][]rule, 0) - for svcName, cfg := range *cfgs { - tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n) - if err != nil { - return nil, fmt.Errorf("error determining tailnet target IPs: %w", err) - } - rulesToAdd, rulesToDelete, err := updatesForCfg(svcName, cfg, status, tailnetTargetIPs) - if err != nil { - return nil, fmt.Errorf("error validating service changes: %v", err) - } - log.Printf("syncegressservices: looking at svc %s rulesToAdd %d rulesToDelete %d", svcName, len(rulesToAdd), len(rulesToDelete)) - if len(rulesToAdd) != 0 { - mak.Set(&rulesPerSvcToAdd, svcName, rulesToAdd) - } - if len(rulesToDelete) != 0 { - mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete) - } - if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) { - // For each tailnet target, set up SNAT from the local tailnet device address of the matching - // family. - for _, t := range tailnetTargetIPs { - var local netip.Addr - for _, pfx := range n.NetMap.SelfNode.Addresses().All() { - if !pfx.IsSingleIP() { - continue - } - if pfx.Addr().Is4() != t.Is4() { - continue - } - local = pfx.Addr() - break - } - if !local.IsValid() { - return nil, fmt.Errorf("no valid local IP: %v", local) - } - if err := ep.nfr.EnsureSNATForDst(local, t); err != nil { - return nil, fmt.Errorf("error setting up SNAT rule: %w", err) - } - } - } - // Update the status. Status will be written back to the state Secret by the caller. - mak.Set(&newStatus.Services, svcName, &egressservices.ServiceStatus{TailnetTargetIPs: tailnetTargetIPs, TailnetTarget: cfg.TailnetTarget, Ports: cfg.Ports}) - } - - // Actually apply the firewall rules. - if err := ensureRulesAdded(rulesPerSvcToAdd, ep.nfr); err != nil { - return nil, fmt.Errorf("error adding rules: %w", err) - } - if err := ensureRulesDeleted(rulesPerSvcToDelete, ep.nfr); err != nil { - return nil, fmt.Errorf("error deleting rules: %w", err) - } - - return newStatus, nil -} - -// updatesForCfg calculates any rules that need to be added or deleted for an individucal egress service config. -func updatesForCfg(svcName string, cfg egressservices.Config, status *egressservices.Status, tailnetTargetIPs []netip.Addr) ([]rule, []rule, error) { - rulesToAdd := make([]rule, 0) - rulesToDelete := make([]rule, 0) - currentConfig, ok := lookupCurrentConfig(svcName, status) - - // If no rules for service are present yet, add them all. - if !ok { - for _, t := range tailnetTargetIPs { - for ports := range cfg.Ports { - log.Printf("syncegressservices: svc %s adding port %v", svcName, ports) - rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: t}) - } - } - return rulesToAdd, rulesToDelete, nil - } - - // If there are no backend targets available, delete any currently configured rules. - if len(tailnetTargetIPs) == 0 { - log.Printf("tailnet target for egress service %s does not have any backend addresses, deleting all rules", svcName) - for _, ip := range currentConfig.TailnetTargetIPs { - for ports := range currentConfig.Ports { - rulesToDelete = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip}) - } - } - return rulesToAdd, rulesToDelete, nil - } - - // If there are rules present for backend targets that no longer match, delete them. - for _, ip := range currentConfig.TailnetTargetIPs { - var found bool - for _, wantsIP := range tailnetTargetIPs { - if reflect.DeepEqual(ip, wantsIP) { - found = true - break - } - } - if !found { - for ports := range currentConfig.Ports { - rulesToDelete = append(rulesToDelete, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip}) - } - } - } - - // Sync rules for the currently wanted backend targets. - for _, ip := range tailnetTargetIPs { - - // If the backend target is not yet present in status, add all rules. - var found bool - for _, gotIP := range currentConfig.TailnetTargetIPs { - if reflect.DeepEqual(ip, gotIP) { - found = true - break - } - } - if !found { - for ports := range cfg.Ports { - rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip}) - } - continue - } - - // If the backend target is present in status, check that the - // currently applied rules are up to date. - - // Delete any current portmappings that are no longer present in config. - for port := range currentConfig.Ports { - if _, ok := cfg.Ports[port]; ok { - continue - } - rulesToDelete = append(rulesToDelete, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip}) - } - - // Add any new portmappings. - for port := range cfg.Ports { - if _, ok := currentConfig.Ports[port]; ok { - continue - } - rulesToAdd = append(rulesToAdd, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip}) - } - } - return rulesToAdd, rulesToDelete, nil -} - -// deleteUnneccessaryServices ensure that any services found on status, but not -// present in config are deleted. -func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error { - if !hasServicesConfigured(status) { - return nil - } - if !wantsServicesConfigured(cfgs) { - for svcName, svc := range status.Services { - log.Printf("service %s is no longer required, deleting", svcName) - if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil { - return fmt.Errorf("error deleting service %s: %w", svcName, err) - } - } - return nil - } - - for svcName, svc := range status.Services { - if _, ok := (*cfgs)[svcName]; !ok { - log.Printf("service %s is no longer required, deleting", svcName) - if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil { - return fmt.Errorf("error deleting service %s: %w", svcName, err) - } - // TODO (irbekrm): also delete the SNAT rule here - } - } - return nil -} - -// getConfigs gets the mounted egress service configuration. -func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) { - j, err := os.ReadFile(ep.cfgPath) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - if len(j) == 0 || string(j) == "" { - return nil, nil - } - cfg := &egressservices.Configs{} - if err := json.Unmarshal(j, &cfg); err != nil { - return nil, err - } - return cfg, nil -} - -// getStatus gets the current status of the configured firewall. The current -// status is stored in state Secret. Returns nil status if no status that -// applies to the current proxy Pod was found. Uses the Pod IP to determine if a -// status found in the state Secret applies to this proxy Pod. -func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, error) { - secret, err := ep.kc.GetSecret(ctx, ep.stateSecret) - if err != nil { - return nil, fmt.Errorf("error retrieving state secret: %w", err) - } - status := &egressservices.Status{} - raw, ok := secret.Data[egressservices.KeyEgressServices] - if !ok { - return nil, nil - } - if err := json.Unmarshal([]byte(raw), status); err != nil { - return nil, fmt.Errorf("error unmarshalling previous config: %w", err) - } - if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) { - return status, nil - } - return nil, nil -} - -// setStatus writes egress proxy's currently configured firewall to the state -// Secret and updates proxy's tailnet addresses. -func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error { - // Pod IP is used to determine if a stored status applies to THIS proxy Pod. - if status == nil { - status = &egressservices.Status{} - } - status.PodIPv4 = ep.podIPv4 - secret, err := ep.kc.GetSecret(ctx, ep.stateSecret) - if err != nil { - return fmt.Errorf("error retrieving state Secret: %w", err) - } - bs, err := json.Marshal(status) - if err != nil { - return fmt.Errorf("error marshalling service config: %w", err) - } - secret.Data[egressservices.KeyEgressServices] = bs - patch := kubeclient.JSONPatch{ - Op: "replace", - Path: fmt.Sprintf("/data/%s", egressservices.KeyEgressServices), - Value: bs, - } - if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil { - return fmt.Errorf("error patching state Secret: %w", err) - } - ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice() - return nil -} - -// tailnetTargetIPsForSvc returns the tailnet IPs to which traffic for this -// egress service should be proxied. The egress service can be configured by IP -// or by FQDN. If it's configured by IP, just return that. If it's configured by -// FQDN, resolve the FQDN and return the resolved IPs. It checks if the -// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it -// doesn't. -func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) { - if svc.TailnetTarget.IP != "" { - addr, err := netip.ParseAddr(svc.TailnetTarget.IP) - if err != nil { - return nil, fmt.Errorf("error parsing tailnet target IP: %w", err) - } - if addr.Is6() && !ep.nfr.HasIPV6NAT() { - log.Printf("tailnet target is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode. This will probably not work.") - return addrs, nil - } - return []netip.Addr{addr}, nil - } - - if svc.TailnetTarget.FQDN == "" { - return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set") - } - if n.NetMap == nil { - log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN) - return addrs, nil - } - var ( - node tailcfg.NodeView - nodeFound bool - ) - for _, nn := range n.NetMap.Peers { - if equalFQDNs(nn.Name(), svc.TailnetTarget.FQDN) { - node = nn - nodeFound = true - break - } - } - if nodeFound { - for _, addr := range node.Addresses().AsSlice() { - if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() { - log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String()) - continue - } - addrs = append(addrs, addr.Addr()) - } - // Egress target endpoints configured via FQDN are stored, so - // that we can determine if a netmap update should trigger a - // resync. - mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, node.Addresses().AsSlice()) - } - return addrs, nil -} - -// shouldResync parses netmap update and returns true if the update contains -// changes for which the egress proxy's firewall should be reconfigured. -func (ep *egressProxy) shouldResync(n ipn.Notify) bool { - if n.NetMap == nil { - return false - } - - // If proxy's tailnet addresses have changed, resync. - if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) { - log.Printf("node addresses have changed, trigger egress config resync") - ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice() - return true - } - - // If the IPs for any of the egress services configured via FQDN have - // changed, resync. - for fqdn, ips := range ep.targetFQDNs { - for _, nn := range n.NetMap.Peers { - if equalFQDNs(nn.Name(), fqdn) { - if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) { - log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice()) - } - return true - } - } - } - return false -} - -// ensureServiceDeleted ensures that any rules for an egress service are removed -// from the firewall configuration. -func ensureServiceDeleted(svcName string, svc *egressservices.ServiceStatus, nfr linuxfw.NetfilterRunner) error { - - // Note that the portmap is needed for iptables based firewall only. - // Nftables group rules for a service in a chain, so there is no need to - // specify individual portmapping based rules. - pms := make([]linuxfw.PortMap, 0) - for pm := range svc.Ports { - pms = append(pms, linuxfw.PortMap{MatchPort: pm.MatchPort, TargetPort: pm.TargetPort, Protocol: pm.Protocol}) - } - - if err := nfr.DeleteSvc(svcName, tailscaleTunInterface, svc.TailnetTargetIPs, pms); err != nil { - return fmt.Errorf("error deleting service %s: %w", svcName, err) - } - return nil -} - -// ensureRulesAdded ensures that all portmapping rules are added to the firewall -// configuration. For any rules that already exist, calling this function is a -// no-op. In case of nftables, a service consists of one or two (one per IP -// family) chains that conain the portmapping rules for the service and the -// chains as needed when this function is called. -func ensureRulesAdded(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error { - for svc, rules := range rulesPerSvc { - for _, rule := range rules { - log.Printf("ensureRulesAdded svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol) - if err := nfr.EnsurePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil { - return fmt.Errorf("error ensuring rule: %w", err) - } - } - } - return nil -} - -// ensureRulesDeleted ensures that the given rules are deleted from the firewall -// configuration. For any rules that do not exist, calling this funcion is a -// no-op. -func ensureRulesDeleted(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error { - for svc, rules := range rulesPerSvc { - for _, rule := range rules { - log.Printf("ensureRulesDeleted svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol) - if err := nfr.DeletePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil { - return fmt.Errorf("error deleting rule: %w", err) - } - } - } - return nil -} - -func lookupCurrentConfig(svcName string, status *egressservices.Status) (*egressservices.ServiceStatus, bool) { - if status == nil || len(status.Services) == 0 { - return nil, false - } - c, ok := status.Services[svcName] - return c, ok -} - -func equalFQDNs(s, s1 string) bool { - s, _ = strings.CutSuffix(s, ".") - s1, _ = strings.CutSuffix(s1, ".") - return strings.EqualFold(s, s1) -} - -// rule contains configuration for an egress proxy firewall rule. -type rule struct { - containerPort uint16 // port to match incoming traffic - tailnetPort uint16 // tailnet service port - tailnetIP netip.Addr // tailnet service IP - protocol string -} - -func wantsServicesConfigured(cfgs *egressservices.Configs) bool { - return cfgs != nil && len(*cfgs) != 0 -} - -func hasServicesConfigured(status *egressservices.Status) bool { - return status != nil && len(status.Services) != 0 -} - -func servicesStatusIsEqual(st, st1 *egressservices.Status) bool { - if st == nil && st1 == nil { - return true - } - if st == nil || st1 == nil { - return false - } - st.PodIPv4 = "" - st1.PodIPv4 = "" - return reflect.DeepEqual(*st, *st1) -} diff --git a/cmd/containerboot/services_test.go b/cmd/containerboot/services_test.go deleted file mode 100644 index 46f6db1cf6d0e..0000000000000 --- a/cmd/containerboot/services_test.go +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "net/netip" - "reflect" - "testing" - - "tailscale.com/kube/egressservices" -) - -func Test_updatesForSvc(t *testing.T) { - tailnetIPv4, tailnetIPv6 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("fd7a:115c:a1e0::701:b62a") - tailnetIPv4_1, tailnetIPv6_1 := netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("fd7a:115c:a1e0::4101:512f") - ports := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {}} - ports1 := map[egressservices.PortMap]struct{}{{Protocol: "udp", MatchPort: 4004, TargetPort: 53}: {}} - ports2 := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {}, - {Protocol: "tcp", MatchPort: 4005, TargetPort: 443}: {}} - fqdnSpec := egressservices.Config{ - TailnetTarget: egressservices.TailnetTarget{FQDN: "test"}, - Ports: ports, - } - fqdnSpec1 := egressservices.Config{ - TailnetTarget: egressservices.TailnetTarget{FQDN: "test"}, - Ports: ports1, - } - fqdnSpec2 := egressservices.Config{ - TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()}, - Ports: ports, - } - fqdnSpec3 := egressservices.Config{ - TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()}, - Ports: ports2, - } - r := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4} - r1 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6} - r2 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv4} - r3 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv6} - r4 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4_1} - r5 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6_1} - r6 := rule{containerPort: 4005, tailnetPort: 443, protocol: "tcp", tailnetIP: tailnetIPv4} - - tests := []struct { - name string - svcName string - tailnetTargetIPs []netip.Addr - podIP string - spec egressservices.Config - status *egressservices.Status - wantRulesToAdd []rule - wantRulesToDelete []rule - }{ - { - name: "add_fqdn_svc_that_does_not_yet_exist", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6}, - spec: fqdnSpec, - status: &egressservices.Status{}, - wantRulesToAdd: []rule{r, r1}, - wantRulesToDelete: []rule{}, - }, - { - name: "fqdn_svc_already_exists", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6}, - spec: fqdnSpec, - status: &egressservices.Status{ - Services: map[string]*egressservices.ServiceStatus{"test": { - TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6}, - TailnetTarget: egressservices.TailnetTarget{FQDN: "test"}, - Ports: ports, - }}}, - wantRulesToAdd: []rule{}, - wantRulesToDelete: []rule{}, - }, - { - name: "fqdn_svc_already_exists_add_port_remove_port", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6}, - spec: fqdnSpec1, - status: &egressservices.Status{ - Services: map[string]*egressservices.ServiceStatus{"test": { - TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6}, - TailnetTarget: egressservices.TailnetTarget{FQDN: "test"}, - Ports: ports, - }}}, - wantRulesToAdd: []rule{r2, r3}, - wantRulesToDelete: []rule{r, r1}, - }, - { - name: "fqdn_svc_already_exists_change_fqdn_backend_ips", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4_1, tailnetIPv6_1}, - spec: fqdnSpec, - status: &egressservices.Status{ - Services: map[string]*egressservices.ServiceStatus{"test": { - TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6}, - TailnetTarget: egressservices.TailnetTarget{FQDN: "test"}, - Ports: ports, - }}}, - wantRulesToAdd: []rule{r4, r5}, - wantRulesToDelete: []rule{r, r1}, - }, - { - name: "add_ip_service", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4}, - spec: fqdnSpec2, - status: &egressservices.Status{}, - wantRulesToAdd: []rule{r}, - wantRulesToDelete: []rule{}, - }, - { - name: "add_ip_service_already_exists", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4}, - spec: fqdnSpec2, - status: &egressservices.Status{ - Services: map[string]*egressservices.ServiceStatus{"test": { - TailnetTargetIPs: []netip.Addr{tailnetIPv4}, - TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()}, - Ports: ports, - }}}, - wantRulesToAdd: []rule{}, - wantRulesToDelete: []rule{}, - }, - { - name: "ip_service_add_port", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4}, - spec: fqdnSpec3, - status: &egressservices.Status{ - Services: map[string]*egressservices.ServiceStatus{"test": { - TailnetTargetIPs: []netip.Addr{tailnetIPv4}, - TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()}, - Ports: ports, - }}}, - wantRulesToAdd: []rule{r6}, - wantRulesToDelete: []rule{}, - }, - { - name: "ip_service_delete_port", - svcName: "test", - tailnetTargetIPs: []netip.Addr{tailnetIPv4}, - spec: fqdnSpec, - status: &egressservices.Status{ - Services: map[string]*egressservices.ServiceStatus{"test": { - TailnetTargetIPs: []netip.Addr{tailnetIPv4}, - TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()}, - Ports: ports2, - }}}, - wantRulesToAdd: []rule{}, - wantRulesToDelete: []rule{r6}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRulesToAdd, gotRulesToDelete, err := updatesForCfg(tt.svcName, tt.spec, tt.status, tt.tailnetTargetIPs) - if err != nil { - t.Errorf("updatesForSvc() unexpected error %v", err) - return - } - if !reflect.DeepEqual(gotRulesToAdd, tt.wantRulesToAdd) { - t.Errorf("updatesForSvc() got rulesToAdd = \n%v\n want rulesToAdd \n%v", gotRulesToAdd, tt.wantRulesToAdd) - } - if !reflect.DeepEqual(gotRulesToDelete, tt.wantRulesToDelete) { - t.Errorf("updatesForSvc() got rulesToDelete = \n%v\n want rulesToDelete \n%v", gotRulesToDelete, tt.wantRulesToDelete) - } - }) - } -} diff --git a/cmd/containerboot/settings.go b/cmd/containerboot/settings.go deleted file mode 100644 index 742713e7700de..0000000000000 --- a/cmd/containerboot/settings.go +++ /dev/null @@ -1,324 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "context" - "errors" - "fmt" - "log" - "net/netip" - "os" - "path" - "strconv" - "strings" - - "tailscale.com/ipn/conffile" - "tailscale.com/kube/kubeclient" -) - -// settings is all the configuration for containerboot. -type settings struct { - AuthKey string - Hostname string - Routes *string - // ProxyTargetIP is the destination IP to which all incoming - // Tailscale traffic should be proxied. If empty, no proxying - // is done. This is typically a locally reachable IP. - ProxyTargetIP string - // ProxyTargetDNSName is a DNS name to whose backing IP addresses all - // incoming Tailscale traffic should be proxied. - ProxyTargetDNSName string - // TailnetTargetIP is the destination IP to which all incoming - // non-Tailscale traffic should be proxied. This is typically a - // Tailscale IP. - TailnetTargetIP string - // TailnetTargetFQDN is an MagicDNS name to which all incoming - // non-Tailscale traffic should be proxied. This must be a full Tailnet - // node FQDN. - TailnetTargetFQDN string - ServeConfigPath string - DaemonExtraArgs string - ExtraArgs string - InKubernetes bool - UserspaceMode bool - StateDir string - AcceptDNS *bool - KubeSecret string - SOCKSProxyAddr string - HTTPProxyAddr string - Socket string - AuthOnce bool - Root string - KubernetesCanPatch bool - TailscaledConfigFilePath string - EnableForwardingOptimizations bool - // If set to true and, if this containerboot instance is a Kubernetes - // ingress proxy, set up rules to forward incoming cluster traffic to be - // forwarded to the ingress target in cluster. - AllowProxyingClusterTrafficViaIngress bool - // PodIP is the IP of the Pod if running in Kubernetes. This is used - // when setting up rules to proxy cluster traffic to cluster ingress - // target. - // Deprecated: use PodIPv4, PodIPv6 instead to support dual stack clusters - PodIP string - PodIPv4 string - PodIPv6 string - HealthCheckAddrPort string - EgressSvcsCfgPath string -} - -func configFromEnv() (*settings, error) { - cfg := &settings{ - AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""), - Hostname: defaultEnv("TS_HOSTNAME", ""), - Routes: defaultEnvStringPointer("TS_ROUTES"), - ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""), - ProxyTargetIP: defaultEnv("TS_DEST_IP", ""), - ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""), - TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""), - TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""), - DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""), - ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""), - InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "", - UserspaceMode: defaultBool("TS_USERSPACE", true), - StateDir: defaultEnv("TS_STATE_DIR", ""), - AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"), - KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"), - SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""), - HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""), - Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"), - AuthOnce: defaultBool("TS_AUTH_ONCE", false), - Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"), - TailscaledConfigFilePath: tailscaledConfigFilePath(), - AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false), - PodIP: defaultEnv("POD_IP", ""), - EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false), - HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""), - EgressSvcsCfgPath: defaultEnv("TS_EGRESS_SERVICES_CONFIG_PATH", ""), - } - podIPs, ok := os.LookupEnv("POD_IPS") - if ok { - ips := strings.Split(podIPs, ",") - if len(ips) > 2 { - return nil, fmt.Errorf("POD_IPs can contain at most 2 IPs, got %d (%v)", len(ips), ips) - } - for _, ip := range ips { - parsed, err := netip.ParseAddr(ip) - if err != nil { - return nil, fmt.Errorf("error parsing IP address %s: %w", ip, err) - } - if parsed.Is4() { - cfg.PodIPv4 = parsed.String() - continue - } - cfg.PodIPv6 = parsed.String() - } - } - if err := cfg.validate(); err != nil { - return nil, fmt.Errorf("invalid configuration: %v", err) - } - return cfg, nil -} - -func (s *settings) validate() error { - if s.TailscaledConfigFilePath != "" { - dir, file := path.Split(s.TailscaledConfigFilePath) - if _, err := os.Stat(dir); err != nil { - return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err) - } - if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil { - return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err) - } - if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil { - return fmt.Errorf("error validating tailscaled configfile contents: %w", err) - } - } - if s.ProxyTargetIP != "" && s.UserspaceMode { - return errors.New("TS_DEST_IP is not supported with TS_USERSPACE") - } - if s.ProxyTargetDNSName != "" && s.UserspaceMode { - return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE") - } - if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" { - return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set") - } - if s.TailnetTargetIP != "" && s.UserspaceMode { - return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE") - } - if s.TailnetTargetFQDN != "" && s.UserspaceMode { - return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE") - } - if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" { - return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set") - } - if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") { - return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.") - } - if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode { - return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode") - } - if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" { - return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy") - } - if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" { - return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set") - } - if s.EnableForwardingOptimizations && s.UserspaceMode { - return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode") - } - if s.HealthCheckAddrPort != "" { - if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil { - return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err) - } - } - return nil -} - -// setupKube is responsible for doing any necessary configuration and checks to -// ensure that tailscale state storage and authentication mechanism will work on -// Kubernetes. -func (cfg *settings) setupKube(ctx context.Context) error { - if cfg.KubeSecret == "" { - return nil - } - canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret) - if err != nil { - return fmt.Errorf("some Kubernetes permissions are missing, please check your RBAC configuration: %v", err) - } - cfg.KubernetesCanPatch = canPatch - - s, err := kc.GetSecret(ctx, cfg.KubeSecret) - if err != nil { - if !kubeclient.IsNotFoundErr(err) { - return fmt.Errorf("getting Tailscale state Secret %s: %v", cfg.KubeSecret, err) - } - - if !canCreate { - return fmt.Errorf("tailscale state Secret %s does not exist and we don't have permissions to create it. "+ - "If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+ - "you can explicitly set TS_KUBE_SECRET env var to an empty string. "+ - "Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret) - } - } - - // Return early if we already have an auth key. - if cfg.AuthKey != "" || isOneStepConfig(cfg) { - return nil - } - - if s == nil { - log.Print("TS_AUTHKEY not provided and state Secret does not exist, login will be interactive if needed.") - return nil - } - - keyBytes, _ := s.Data["authkey"] - key := string(keyBytes) - - if key != "" { - // Enforce that we must be able to patch out the authkey after - // authenticating if you want to use this feature. This avoids - // us having to deal with the case where we might leave behind - // an unnecessary reusable authkey in a secret, like a rake in - // the grass. - if !cfg.KubernetesCanPatch { - return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the Secret to manage the authkey.") - } - cfg.AuthKey = key - } - - log.Print("No authkey found in state Secret and TS_AUTHKEY not provided, login will be interactive if needed.") - - return nil -} - -// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured -// in two steps and login should only happen once. -// Step 1: run 'tailscaled' -// Step 2): -// A) if this is the first time starting this node run 'tailscale up --authkey ' -// B) if this is not the first time starting this node run 'tailscale set '. -func isTwoStepConfigAuthOnce(cfg *settings) bool { - return cfg.AuthOnce && cfg.TailscaledConfigFilePath == "" -} - -// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured -// in two steps and we should log in every time it starts. -// Step 1: run 'tailscaled' -// Step 2): run 'tailscale up --authkey ' -func isTwoStepConfigAlwaysAuth(cfg *settings) bool { - return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == "" -} - -// isOneStepConfig returns true if the Tailscale node should always be ran and -// configured in a single step by running 'tailscaled ' -func isOneStepConfig(cfg *settings) bool { - return cfg.TailscaledConfigFilePath != "" -} - -// isL3Proxy returns true if the Tailscale node needs to be configured to act -// as an L3 proxy, proxying to an endpoint provided via one of the config env -// vars. -func isL3Proxy(cfg *settings) bool { - return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress || cfg.EgressSvcsCfgPath != "" -} - -// hasKubeStateStore returns true if the state must be stored in a Kubernetes -// Secret. -func hasKubeStateStore(cfg *settings) bool { - return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" -} - -// defaultEnv returns the value of the given envvar name, or defVal if -// unset. -func defaultEnv(name, defVal string) string { - if v, ok := os.LookupEnv(name); ok { - return v - } - return defVal -} - -// defaultEnvStringPointer returns a pointer to the given envvar value if set, else -// returns nil. This is useful in cases where we need to distinguish between a -// variable being set to empty string vs unset. -func defaultEnvStringPointer(name string) *string { - if v, ok := os.LookupEnv(name); ok { - return &v - } - return nil -} - -// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else -// returns nil. This is useful in cases where we need to distinguish between a -// variable being explicitly set to false vs unset. -func defaultEnvBoolPointer(name string) *bool { - v := os.Getenv(name) - ret, err := strconv.ParseBool(v) - if err != nil { - return nil - } - return &ret -} - -func defaultEnvs(names []string, defVal string) string { - for _, name := range names { - if v, ok := os.LookupEnv(name); ok { - return v - } - } - return defVal -} - -// defaultBool returns the boolean value of the given envvar name, or -// defVal if unset or not a bool. -func defaultBool(name string, defVal bool) bool { - v := os.Getenv(name) - ret, err := strconv.ParseBool(v) - if err != nil { - return defVal - } - return ret -} diff --git a/cmd/containerboot/tailscaled.go b/cmd/containerboot/tailscaled.go deleted file mode 100644 index 53fb7e703be45..0000000000000 --- a/cmd/containerboot/tailscaled.go +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package main - -import ( - "context" - "errors" - "fmt" - "io/fs" - "log" - "os" - "os/exec" - "strings" - "syscall" - "time" - - "tailscale.com/client/tailscale" -) - -func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) { - args := tailscaledArgs(cfg) - // tailscaled runs without context, since it needs to persist - // beyond the startup timeout in ctx. - cmd := exec.Command("tailscaled", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setpgid: true, - } - log.Printf("Starting tailscaled") - if err := cmd.Start(); err != nil { - return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err) - } - - // Wait for the socket file to appear, otherwise API ops will racily fail. - log.Printf("Waiting for tailscaled socket") - for { - if ctx.Err() != nil { - log.Fatalf("Timed out waiting for tailscaled socket") - } - _, err := os.Stat(cfg.Socket) - if errors.Is(err, fs.ErrNotExist) { - time.Sleep(100 * time.Millisecond) - continue - } else if err != nil { - log.Fatalf("Waiting for tailscaled socket: %v", err) - } - break - } - - tsClient := &tailscale.LocalClient{ - Socket: cfg.Socket, - UseSocketOnly: true, - } - - return tsClient, cmd.Process, nil -} - -// tailscaledArgs uses cfg to construct the argv for tailscaled. -func tailscaledArgs(cfg *settings) []string { - args := []string{"--socket=" + cfg.Socket} - switch { - case cfg.InKubernetes && cfg.KubeSecret != "": - args = append(args, "--state=kube:"+cfg.KubeSecret) - if cfg.StateDir == "" { - cfg.StateDir = "/tmp" - } - fallthrough - case cfg.StateDir != "": - args = append(args, "--statedir="+cfg.StateDir) - default: - args = append(args, "--state=mem:", "--statedir=/tmp") - } - - if cfg.UserspaceMode { - args = append(args, "--tun=userspace-networking") - } else if err := ensureTunFile(cfg.Root); err != nil { - log.Fatalf("ensuring that /dev/net/tun exists: %v", err) - } - - if cfg.SOCKSProxyAddr != "" { - args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr) - } - if cfg.HTTPProxyAddr != "" { - args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr) - } - if cfg.TailscaledConfigFilePath != "" { - args = append(args, "--config="+cfg.TailscaledConfigFilePath) - } - if cfg.DaemonExtraArgs != "" { - args = append(args, strings.Fields(cfg.DaemonExtraArgs)...) - } - return args -} - -// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or -// if TS_AUTH_ONCE is set, only the first time containerboot starts. -func tailscaleUp(ctx context.Context, cfg *settings) error { - args := []string{"--socket=" + cfg.Socket, "up"} - if cfg.AcceptDNS != nil && *cfg.AcceptDNS { - args = append(args, "--accept-dns=true") - } else { - args = append(args, "--accept-dns=false") - } - if cfg.AuthKey != "" { - args = append(args, "--authkey="+cfg.AuthKey) - } - // --advertise-routes can be passed an empty string to configure a - // device (that might have previously advertised subnet routes) to not - // advertise any routes. Respect an empty string passed by a user and - // use it to explicitly unset the routes. - if cfg.Routes != nil { - args = append(args, "--advertise-routes="+*cfg.Routes) - } - if cfg.Hostname != "" { - args = append(args, "--hostname="+cfg.Hostname) - } - if cfg.ExtraArgs != "" { - args = append(args, strings.Fields(cfg.ExtraArgs)...) - } - log.Printf("Running 'tailscale up'") - cmd := exec.CommandContext(ctx, "tailscale", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("tailscale up failed: %v", err) - } - return nil -} - -// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration -// options that are passed in via environment variables. This is run after the -// node is in Running state and only if TS_AUTH_ONCE is set. -func tailscaleSet(ctx context.Context, cfg *settings) error { - args := []string{"--socket=" + cfg.Socket, "set"} - if cfg.AcceptDNS != nil && *cfg.AcceptDNS { - args = append(args, "--accept-dns=true") - } else { - args = append(args, "--accept-dns=false") - } - // --advertise-routes can be passed an empty string to configure a - // device (that might have previously advertised subnet routes) to not - // advertise any routes. Respect an empty string passed by a user and - // use it to explicitly unset the routes. - if cfg.Routes != nil { - args = append(args, "--advertise-routes="+*cfg.Routes) - } - if cfg.Hostname != "" { - args = append(args, "--hostname="+cfg.Hostname) - } - log.Printf("Running 'tailscale set'") - cmd := exec.CommandContext(ctx, "tailscale", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("tailscale set failed: %v", err) - } - return nil -} diff --git a/cmd/containerboot/test_tailscale.sh b/cmd/containerboot/test_tailscale.sh deleted file mode 100644 index 1fa10abb18185..0000000000000 --- a/cmd/containerboot/test_tailscale.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -# -# This is a fake tailscale CLI (and also iptables and ip6tables) that -# records its arguments and exits successfully. -# -# It is used by main_test.go to test the behavior of containerboot. - -echo $0 $@ >>$TS_TEST_RECORD_ARGS diff --git a/cmd/containerboot/test_tailscaled.sh b/cmd/containerboot/test_tailscaled.sh deleted file mode 100644 index 335e2cb0dcfd1..0000000000000 --- a/cmd/containerboot/test_tailscaled.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# -# This is a fake tailscale daemon that records its arguments, symlinks a -# fake LocalAPI socket into place, and does nothing until terminated. -# -# It is used by main_test.go to test the behavior of containerboot. - -set -eu - -echo $0 $@ >>$TS_TEST_RECORD_ARGS - -socket="" -while [[ $# -gt 0 ]]; do - case $1 in - --socket=*) - socket="${1#--socket=}" - shift - ;; - --socket) - shift - socket="$1" - shift - ;; - *) - shift - ;; - esac -done - -if [[ -z "$socket" ]]; then - echo "didn't find socket path in args" - exit 1 -fi - -ln -s "$TS_TEST_SOCKET" "$socket" -trap 'rm -f "$socket"' EXIT - -while sleep 10; do :; done diff --git a/cmd/derper/README.md b/cmd/derper/README.md deleted file mode 100644 index 8638db72bf241..0000000000000 --- a/cmd/derper/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# DERP - -This is the code for the [Tailscale DERP server](https://tailscale.com/kb/1232/derp-servers). - -In general, you should not need to or want to run this code. The overwhelming -majority of Tailscale users (both individuals and companies) do not. - -In the happy path, Tailscale establishes direct connections between peers and -data plane traffic flows directly between them, without using DERP for more than -acting as a low bandwidth side channel to bootstrap the NAT traversal. If you -find yourself wanting DERP for more bandwidth, the real problem is usually the -network configuration of your Tailscale node(s), making sure that Tailscale can -get direction connections via some mechanism. - -If you've decided or been advised to run your own `derper`, then read on. - -## Caveats - -* Node sharing and other cross-Tailnet features don't work when using custom - DERP servers. - -* DERP servers only see encrypted WireGuard packets and thus are not useful for - network-level debugging. - -* The Tailscale control plane does certain geo-level steering features and - optimizations that are not available when using custom DERP servers. - -## Guide to running `cmd/derper` - -* You must build and update the `cmd/derper` binary yourself. There are no - packages. Use `go install tailscale.com/cmd/derper@latest` with the latest - version of Go. You should update this binary approximately as regularly as - you update Tailscale nodes. If using `--verify-clients`, the `derper` binary - and `tailscaled` binary on the machine must be built from the same git revision. - (It might work otherwise, but they're developed and only tested together.) - -* The DERP protocol does a protocol switch inside TLS from HTTP to a custom - bidirectional binary protocol. It is thus incompatible with many HTTP proxies. - Do not put `derper` behind another HTTP proxy. - -* The `tailscaled` client does its own selection of the fastest/nearest DERP - server based on latency measurements. Do not put `derper` behind a global load - balancer. - -* DERP servers should ideally have both a static IPv4 and static IPv6 address. -Both of those should be listed in the DERP map so the client doesn't need to -rely on its DNS which might be broken and dependent on DERP to get back up. - -* A DERP server should not share an IP address with any other DERP server. - -* Avoid having multiple DERP nodes in a region. If you must, they all need to be - meshed with each other and monitored. Having two one-node "regions" in the - same datacenter is usually easier and more reliable than meshing, at the cost - of more required connections from clients in some cases. If your clients - aren't mobile (battery constrained), one node regions are definitely - preferred. If you really need multiple nodes in a region for HA reasons, two - is sufficient. - -* Monitor your DERP servers with [`cmd/derpprobe`](../derpprobe/). - -* If using `--verify-clients`, a `tailscaled` must be running alongside the - `derper`, and all clients must be visible to the derper tailscaled in the ACL. - -* If using `--verify-clients`, a `tailscaled` must also be running alongside - your `derpprobe`, and `derpprobe` needs to use `--derp-map=local`. - -* The firewall on the `derper` should permit TCP ports 80 and 443 and UDP port - 3478. - -* Only LetsEncrypt certs are rotated automatically. Other cert updates require a - restart. - -* Don't use a firewall in front of `derper` that suppresses `RST`s upon - receiving traffic to a dead or unknown connection. - -* Don't rate-limit UDP STUN packets. - -* Don't rate-limit outbound TCP traffic (only inbound). - -## Diagnostics - -This is not a complete guide on DERP diagnostics. - -Running your own DERP services requires exeprtise in multi-layer network and -application diagnostics. As the DERP runs multiple protocols at multiple layers -and is not a regular HTTP(s) server you will need expertise in correlative -analysis to diagnose the most tricky problems. There is no "plain text" or -"open" mode of operation for DERP. - -* The debug handler is accessible at URL path `/debug/`. It is only accessible - over localhost or from a Tailscale IP address. - -* Go pprof can be accessed via the debug handler at `/debug/pprof/` - -* Prometheus compatible metrics can be gathered from the debug handler at - `/debug/varz`. - -* `cmd/stunc` in the Tailscale repository provides a basic tool for diagnosing - issues with STUN. - -* `cmd/derpprobe` provides a service for monitoring DERP cluster health. - -* `tailscale debug derp` and `tailscale netcheck` provide additional client - driven diagnostic information for DERP communications. - -* Tailscale logs may provide insight for certain problems, such as if DERPs are - unreachable or peers are regularly not reachable in their DERP home regions. - There are many possible misconfiguration causes for these problems, but - regular log entries are a good first indicator that there is a problem. diff --git a/cmd/derper/bootstrap_dns.go b/cmd/derper/bootstrap_dns.go deleted file mode 100644 index a58f040bae687..0000000000000 --- a/cmd/derper/bootstrap_dns.go +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "context" - "encoding/binary" - "encoding/json" - "expvar" - "log" - "math/rand/v2" - "net" - "net/http" - "net/netip" - "strconv" - "strings" - "sync/atomic" - "time" - - "tailscale.com/syncs" - "tailscale.com/util/mak" - "tailscale.com/util/slicesx" -) - -const refreshTimeout = time.Minute - -type dnsEntryMap struct { - IPs map[string][]net.IP - Percent map[string]float64 // "foo.com" => 0.5 for 50% -} - -var ( - dnsCache atomic.Pointer[dnsEntryMap] - dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON - unpublishedDNSCache atomic.Pointer[dnsEntryMap] - bootstrapLookupMap syncs.Map[string, bool] -) - -var ( - bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests") - publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits") - publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses") - unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits") - unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses") - unpublishedDNSPercentMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_percent_misses") -) - -func init() { - expvar.Publish("counter_bootstrap_dns_queried_domains", expvar.Func(func() any { - return bootstrapLookupMap.Len() - })) -} - -func refreshBootstrapDNSLoop() { - if *bootstrapDNS == "" && *unpublishedDNS == "" { - return - } - for { - refreshBootstrapDNS() - refreshUnpublishedDNS() - time.Sleep(10 * time.Minute) - } -} - -func refreshBootstrapDNS() { - if *bootstrapDNS == "" { - return - } - ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout) - defer cancel() - dnsEntries := resolveList(ctx, *bootstrapDNS) - // Randomize the order of the IPs for each name to avoid the client biasing - // to IPv6 - for _, vv := range dnsEntries.IPs { - slicesx.Shuffle(vv) - } - j, err := json.MarshalIndent(dnsEntries.IPs, "", "\t") - if err != nil { - // leave the old values in place - return - } - - dnsCache.Store(dnsEntries) - dnsCacheBytes.Store(j) -} - -func refreshUnpublishedDNS() { - if *unpublishedDNS == "" { - return - } - ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout) - defer cancel() - dnsEntries := resolveList(ctx, *unpublishedDNS) - unpublishedDNSCache.Store(dnsEntries) -} - -// resolveList takes a comma-separated list of DNS names to resolve. -// -// If an entry contains a slash, it's two DNS names: the first is the one to -// resolve and the second is that of a TXT recording containing the rollout -// percentage in range "0".."100". If the TXT record doesn't exist or is -// malformed, the percentage is 0. If the TXT record is not provided (there's no -// slash), then the percentage is 100. -func resolveList(ctx context.Context, list string) *dnsEntryMap { - ents := strings.Split(list, ",") - - ret := &dnsEntryMap{} - - var r net.Resolver - for _, ent := range ents { - name, txtName, _ := strings.Cut(ent, "/") - addrs, err := r.LookupIP(ctx, "ip", name) - if err != nil { - log.Printf("bootstrap DNS lookup %q: %v", name, err) - continue - } - mak.Set(&ret.IPs, name, addrs) - - if txtName == "" { - mak.Set(&ret.Percent, name, 1.0) - continue - } - vals, err := r.LookupTXT(ctx, txtName) - if err != nil { - log.Printf("bootstrap DNS lookup %q: %v", txtName, err) - continue - } - for _, v := range vals { - if v, err := strconv.Atoi(v); err == nil && v >= 0 && v <= 100 { - mak.Set(&ret.Percent, name, float64(v)/100) - } - } - } - return ret -} - -func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) { - bootstrapDNSRequests.Add(1) - - w.Header().Set("Content-Type", "application/json") - // Bootstrap DNS requests occur cross-regions, and are randomized per - // request, so keeping a connection open is pointlessly expensive. - w.Header().Set("Connection", "close") - - // Try answering a query from our hidden map first - if q := r.URL.Query().Get("q"); q != "" { - bootstrapLookupMap.Store(q, true) - if bootstrapLookupMap.Len() > 500 { // defensive - bootstrapLookupMap.Clear() - } - if m := unpublishedDNSCache.Load(); m != nil && len(m.IPs[q]) > 0 { - unpublishedDNSHits.Add(1) - - percent := m.Percent[q] - if remoteAddrMatchesPercent(r.RemoteAddr, percent) { - // Only return the specific query, not everything. - m := map[string][]net.IP{q: m.IPs[q]} - j, err := json.MarshalIndent(m, "", "\t") - if err == nil { - w.Write(j) - return - } - } else { - unpublishedDNSPercentMisses.Add(1) - } - } - - // If we have a "q" query for a name in the published cache - // list, then track whether that's a hit/miss. - m := dnsCache.Load() - var inPub bool - var ips []net.IP - if m != nil { - ips, inPub = m.IPs[q] - } - if inPub { - if len(ips) > 0 { - publishedDNSHits.Add(1) - } else { - publishedDNSMisses.Add(1) - } - } else { - // If it wasn't in either cache, treat this as a query - // for the unpublished cache, and thus a cache miss. - unpublishedDNSMisses.Add(1) - } - } - - // Fall back to returning the public set of cached DNS names - j := dnsCacheBytes.Load() - w.Write(j) -} - -// percent is [0.0, 1.0]. -func remoteAddrMatchesPercent(remoteAddr string, percent float64) bool { - if percent == 0 { - return false - } - if percent == 1 { - return true - } - reqIPStr, _, err := net.SplitHostPort(remoteAddr) - if err != nil { - return false - } - reqIP, err := netip.ParseAddr(reqIPStr) - if err != nil { - return false - } - if reqIP.IsLoopback() { - // For local testing. - return rand.Float64() < 0.5 - } - reqIP16 := reqIP.As16() - rndSrc := rand.NewPCG(binary.LittleEndian.Uint64(reqIP16[:8]), binary.LittleEndian.Uint64(reqIP16[8:])) - rnd := rand.New(rndSrc) - return percent > rnd.Float64() -} diff --git a/cmd/derper/bootstrap_dns_test.go b/cmd/derper/bootstrap_dns_test.go deleted file mode 100644 index d151bc2b05fdf..0000000000000 --- a/cmd/derper/bootstrap_dns_test.go +++ /dev/null @@ -1,229 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "bytes" - "encoding/json" - "io" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "net/url" - "reflect" - "testing" - - "tailscale.com/tstest" - "tailscale.com/tstest/nettest" -) - -func BenchmarkHandleBootstrapDNS(b *testing.B) { - 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) - b.ReportAllocs() - b.ResetTimer() - b.RunParallel(func(b *testing.PB) { - for b.Next() { - handleBootstrapDNS(w, req) - } - }) -} - -type bitbucketResponseWriter struct{} - -func (b *bitbucketResponseWriter) Header() http.Header { return make(http.Header) } - -func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p), nil } - -func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {} - -func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP { - t.Helper() - req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil) - w := httptest.NewRecorder() - handleBootstrapDNS(w, req) - - res := w.Result() - if res.StatusCode != 200 { - t.Fatalf("got status=%d; want %d", res.StatusCode, 200) - } - var m map[string][]net.IP - var buf bytes.Buffer - if err := json.NewDecoder(io.TeeReader(res.Body, &buf)).Decode(&m); err != nil { - t.Fatalf("error decoding response body %q: %v", buf.Bytes(), err) - } - return m -} - -func TestUnpublishedDNS(t *testing.T) { - nettest.SkipIfNoNetwork(t) - - const published = "login.tailscale.com" - const unpublished = "log.tailscale.io" - - prev1, prev2 := *bootstrapDNS, *unpublishedDNS - *bootstrapDNS = published - *unpublishedDNS = unpublished - t.Cleanup(func() { - *bootstrapDNS = prev1 - *unpublishedDNS = prev2 - }) - - refreshBootstrapDNS() - refreshUnpublishedDNS() - - hasResponse := func(q string) bool { - _, found := getBootstrapDNS(t, q)[q] - return found - } - - if !hasResponse(published) { - t.Errorf("expected response for: %s", published) - } - if !hasResponse(unpublished) { - t.Errorf("expected response for: %s", unpublished) - } - - // Verify that querying for a random query or a real query does not - // leak our unpublished domain - m1 := getBootstrapDNS(t, published) - if _, found := m1[unpublished]; found { - t.Errorf("found unpublished domain %s: %+v", unpublished, m1) - } - m2 := getBootstrapDNS(t, "random.example.com") - if _, found := m2[unpublished]; found { - t.Errorf("found unpublished domain %s: %+v", unpublished, m2) - } -} - -func resetMetrics() { - publishedDNSHits.Set(0) - publishedDNSMisses.Set(0) - unpublishedDNSHits.Set(0) - unpublishedDNSMisses.Set(0) - bootstrapLookupMap.Clear() -} - -// Verify that we don't count an empty list in the unpublishedDNSCache as a -// cache hit in our metrics. -func TestUnpublishedDNSEmptyList(t *testing.T) { - pub := &dnsEntryMap{ - IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}}, - } - dnsCache.Store(pub) - dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`)) - - unpublishedDNSCache.Store(&dnsEntryMap{ - IPs: map[string][]net.IP{ - "log.tailscale.io": {}, - "controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}, - }, - Percent: map[string]float64{ - "log.tailscale.io": 1.0, - "controlplane.tailscale.com": 1.0, - }, - }) - - t.Run("CacheMiss", func(t *testing.T) { - // One domain in map but empty, one not in map at all - for _, q := range []string{"log.tailscale.io", "login.tailscale.com"} { - resetMetrics() - ips := getBootstrapDNS(t, q) - - // Expected our public map to be returned on a cache miss - if !reflect.DeepEqual(ips, pub.IPs) { - t.Errorf("got ips=%+v; want %+v", ips, pub.IPs) - } - if v := unpublishedDNSHits.Value(); v != 0 { - t.Errorf("got hits=%d; want 0", v) - } - if v := unpublishedDNSMisses.Value(); v != 1 { - t.Errorf("got misses=%d; want 1", v) - } - } - }) - - // Verify that we do get a valid response and metric. - t.Run("CacheHit", func(t *testing.T) { - resetMetrics() - ips := getBootstrapDNS(t, "controlplane.tailscale.com") - want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}} - if !reflect.DeepEqual(ips, want) { - t.Errorf("got ips=%+v; want %+v", ips, want) - } - if v := unpublishedDNSHits.Value(); v != 1 { - t.Errorf("got hits=%d; want 1", v) - } - if v := unpublishedDNSMisses.Value(); v != 0 { - t.Errorf("got misses=%d; want 0", v) - } - }) - -} - -func TestLookupMetric(t *testing.T) { - d := []string{"a.io", "b.io", "c.io", "d.io", "e.io", "e.io", "e.io", "a.io"} - resetMetrics() - for _, q := range d { - _ = getBootstrapDNS(t, q) - } - // {"a.io": true, "b.io": true, "c.io": true, "d.io": true, "e.io": true} - if bootstrapLookupMap.Len() != 5 { - t.Errorf("bootstrapLookupMap.Len() want=5, got %v", bootstrapLookupMap.Len()) - } -} - -func TestRemoteAddrMatchesPercent(t *testing.T) { - tests := []struct { - remoteAddr string - percent float64 - want bool - }{ - // 0% and 100%. - {"10.0.0.1:1234", 0.0, false}, - {"10.0.0.1:1234", 1.0, true}, - - // Invalid IP. - {"", 1.0, true}, - {"", 0.0, false}, - {"", 0.5, false}, - - // Small manual sample at 50%. The func uses a deterministic PRNG seed. - {"1.2.3.4:567", 0.5, true}, - {"1.2.3.5:567", 0.5, true}, - {"1.2.3.6:567", 0.5, false}, - {"1.2.3.7:567", 0.5, true}, - {"1.2.3.8:567", 0.5, false}, - {"1.2.3.9:567", 0.5, true}, - {"1.2.3.10:567", 0.5, true}, - } - for _, tt := range tests { - got := remoteAddrMatchesPercent(tt.remoteAddr, tt.percent) - if got != tt.want { - t.Errorf("remoteAddrMatchesPercent(%q, %v) = %v; want %v", tt.remoteAddr, tt.percent, got, tt.want) - } - } - - var match, all int - const wantPercent = 0.5 - for a := range 256 { - for b := range 256 { - all++ - if remoteAddrMatchesPercent( - netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 2, byte(a), byte(b)}), 12345).String(), - wantPercent) { - match++ - } - } - } - gotPercent := float64(match) / float64(all) - const tolerance = 0.005 - t.Logf("got percent %v (goal %v)", gotPercent, wantPercent) - if gotPercent < wantPercent-tolerance || gotPercent > wantPercent+tolerance { - t.Errorf("got %v; want %v ± %v", gotPercent, wantPercent, tolerance) - } -} diff --git a/cmd/derper/cert.go b/cmd/derper/cert.go deleted file mode 100644 index db84aa515d257..0000000000000 --- a/cmd/derper/cert.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "net/http" - "path/filepath" - "regexp" - - "golang.org/x/crypto/acme/autocert" -) - -var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`) - -type certProvider interface { - // TLSConfig creates a new TLS config suitable for net/http.Server servers. - // - // The returned Config must have a GetCertificate function set and that - // function must return a unique *tls.Certificate for each call. The - // returned *tls.Certificate will be mutated by the caller to append to the - // (*tls.Certificate).Certificate field. - TLSConfig() *tls.Config - // HTTPHandler handle ACME related request, if any. - HTTPHandler(fallback http.Handler) http.Handler -} - -func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) { - if dir == "" { - return nil, errors.New("missing required --certdir flag") - } - switch mode { - case "letsencrypt": - certManager := &autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(hostname), - Cache: autocert.DirCache(dir), - } - if hostname == "derp.tailscale.com" { - certManager.HostPolicy = prodAutocertHostPolicy - certManager.Email = "security@tailscale.com" - } - return certManager, nil - case "manual": - return NewManualCertManager(dir, hostname) - default: - return nil, fmt.Errorf("unsupport cert mode: %q", mode) - } -} - -type manualCertManager struct { - cert *tls.Certificate - hostname string -} - -// NewManualCertManager returns a cert provider which read certificate by given hostname on create. -func NewManualCertManager(certdir, hostname string) (certProvider, error) { - keyname := unsafeHostnameCharacters.ReplaceAllString(hostname, "") - crtPath := filepath.Join(certdir, keyname+".crt") - keyPath := filepath.Join(certdir, keyname+".key") - cert, err := tls.LoadX509KeyPair(crtPath, keyPath) - if err != nil { - return nil, fmt.Errorf("can not load x509 key pair for hostname %q: %w", keyname, err) - } - // ensure hostname matches with the certificate - x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - return nil, fmt.Errorf("can not load cert: %w", err) - } - if err := x509Cert.VerifyHostname(hostname); err != nil { - return nil, fmt.Errorf("cert invalid for hostname %q: %w", hostname, err) - } - return &manualCertManager{cert: &cert, hostname: hostname}, nil -} - -func (m *manualCertManager) TLSConfig() *tls.Config { - return &tls.Config{ - Certificates: nil, - NextProtos: []string{ - "http/1.1", - }, - GetCertificate: m.getCertificate, - } -} - -func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { - if hi.ServerName != m.hostname { - return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName) - } - - // Return a shallow copy of the cert so the caller can append to its - // Certificate field. - certCopy := new(tls.Certificate) - *certCopy = *m.cert - certCopy.Certificate = certCopy.Certificate[:len(certCopy.Certificate):len(certCopy.Certificate)] - return certCopy, nil -} - -func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler { - return fallback -} diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt deleted file mode 100644 index 076074f2554a1..0000000000000 --- a/cmd/derper/depaware.txt +++ /dev/null @@ -1,316 +0,0 @@ -tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depaware) - - filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus - filippo.io/edwards25519/field from filippo.io/edwards25519 - 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/coder/websocket from tailscale.com/cmd/derper+ - github.com/coder/websocket/internal/errd from github.com/coder/websocket - github.com/coder/websocket/internal/util from github.com/coder/websocket - github.com/coder/websocket/internal/xsync from github.com/coder/websocket - L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw - W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil - github.com/fxamacker/cbor/v2 from tailscale.com/tka - github.com/go-json-experiment/json from tailscale.com/types/opt+ - github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ - github.com/golang/groupcache/lru from tailscale.com/net/dnscache - L github.com/google/nftables from tailscale.com/util/linuxfw - L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt - L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+ - L github.com/google/nftables/expr from github.com/google/nftables+ - L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ - L github.com/google/nftables/xt from github.com/google/nftables/expr+ - 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/netmon - L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink - L 💣 github.com/mdlayher/netlink from github.com/google/nftables+ - L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ - L github.com/mdlayher/netlink/nltest from github.com/google/nftables - 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 - W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket - W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio - W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio - W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs - W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw - L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink - L github.com/vishvananda/netns from github.com/tailscale/netlink+ - github.com/x448/float16 from github.com/fxamacker/cbor/v2 - 💣 go4.org/mem from tailscale.com/client/tailscale+ - go4.org/netipx from tailscale.com/net/tsaddr - W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+ - google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt - google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+ - google.golang.org/protobuf/encoding/protowire from google.golang.org/protobuf/encoding/protodelim+ - 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/editiondefaults from google.golang.org/protobuf/internal/filedesc - 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/prometheus/client_golang/prometheus+ - 💣 google.golang.org/protobuf/reflect/protoreflect from github.com/prometheus/client_model/go+ - google.golang.org/protobuf/reflect/protoregistry from google.golang.org/protobuf/encoding/prototext+ - google.golang.org/protobuf/runtime/protoiface from google.golang.org/protobuf/internal/impl+ - google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+ - google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+ - tailscale.com from tailscale.com/version - tailscale.com/atomicfile from tailscale.com/cmd/derper+ - tailscale.com/client/tailscale from tailscale.com/derp - tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale - tailscale.com/derp from tailscale.com/cmd/derper+ - tailscale.com/derp/derphttp from tailscale.com/cmd/derper - tailscale.com/disco from tailscale.com/derp - tailscale.com/drive from tailscale.com/client/tailscale+ - tailscale.com/envknob from tailscale.com/client/tailscale+ - tailscale.com/health from tailscale.com/net/tlsdial+ - tailscale.com/hostinfo from tailscale.com/net/netmon+ - tailscale.com/ipn from tailscale.com/client/tailscale - tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+ - tailscale.com/kube/kubetypes from tailscale.com/envknob - tailscale.com/metrics from tailscale.com/cmd/derper+ - tailscale.com/net/dnscache from tailscale.com/derp/derphttp - tailscale.com/net/ktimeout from tailscale.com/cmd/derper - tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netknob from tailscale.com/net/netns - 💣 tailscale.com/net/netmon from tailscale.com/derp/derphttp+ - 💣 tailscale.com/net/netns from tailscale.com/derp/derphttp - tailscale.com/net/netutil from tailscale.com/client/tailscale - tailscale.com/net/sockstats from tailscale.com/derp/derphttp - tailscale.com/net/stun from tailscale.com/net/stunserver - tailscale.com/net/stunserver from tailscale.com/cmd/derper - L tailscale.com/net/tcpinfo from tailscale.com/derp - tailscale.com/net/tlsdial from tailscale.com/derp/derphttp - tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial - tailscale.com/net/tsaddr from tailscale.com/ipn+ - 💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+ - tailscale.com/net/wsconn from tailscale.com/cmd/derper - tailscale.com/paths from tailscale.com/client/tailscale - 💣 tailscale.com/safesocket from tailscale.com/client/tailscale - tailscale.com/syncs from tailscale.com/cmd/derper+ - tailscale.com/tailcfg from tailscale.com/client/tailscale+ - tailscale.com/tka from tailscale.com/client/tailscale+ - W tailscale.com/tsconst from tailscale.com/net/netmon+ - tailscale.com/tstime from tailscale.com/derp+ - tailscale.com/tstime/mono from tailscale.com/tstime/rate - tailscale.com/tstime/rate from tailscale.com/derp - 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/tailcfg+ - tailscale.com/types/key from tailscale.com/client/tailscale+ - 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+ - tailscale.com/types/persist from tailscale.com/ipn - tailscale.com/types/preftype from tailscale.com/ipn - tailscale.com/types/ptr from tailscale.com/hostinfo+ - tailscale.com/types/result from tailscale.com/util/lineiter - tailscale.com/types/structs from tailscale.com/ipn+ - tailscale.com/types/tkatype from tailscale.com/client/tailscale+ - tailscale.com/types/views from tailscale.com/ipn+ - tailscale.com/util/cibuild from tailscale.com/health - tailscale.com/util/clientmetric from tailscale.com/net/netmon+ - tailscale.com/util/cloudenv from tailscale.com/hostinfo+ - W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy - tailscale.com/util/ctxkey from tailscale.com/tsweb+ - 💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting - L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics - tailscale.com/util/dnsname from tailscale.com/hostinfo+ - 💣 tailscale.com/util/hashx from tailscale.com/util/deephash - tailscale.com/util/httpm from tailscale.com/client/tailscale - tailscale.com/util/lineiter from tailscale.com/hostinfo+ - L tailscale.com/util/linuxfw from tailscale.com/net/netns - tailscale.com/util/mak from tailscale.com/health+ - tailscale.com/util/multierr from tailscale.com/health+ - tailscale.com/util/nocasemaps from tailscale.com/types/ipproto - tailscale.com/util/rands from tailscale.com/tsweb - tailscale.com/util/set from tailscale.com/derp+ - tailscale.com/util/singleflight from tailscale.com/net/dnscache - tailscale.com/util/slicesx from tailscale.com/cmd/derper+ - tailscale.com/util/syspolicy from tailscale.com/ipn - tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ - tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+ - tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source - tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy - tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+ - tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+ - tailscale.com/util/testenv from tailscale.com/util/syspolicy+ - tailscale.com/util/usermetric from tailscale.com/health - tailscale.com/util/vizerror from tailscale.com/tailcfg+ - W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+ - W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source - W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ - tailscale.com/version from tailscale.com/derp+ - tailscale.com/version/distro from tailscale.com/envknob+ - tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap - golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert - golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper - golang.org/x/crypto/argon2 from tailscale.com/tka - golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ - golang.org/x/crypto/blake2s from tailscale.com/tka - golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 - golang.org/x/crypto/chacha20poly1305 from crypto/tls+ - golang.org/x/crypto/cryptobyte from crypto/ecdsa+ - golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+ - golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/hkdf from crypto/tls+ - golang.org/x/crypto/nacl/box from tailscale.com/types/key - golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box - golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/sha3 from crypto/internal/mlkem768+ - W golang.org/x/exp/constraints from tailscale.com/util/winutil - golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting+ - 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/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 - D golang.org/x/net/route from net+ - golang.org/x/sync/errgroup from github.com/mdlayher/socket+ - golang.org/x/sys/cpu from github.com/josharian/native+ - LD golang.org/x/sys/unix from github.com/google/nftables+ - W golang.org/x/sys/windows from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+ - W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil - golang.org/x/text/secure/bidirule from golang.org/x/net/idna - golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ - golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ - golang.org/x/text/unicode/norm from golang.org/x/net/idna - golang.org/x/time/rate from tailscale.com/cmd/derper+ - bufio from compress/flate+ - bytes from bufio+ - cmp from slices+ - compress/flate from compress/gzip+ - compress/gzip from google.golang.org/protobuf/internal/impl+ - container/list from crypto/tls+ - context from crypto/tls+ - crypto from crypto/ecdh+ - crypto/aes from crypto/ecdsa+ - crypto/cipher from crypto/aes+ - crypto/des from crypto/tls+ - crypto/dsa from crypto/x509 - crypto/ecdh from crypto/ecdsa+ - crypto/ecdsa from crypto/tls+ - crypto/ed25519 from crypto/tls+ - crypto/elliptic from crypto/ecdsa+ - crypto/hmac from crypto/tls+ - crypto/md5 from crypto/tls+ - crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls - crypto/rsa from crypto/tls+ - crypto/sha1 from crypto/tls+ - crypto/sha256 from crypto/tls+ - crypto/sha512 from crypto/ecdsa+ - crypto/subtle from crypto/aes+ - crypto/tls from golang.org/x/crypto/acme+ - crypto/x509 from crypto/tls+ - crypto/x509/pkix from crypto/x509+ - embed from crypto/internal/nistec+ - encoding from encoding/json+ - encoding/asn1 from crypto/x509+ - encoding/base32 from github.com/fxamacker/cbor/v2+ - encoding/base64 from encoding/json+ - encoding/binary from compress/gzip+ - encoding/hex from crypto/x509+ - encoding/json from expvar+ - encoding/pem from crypto/tls+ - errors from bufio+ - expvar from github.com/prometheus/client_golang/prometheus+ - 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+ - html/template from tailscale.com/cmd/derper - io from bufio+ - io/fs from crypto/x509+ - io/ioutil from github.com/mitchellh/go-ps+ - iter from maps+ - log from expvar+ - log/internal from log - maps from tailscale.com/ipn+ - math from compress/flate+ - math/big from crypto/dsa+ - math/bits from compress/flate+ - math/rand from github.com/mdlayher/netlink+ - math/rand/v2 from internal/concurrent+ - mime from github.com/prometheus/common/expfmt+ - mime/multipart from net/http - mime/quotedprintable from mime/multipart - net from crypto/tls+ - net/http from expvar+ - net/http/httptrace from net/http+ - net/http/internal from net/http - 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+ - os from crypto/rand+ - os/exec from github.com/coreos/go-iptables/iptables+ - os/signal from tailscale.com/cmd/derper - W os/user from tailscale.com/util/winutil+ - path from github.com/prometheus/client_golang/prometheus/internal+ - path/filepath from crypto/x509+ - reflect from crypto/x509+ - regexp from github.com/coreos/go-iptables/iptables+ - regexp/syntax from regexp - runtime/debug from github.com/prometheus/client_golang/prometheus+ - runtime/metrics from github.com/prometheus/client_golang/prometheus+ - runtime/pprof from net/http/pprof - runtime/trace from net/http/pprof - slices from tailscale.com/ipn/ipnstate+ - sort from compress/flate+ - strconv from compress/flate+ - strings from bufio+ - sync from compress/flate+ - sync/atomic from context+ - syscall from crypto/rand+ - text/tabwriter from runtime/pprof - text/template from html/template - text/template/parse from html/template+ - time from compress/gzip+ - unicode from bytes+ - unicode/utf16 from crypto/x509+ - unicode/utf8 from bufio+ - unique from net/netip diff --git a/cmd/derper/derper.go b/cmd/derper/derper.go deleted file mode 100644 index 51be3abbe3b93..0000000000000 --- a/cmd/derper/derper.go +++ /dev/null @@ -1,511 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The derper binary is a simple DERP server. -// -// For more information, see: -// -// - About: https://tailscale.com/kb/1232/derp-servers -// - Protocol & Go docs: https://pkg.go.dev/tailscale.com/derp -// - Running a DERP server: https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp -package main // import "tailscale.com/cmd/derper" - -import ( - "cmp" - "context" - "crypto/tls" - "encoding/json" - "errors" - "expvar" - "flag" - "fmt" - "html/template" - "io" - "log" - "math" - "net" - "net/http" - "os" - "os/signal" - "path/filepath" - "regexp" - "runtime" - runtimemetrics "runtime/metrics" - "strconv" - "strings" - "syscall" - "time" - - "golang.org/x/time/rate" - "tailscale.com/atomicfile" - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/metrics" - "tailscale.com/net/ktimeout" - "tailscale.com/net/stunserver" - "tailscale.com/tsweb" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/version" -) - -var ( - dev = flag.Bool("dev", false, "run in localhost development mode (overrides -a)") - versionFlag = flag.Bool("version", false, "print version and exit") - 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") - certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt") - certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443") - hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443") - runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.") - runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.") - - meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.") - meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list") - bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns") - unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list. If an entry contains a slash, the second part names a DNS record to poll for its TXT record with a `0` to `100` value for rollout percentage.") - verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.") - verifyClientURL = flag.String("verify-client-url", "", "if non-empty, an admission controller URL for permitting client connections; see tailcfg.DERPAdmitClientRequest") - verifyFailOpen = flag.Bool("verify-client-url-fail-open", true, "whether we fail open if --verify-client-url is unreachable") - - acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection") - acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection") - - // tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule. - tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time") - // tcpUserTimeout is intentionally short, so that hung connections are cleaned up promptly. DERPs should be nearby users. - tcpUserTimeout = flag.Duration("tcp-user-timeout", 15*time.Second, "TCP user timeout") -) - -var ( - tlsRequestVersion = &metrics.LabelMap{Label: "version"} - tlsActiveVersion = &metrics.LabelMap{Label: "version"} -) - -func init() { - expvar.Publish("derper_tls_request_version", tlsRequestVersion) - expvar.Publish("gauge_derper_tls_active_version", tlsActiveVersion) -} - -type config struct { - PrivateKey key.NodePrivate -} - -func loadConfig() config { - if *dev { - return config{PrivateKey: key.NewNode()} - } - if *configPath == "" { - if os.Getuid() == 0 { - *configPath = "/var/lib/derper/derper.key" - } else { - log.Fatalf("derper: -c not specified") - } - log.Printf("no config path specified; using %s", *configPath) - } - b, err := os.ReadFile(*configPath) - switch { - case errors.Is(err, os.ErrNotExist): - return writeNewConfig() - case err != nil: - log.Fatal(err) - panic("unreachable") - default: - var cfg config - if err := json.Unmarshal(b, &cfg); err != nil { - log.Fatalf("derper: config: %v", err) - } - return cfg - } -} - -func writeNewConfig() config { - k := key.NewNode() - if err := os.MkdirAll(filepath.Dir(*configPath), 0777); err != nil { - log.Fatal(err) - } - cfg := config{ - PrivateKey: k, - } - b, err := json.MarshalIndent(cfg, "", "\t") - if err != nil { - log.Fatal(err) - } - if err := atomicfile.WriteFile(*configPath, b, 0600); err != nil { - log.Fatal(err) - } - return cfg -} - -func main() { - flag.Parse() - if *versionFlag { - fmt.Println(version.Long()) - return - } - - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - if *dev { - *addr = ":3340" // above the keys DERP - log.Printf("Running in dev mode.") - tsweb.DevMode = true - } - - listenHost, _, err := net.SplitHostPort(*addr) - if err != nil { - log.Fatalf("invalid server address: %v", err) - } - - if *runSTUN { - ss := stunserver.New(ctx) - go ss.ListenAndServe(net.JoinHostPort(listenHost, fmt.Sprint(*stunPort))) - } - - cfg := loadConfig() - - serveTLS := tsweb.IsProd443(*addr) || *certMode == "manual" - - s := derp.NewServer(cfg.PrivateKey, log.Printf) - s.SetVerifyClient(*verifyClients) - s.SetVerifyClientURL(*verifyClientURL) - s.SetVerifyClientURLFailOpen(*verifyFailOpen) - - if *meshPSKFile != "" { - b, err := os.ReadFile(*meshPSKFile) - if err != nil { - log.Fatal(err) - } - key := strings.TrimSpace(string(b)) - if matched, _ := regexp.MatchString(`(?i)^[0-9a-f]{64,}$`, key); !matched { - log.Fatalf("key in %s must contain 64+ hex digits", *meshPSKFile) - } - s.SetMeshKey(key) - log.Printf("DERP mesh key configured") - } - if err := startMesh(s); err != nil { - log.Fatalf("startMesh: %v", err) - } - expvar.Publish("derp", s.ExpVar()) - - mux := http.NewServeMux() - if *runDERP { - derpHandler := derphttp.Handler(s) - derpHandler = addWebSocketSupport(s, derpHandler) - mux.Handle("/derp", derpHandler) - } else { - mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "derp server disabled", http.StatusNotFound) - })) - } - - // These two endpoints are the same. Different versions of the clients - // have assumes different paths over time so we support both. - mux.HandleFunc("/derp/probe", derphttp.ProbeHandler) - mux.HandleFunc("/derp/latency-check", derphttp.ProbeHandler) - - go refreshBootstrapDNSLoop() - mux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS)) - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tsweb.AddBrowserHeaders(w) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(200) - err := homePageTemplate.Execute(w, templateData{ - ShowAbuseInfo: validProdHostname.MatchString(*hostname), - Disabled: !*runDERP, - AllowDebug: tsweb.AllowDebugAccess(r), - }) - if err != nil { - if r.Context().Err() == nil { - log.Printf("homePageTemplate.Execute: %v", err) - } - return - } - })) - mux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tsweb.AddBrowserHeaders(w) - io.WriteString(w, "User-agent: *\nDisallow: /\n") - })) - mux.Handle("/generate_204", http.HandlerFunc(derphttp.ServeNoContent)) - debug := tsweb.Debugger(mux) - debug.KV("TLS hostname", *hostname) - debug.KV("Mesh key", s.HasMeshKey()) - debug.Handle("check", "Consistency check", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := s.ConsistencyCheck() - if err != nil { - http.Error(w, err.Error(), 500) - } else { - io.WriteString(w, "derp.Server ConsistencyCheck okay") - } - })) - debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic)) - debug.Handle("set-mutex-profile-fraction", "SetMutexProfileFraction", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s := r.FormValue("rate") - if s == "" || r.Header.Get("Sec-Debug") != "derp" { - http.Error(w, "To set, use: curl -HSec-Debug:derp 'http://derp/debug/set-mutex-profile-fraction?rate=100'", http.StatusBadRequest) - return - } - v, err := strconv.Atoi(s) - if err != nil { - http.Error(w, "bad rate value", http.StatusBadRequest) - return - } - old := runtime.SetMutexProfileFraction(v) - fmt.Fprintf(w, "mutex changed from %v to %v\n", old, v) - })) - - // Longer lived DERP connections send an application layer keepalive. Note - // if the keepalive is hit, the user timeout will take precedence over the - // keepalive counter, so the probe if unanswered will take effect promptly, - // this is less tolerant of high loss, but high loss is unexpected. - lc := net.ListenConfig{ - Control: ktimeout.UserTimeout(*tcpUserTimeout), - KeepAlive: *tcpKeepAlive, - } - - quietLogger := log.New(logger.HTTPServerLogFilter{Inner: log.Printf}, "", 0) - httpsrv := &http.Server{ - Addr: *addr, - Handler: mux, - ErrorLog: quietLogger, - - // Set read/write timeout. For derper, this basically - // only affects TLS setup, as read/write deadlines are - // cleared on Hijack, which the DERP server does. But - // without this, we slowly accumulate stuck TLS - // handshake goroutines forever. This also affects - // /debug/ traffic, but 30 seconds is plenty for - // Prometheus/etc scraping. - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - } - go func() { - <-ctx.Done() - httpsrv.Shutdown(ctx) - }() - - if serveTLS { - log.Printf("derper: serving on %s with TLS", *addr) - var certManager certProvider - certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname) - if err != nil { - log.Fatalf("derper: can not start cert provider: %v", err) - } - httpsrv.TLSConfig = certManager.TLSConfig() - getCert := httpsrv.TLSConfig.GetCertificate - httpsrv.TLSConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := getCert(hi) - if err != nil { - return nil, err - } - cert.Certificate = append(cert.Certificate, s.MetaCert()) - return cert, nil - } - // Disable TLS 1.0 and 1.1, which are obsolete and have security issues. - httpsrv.TLSConfig.MinVersion = tls.VersionTLS12 - httpsrv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.TLS != nil { - label := "unknown" - switch r.TLS.Version { - case tls.VersionTLS10: - label = "1.0" - case tls.VersionTLS11: - label = "1.1" - case tls.VersionTLS12: - label = "1.2" - case tls.VersionTLS13: - label = "1.3" - } - tlsRequestVersion.Add(label, 1) - tlsActiveVersion.Add(label, 1) - defer tlsActiveVersion.Add(label, -1) - } - - mux.ServeHTTP(w, r) - }) - if *httpPort > -1 { - go func() { - port80mux := http.NewServeMux() - port80mux.HandleFunc("/generate_204", derphttp.ServeNoContent) - port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux})) - port80srv := &http.Server{ - Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)), - Handler: port80mux, - ErrorLog: quietLogger, - ReadTimeout: 30 * time.Second, - // Crank up WriteTimeout a bit more than usually - // necessary just so we can do long CPU profiles - // and not hit net/http/pprof's "profile - // duration exceeds server's WriteTimeout". - WriteTimeout: 5 * time.Minute, - } - ln, err := lc.Listen(context.Background(), "tcp", port80srv.Addr) - if err != nil { - log.Fatal(err) - } - defer ln.Close() - err = port80srv.Serve(ln) - if err != nil { - if err != http.ErrServerClosed { - log.Fatal(err) - } - } - }() - } - err = rateLimitedListenAndServeTLS(httpsrv, &lc) - } else { - log.Printf("derper: serving on %s", *addr) - var ln net.Listener - ln, err = lc.Listen(context.Background(), "tcp", httpsrv.Addr) - if err != nil { - log.Fatal(err) - } - err = httpsrv.Serve(ln) - } - if err != nil && err != http.ErrServerClosed { - log.Fatalf("derper: %v", err) - } -} - -var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`) - -func prodAutocertHostPolicy(_ context.Context, host string) error { - if validProdHostname.MatchString(host) { - return nil - } - return errors.New("invalid hostname") -} - -func defaultMeshPSKFile() string { - try := []string{ - "/home/derp/keys/derp-mesh.key", - filepath.Join(os.Getenv("HOME"), "keys", "derp-mesh.key"), - } - for _, p := range try { - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" -} - -func rateLimitedListenAndServeTLS(srv *http.Server, lc *net.ListenConfig) error { - ln, err := lc.Listen(context.Background(), "tcp", cmp.Or(srv.Addr, ":https")) - if err != nil { - return err - } - rln := newRateLimitedListener(ln, rate.Limit(*acceptConnLimit), *acceptConnBurst) - expvar.Publish("tls_listener", rln.ExpVar()) - defer rln.Close() - return srv.ServeTLS(rln, "", "") -} - -type rateLimitedListener struct { - // These are at the start of the struct to ensure 64-bit alignment - // on 32-bit architecture regardless of what other fields may exist - // in this package. - numAccepts expvar.Int // does not include number of rejects - numRejects expvar.Int - - net.Listener - - lim *rate.Limiter -} - -func newRateLimitedListener(ln net.Listener, limit rate.Limit, burst int) *rateLimitedListener { - return &rateLimitedListener{Listener: ln, lim: rate.NewLimiter(limit, burst)} -} - -func (l *rateLimitedListener) ExpVar() expvar.Var { - m := new(metrics.Set) - m.Set("counter_accepted_connections", &l.numAccepts) - m.Set("counter_rejected_connections", &l.numRejects) - return m -} - -var errLimitedConn = errors.New("cannot accept connection; rate limited") - -func (l *rateLimitedListener) Accept() (net.Conn, error) { - // Even under a rate limited situation, we accept the connection immediately - // and close it, rather than being slow at accepting new connections. - // This provides two benefits: 1) it signals to the client that something - // is going on on the server, and 2) it prevents new connections from - // piling up and occupying resources in the OS kernel. - // The client will retry as needing (with backoffs in place). - cn, err := l.Listener.Accept() - if err != nil { - return nil, err - } - if !l.lim.Allow() { - l.numRejects.Add(1) - cn.Close() - return nil, errLimitedConn - } - l.numAccepts.Add(1) - return cn, nil -} - -func init() { - expvar.Publish("go_sync_mutex_wait_seconds", expvar.Func(func() any { - const name = "/sync/mutex/wait/total:seconds" // Go 1.20+ - var s [1]runtimemetrics.Sample - s[0].Name = name - runtimemetrics.Read(s[:]) - if v := s[0].Value; v.Kind() == runtimemetrics.KindFloat64 { - return v.Float64() - } - return 0 - })) -} - -type templateData struct { - ShowAbuseInfo bool - Disabled bool - AllowDebug bool -} - -// homePageTemplate renders the home page using [templateData]. -var homePageTemplate = template.Must(template.New("home").Parse(` -

DERP

-

- This is a Tailscale DERP server. -

- -

- It provides STUN, interactive connectivity establishment, and relaying of end-to-end encrypted traffic - for Tailscale clients. -

- -{{if .ShowAbuseInfo }} -

- If you suspect abuse, please contact security@tailscale.com. -

-{{end}} - -

- Documentation: -

- - - -{{if .Disabled}} -

Status: disabled

-{{end}} - -{{if .AllowDebug}} -

Debug info at /debug/.

-{{end}} - - -`)) diff --git a/cmd/derper/derper_test.go b/cmd/derper/derper_test.go deleted file mode 100644 index 08d2e9cbf97c2..0000000000000 --- a/cmd/derper/derper_test.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "bytes" - "context" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "tailscale.com/derp/derphttp" - "tailscale.com/tstest/deptest" -) - -func TestProdAutocertHostPolicy(t *testing.T) { - tests := []struct { - in string - wantOK bool - }{ - {"derp.tailscale.com", true}, - {"derp.tailscale.com.", true}, - {"derp1.tailscale.com", true}, - {"derp1b.tailscale.com", true}, - {"derp2.tailscale.com", true}, - {"derp02.tailscale.com", true}, - {"derp-nyc.tailscale.com", true}, - {"derpfoo.tailscale.com", true}, - {"derp02.bar.tailscale.com", false}, - {"example.net", false}, - } - for _, tt := range tests { - got := prodAutocertHostPolicy(context.Background(), tt.in) == nil - if got != tt.wantOK { - t.Errorf("f(%q) = %v; want %v", tt.in, got, tt.wantOK) - } - } -} - -func TestNoContent(t *testing.T) { - testCases := []struct { - name string - input string - want string - }{ - { - name: "no challenge", - }, - { - name: "valid challenge", - input: "input", - want: "response input", - }, - { - name: "valid challenge hostname", - input: "ts_derp99b.tailscale.com", - want: "response ts_derp99b.tailscale.com", - }, - { - name: "invalid challenge", - input: "foo\x00bar", - want: "", - }, - { - name: "whitespace invalid challenge", - input: "foo bar", - want: "", - }, - { - name: "long challenge", - input: strings.Repeat("x", 65), - want: "", - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - req, _ := http.NewRequest("GET", "https://localhost/generate_204", nil) - if tt.input != "" { - req.Header.Set(derphttp.NoContentChallengeHeader, tt.input) - } - w := httptest.NewRecorder() - derphttp.ServeNoContent(w, req) - resp := w.Result() - - if tt.want == "" { - if h, found := resp.Header[derphttp.NoContentResponseHeader]; found { - t.Errorf("got %+v; expected no response header", h) - } - return - } - - if got := resp.Header.Get(derphttp.NoContentResponseHeader); got != tt.want { - t.Errorf("got %q; want %q", got, tt.want) - } - }) - } -} - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - BadDeps: map[string]string{ - "testing": "do not use testing package in production code", - "gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756", - "tailscale.com/net/packet": "not needed in derper", - "github.com/gaissmai/bart": "not needed in derper", - "database/sql/driver": "not needed in derper", // previously came in via github.com/google/uuid - }, - }.Check(t) -} - -func TestTemplate(t *testing.T) { - buf := &bytes.Buffer{} - err := homePageTemplate.Execute(buf, templateData{ - ShowAbuseInfo: true, - Disabled: true, - AllowDebug: true, - }) - if err != nil { - t.Fatal(err) - } - - str := buf.String() - if !strings.Contains(str, "If you suspect abuse") { - t.Error("Output is missing abuse mailto") - } - if !strings.Contains(str, "Tailscale Security Policies") { - t.Error("Output is missing Tailscale Security Policies link") - } - if !strings.Contains(str, "Status:") { - t.Error("Output is missing disabled status") - } - if !strings.Contains(str, "Debug info") { - t.Error("Output is missing debug info") - } - fmt.Println(buf.String()) -} diff --git a/cmd/derper/mesh.go b/cmd/derper/mesh.go deleted file mode 100644 index ee1807f001202..0000000000000 --- a/cmd/derper/mesh.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "strings" - "time" - - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" -) - -func startMesh(s *derp.Server) error { - if *meshWith == "" { - return nil - } - if !s.HasMeshKey() { - return errors.New("--mesh-with requires --mesh-psk-file") - } - for _, host := range strings.Split(*meshWith, ",") { - if err := startMeshWithHost(s, host); err != nil { - return err - } - } - return nil -} - -func startMeshWithHost(s *derp.Server, host string) error { - logf := logger.WithPrefix(log.Printf, fmt.Sprintf("mesh(%q): ", host)) - netMon := netmon.NewStatic() // good enough for cmd/derper; no need for netns fanciness - c, err := derphttp.NewClient(s.PrivateKey(), "https://"+host+"/derp", logf, netMon) - if err != nil { - return err - } - c.MeshKey = s.MeshKey() - c.WatchConnectionChanges = true - - // For meshed peers within a region, connect via VPC addresses. - c.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - var d net.Dialer - var r net.Resolver - if base, ok := strings.CutSuffix(host, ".tailscale.com"); ok && port == "443" { - subCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - vpcHost := base + "-vpc.tailscale.com" - ips, _ := r.LookupIP(subCtx, "ip", vpcHost) - if len(ips) > 0 { - vpcAddr := net.JoinHostPort(ips[0].String(), port) - c, err := d.DialContext(subCtx, network, vpcAddr) - if err == nil { - log.Printf("connected to %v (%v) instead of %v", vpcHost, ips[0], base) - return c, nil - } - log.Printf("failed to connect to %v (%v): %v; trying non-VPC route", vpcHost, ips[0], err) - } - } - return d.DialContext(ctx, network, addr) - }) - - add := func(m derp.PeerPresentMessage) { s.AddPacketForwarder(m.Key, c) } - remove := func(m derp.PeerGoneMessage) { s.RemovePacketForwarder(m.Peer, c) } - go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove) - return nil -} diff --git a/cmd/derper/websocket.go b/cmd/derper/websocket.go deleted file mode 100644 index 05f40deb816d5..0000000000000 --- a/cmd/derper/websocket.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "bufio" - "expvar" - "log" - "net/http" - "strings" - - "github.com/coder/websocket" - "tailscale.com/derp" - "tailscale.com/net/wsconn" -) - -var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts") - -// addWebSocketSupport returns a Handle wrapping base that adds WebSocket server support. -func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - up := strings.ToLower(r.Header.Get("Upgrade")) - - // Very early versions of Tailscale set "Upgrade: WebSocket" but didn't actually - // speak WebSockets (they still assumed DERP's binary framing). So to distinguish - // clients that actually want WebSockets, look for an explicit "derp" subprotocol. - if up != "websocket" || !strings.Contains(r.Header.Get("Sec-Websocket-Protocol"), "derp") { - base.ServeHTTP(w, r) - return - } - - c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - Subprotocols: []string{"derp"}, - OriginPatterns: []string{"*"}, - // Disable compression because we transmit WireGuard messages that - // are not compressible. - // Additionally, Safari has a broken implementation of compression - // (see https://github.com/nhooyr/websocket/issues/218) that makes - // enabling it actively harmful. - CompressionMode: websocket.CompressionDisabled, - }) - if err != nil { - log.Printf("websocket.Accept: %v", err) - return - } - defer c.Close(websocket.StatusInternalError, "closing") - if c.Subprotocol() != "derp" { - c.Close(websocket.StatusPolicyViolation, "client must speak the derp subprotocol") - return - } - counterWebSocketAccepts.Add(1) - wc := wsconn.NetConn(r.Context(), c, websocket.MessageBinary, r.RemoteAddr) - brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc)) - s.Accept(r.Context(), wc, brw, r.RemoteAddr) - }) -} diff --git a/cmd/derpprobe/derpprobe.go b/cmd/derpprobe/derpprobe.go deleted file mode 100644 index 8f04326b03980..0000000000000 --- a/cmd/derpprobe/derpprobe.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The derpprobe binary probes derpers. -package main - -import ( - "flag" - "fmt" - "log" - "net/http" - "sort" - "time" - - "tailscale.com/prober" - "tailscale.com/tsweb" - "tailscale.com/version" -) - -var ( - derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://) or 'local' to use the local tailscaled's DERP map") - versionFlag = flag.Bool("version", false, "print version and exit") - 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") - meshInterval = flag.Duration("mesh-interval", 15*time.Second, "mesh probe interval") - stunInterval = flag.Duration("stun-interval", 15*time.Second, "STUN probe interval") - tlsInterval = flag.Duration("tls-interval", 15*time.Second, "TLS probe interval") - bwInterval = flag.Duration("bw-interval", 0, "bandwidth probe interval (0 = no bandwidth probing)") - bwSize = flag.Int64("bw-probe-size-bytes", 1_000_000, "bandwidth probe size") - regionCode = flag.String("region-code", "", "probe only this region (e.g. 'lax'); if left blank, all regions will be probed") -) - -func main() { - flag.Parse() - if *versionFlag { - fmt.Println(version.Long()) - return - } - - p := prober.New().WithSpread(*spread).WithOnce(*probeOnce).WithMetricNamespace("derpprobe") - opts := []prober.DERPOpt{ - prober.WithMeshProbing(*meshInterval), - prober.WithSTUNProbing(*stunInterval), - prober.WithTLSProbing(*tlsInterval), - } - if *bwInterval > 0 { - opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize)) - } - if *regionCode != "" { - opts = append(opts, prober.WithRegion(*regionCode)) - } - dp, err := prober.DERP(p, *derpMapURL, opts...) - if err != nil { - log.Fatal(err) - } - p.Run("derpmap-probe", *interval, nil, dp.ProbeMap) - - if *probeOnce { - log.Printf("Waiting for all probes (may take up to 1m)") - p.Wait() - - st := getOverallStatus(p) - for _, s := range st.good { - log.Printf("good: %s", s) - } - for _, s := range st.bad { - log.Printf("bad: %s", s) - } - return - } - - mux := http.NewServeMux() - d := tsweb.Debugger(mux) - d.Handle("probe-run", "Run a probe", tsweb.StdHandler(tsweb.ReturnHandlerFunc(p.RunHandler), tsweb.HandlerOptions{Logf: log.Printf})) - mux.Handle("/", tsweb.StdHandler(p.StatusHandler( - prober.WithTitle("DERP Prober"), - prober.WithPageLink("Prober metrics", "/debug/varz"), - prober.WithProbeLink("Run Probe", "/debug/probe-run?name={{.Name}}"), - ), tsweb.HandlerOptions{Logf: log.Printf})) - mux.Handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok\n")) - })) - log.Printf("Listening on %s", *listen) - log.Fatal(http.ListenAndServe(*listen, mux)) -} - -type overallStatus struct { - good, bad []string -} - -func (st *overallStatus) addBadf(format string, a ...any) { - st.bad = append(st.bad, fmt.Sprintf(format, a...)) -} - -func (st *overallStatus) addGoodf(format string, a ...any) { - st.good = append(st.good, fmt.Sprintf(format, a...)) -} - -func getOverallStatus(p *prober.Prober) (o overallStatus) { - for p, i := range p.ProbeInfo() { - if i.End.IsZero() { - // Do not show probes that have not finished yet. - continue - } - if i.Result { - o.addGoodf("%s: %s", p, i.Latency) - } else { - o.addBadf("%s: %s", p, i.Error) - } - } - - sort.Strings(o.bad) - sort.Strings(o.good) - return -} diff --git a/cmd/dist/dist.go b/cmd/dist/dist.go deleted file mode 100644 index 05f5bbfb231a8..0000000000000 --- a/cmd/dist/dist.go +++ /dev/null @@ -1,65 +0,0 @@ -// 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/qnap" - "tailscale.com/release/dist/synology" - "tailscale.com/release/dist/unixpkgs" -) - -var ( - synologyPackageCenter bool - qnapPrivateKeyPath string - qnapCertificatePath string -) - -func getTargets() ([]dist.Target, error) { - var ret []dist.Target - - ret = append(ret, unixpkgs.Targets(unixpkgs.Signers{})...) - // Synology packages can be built either for sideloading, or for - // distribution by Synology in their package center. When - // distributed through the package center, apps can request - // additional permissions to use a tuntap interface and control - // the NAS's network stack, rather than be forced to run in - // userspace mode. - // - // Since only we can provide packages to Synology for - // distribution, we default to building the "sideload" variant of - // packages that we distribute on pkgs.tailscale.com. - // - // To build for package center, run - // ./tool/go run ./cmd/dist build --synology-package-center synology - ret = append(ret, synology.Targets(synologyPackageCenter, nil)...) - if (qnapPrivateKeyPath == "") != (qnapCertificatePath == "") { - return nil, errors.New("both --qnap-private-key-path and --qnap-certificate-path must be set") - } - ret = append(ret, qnap.Targets(qnapPrivateKeyPath, qnapCertificatePath)...) - return ret, nil -} - -func main() { - cmd := cli.CLI(getTargets) - for _, subcmd := range cmd.Subcommands { - if subcmd.Name == "build" { - subcmd.FlagSet.BoolVar(&synologyPackageCenter, "synology-package-center", false, "build synology packages with extra metadata for the official package center") - subcmd.FlagSet.StringVar(&qnapPrivateKeyPath, "qnap-private-key-path", "", "sign qnap packages with given key (must also provide --qnap-certificate-path)") - subcmd.FlagSet.StringVar(&qnapCertificatePath, "qnap-certificate-path", "", "sign qnap packages with given certificate (must also provide --qnap-private-key-path)") - } - } - - 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/.gitignore b/cmd/get-authkey/.gitignore deleted file mode 100644 index 3f9c9fb90e68e..0000000000000 --- a/cmd/get-authkey/.gitignore +++ /dev/null @@ -1 +0,0 @@ -get-authkey diff --git a/cmd/get-authkey/main.go b/cmd/get-authkey/main.go deleted file mode 100644 index 777258d64b21b..0000000000000 --- a/cmd/get-authkey/main.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// get-authkey allocates an authkey using an OAuth API client -// https://tailscale.com/s/oauth-clients and prints it -// to stdout for scripts to capture and use. -package main - -import ( - "cmp" - "context" - "flag" - "fmt" - "log" - "os" - "strings" - - "golang.org/x/oauth2/clientcredentials" - "tailscale.com/client/tailscale" -) - -func main() { - // Required to use our client API. We're fine with the instability since the - // client lives in the same repo as this code. - tailscale.I_Acknowledge_This_API_Is_Unstable = true - - reusable := flag.Bool("reusable", false, "allocate a reusable authkey") - ephemeral := flag.Bool("ephemeral", false, "allocate an ephemeral authkey") - preauth := flag.Bool("preauth", true, "set the authkey as pre-authorized") - tags := flag.String("tags", "", "comma-separated list of tags to apply to the authkey") - flag.Parse() - - clientID := os.Getenv("TS_API_CLIENT_ID") - clientSecret := os.Getenv("TS_API_CLIENT_SECRET") - if clientID == "" || clientSecret == "" { - log.Fatal("TS_API_CLIENT_ID and TS_API_CLIENT_SECRET must be set") - } - - if *tags == "" { - log.Fatal("at least one tag must be specified") - } - - baseURL := cmp.Or(os.Getenv("TS_BASE_URL"), "https://api.tailscale.com") - - credentials := clientcredentials.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - TokenURL: baseURL + "/api/v2/oauth/token", - Scopes: []string{"device"}, - } - - ctx := context.Background() - tsClient := tailscale.NewClient("-", nil) - tsClient.UserAgent = "tailscale-get-authkey" - tsClient.HTTPClient = credentials.Client(ctx) - tsClient.BaseURL = baseURL - - caps := tailscale.KeyCapabilities{ - Devices: tailscale.KeyDeviceCapabilities{ - Create: tailscale.KeyDeviceCreateCapabilities{ - Reusable: *reusable, - Ephemeral: *ephemeral, - Preauthorized: *preauth, - Tags: strings.Split(*tags, ","), - }, - }, - } - - authkey, _, err := tsClient.CreateKey(ctx, caps) - if err != nil { - log.Fatal(err.Error()) - } - - fmt.Println(authkey) -} diff --git a/cmd/gitops-pusher/.gitignore b/cmd/gitops-pusher/.gitignore deleted file mode 100644 index 5044522494b23..0000000000000 --- a/cmd/gitops-pusher/.gitignore +++ /dev/null @@ -1 +0,0 @@ -version-cache.json diff --git a/cmd/gitops-pusher/README.md b/cmd/gitops-pusher/README.md deleted file mode 100644 index 9f77ea970e033..0000000000000 --- a/cmd/gitops-pusher/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# gitops-pusher - -This is a small tool to help people achieve a -[GitOps](https://about.gitlab.com/topics/gitops/) workflow with Tailscale ACL -changes. This tool is intended to be used in a CI flow that looks like this: - -```yaml -name: Tailscale ACL syncing - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - acls: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Go environment - uses: actions/setup-go@v3.2.0 - - - name: Install gitops-pusher - run: go install tailscale.com/cmd/gitops-pusher@latest - - - name: Deploy ACL - if: github.event_name == 'push' - env: - TS_API_KEY: ${{ secrets.TS_API_KEY }} - TS_TAILNET: ${{ secrets.TS_TAILNET }} - run: | - ~/go/bin/gitops-pusher --policy-file ./policy.hujson apply - - - name: ACL tests - if: github.event_name == 'pull_request' - env: - TS_API_KEY: ${{ secrets.TS_API_KEY }} - TS_TAILNET: ${{ secrets.TS_TAILNET }} - run: | - ~/go/bin/gitops-pusher --policy-file ./policy.hujson test -``` - -Change the value of the `--policy-file` flag to point to the policy file on -disk. Policy files should be in [HuJSON](https://github.com/tailscale/hujson) -format. diff --git a/cmd/gitops-pusher/cache.go b/cmd/gitops-pusher/cache.go deleted file mode 100644 index 6792e5e63e9cc..0000000000000 --- a/cmd/gitops-pusher/cache.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "encoding/json" - "os" -) - -// Cache contains cached information about the last time this tool was run. -// -// This is serialized to a JSON file that should NOT be checked into git. -// It should be managed with either CI cache tools or stored locally somehow. The -// exact mechanism is irrelevant as long as it is consistent. -// -// This allows gitops-pusher to detect external ACL changes. I'm not sure what to -// call this problem, so I've been calling it the "three version problem" in my -// notes. The basic problem is that at any given time we only have two versions -// of the ACL file at any given point. In order to check if there has been -// tampering of the ACL files in the admin panel, we need to have a _third_ version -// to compare against. -// -// In this case I am not storing the old ACL entirely (though that could be a -// reasonable thing to add in the future), but only its sha256sum. This allows -// us to detect if the shasum in control matches the shasum we expect, and if that -// expectation fails, then we can react accordingly. -type Cache struct { - PrevETag string // Stores the previous ETag of the ACL to allow -} - -// Save persists the cache to a given file. -func (c *Cache) Save(fname string) error { - os.Remove(fname) - fout, err := os.Create(fname) - if err != nil { - return err - } - defer fout.Close() - - return json.NewEncoder(fout).Encode(c) -} - -// LoadCache loads the cache from a given file. -func LoadCache(fname string) (*Cache, error) { - var result Cache - - fin, err := os.Open(fname) - if err != nil { - return nil, err - } - defer fin.Close() - - err = json.NewDecoder(fin).Decode(&result) - if err != nil { - return nil, err - } - - return &result, nil -} - -// Shuck removes the first and last character of a string, analogous to -// shucking off the husk of an ear of corn. -func Shuck(s string) string { - return s[1 : len(s)-1] -} diff --git a/cmd/gitops-pusher/gitops-pusher.go b/cmd/gitops-pusher/gitops-pusher.go deleted file mode 100644 index c33937ef24959..0000000000000 --- a/cmd/gitops-pusher/gitops-pusher.go +++ /dev/null @@ -1,412 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs. -// -// See README.md for more details. -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "flag" - "fmt" - "log" - "net/http" - "os" - "regexp" - "strings" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/tailscale/hujson" - "golang.org/x/oauth2/clientcredentials" - "tailscale.com/client/tailscale" - "tailscale.com/util/httpm" -) - -var ( - rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError) - policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file") - cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash") - timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run") - githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)") - apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact") - failOnManualEdits = rootFlagSet.Bool("fail-on-manual-edits", false, "fail if manual edits to the ACLs in the admin panel are detected; when set to false (the default) only a warning is printed") -) - -func modifiedExternallyError() error { - if *githubSyntax { - return fmt.Errorf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.", *policyFname) - } else { - return fmt.Errorf("The policy file was modified externally in the admin console.") - } -} - -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, client, tailnet, apiKey) - if err != nil { - return err - } - - localEtag, err := sumFile(*policyFname) - if err != nil { - return err - } - - if cache.PrevETag == "" { - log.Println("no previous etag found, assuming local file is correct and recording that") - cache.PrevETag = localEtag - } - - log.Printf("control: %s", controlEtag) - log.Printf("local: %s", localEtag) - log.Printf("cache: %s", cache.PrevETag) - - if controlEtag == localEtag { - cache.PrevETag = localEtag - log.Println("no update needed, doing nothing") - return nil - } - - if cache.PrevETag != controlEtag { - if err := modifiedExternallyError(); err != nil { - if *failOnManualEdits { - return err - } else { - fmt.Println(err) - } - } - } - - if err := applyNewACL(ctx, client, tailnet, apiKey, *policyFname, controlEtag); err != nil { - return err - } - - cache.PrevETag = localEtag - - return nil - } -} - -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, client, tailnet, apiKey) - if err != nil { - return err - } - - localEtag, err := sumFile(*policyFname) - if err != nil { - return err - } - - if cache.PrevETag == "" { - log.Println("no previous etag found, assuming local file is correct and recording that") - cache.PrevETag = localEtag - } - - log.Printf("control: %s", controlEtag) - log.Printf("local: %s", localEtag) - log.Printf("cache: %s", cache.PrevETag) - - if controlEtag == localEtag { - log.Println("no updates found, doing nothing") - return nil - } - - if cache.PrevETag != controlEtag { - if err := modifiedExternallyError(); err != nil { - if *failOnManualEdits { - return err - } else { - fmt.Println(err) - } - } - } - - if err := testNewACLs(ctx, client, tailnet, apiKey, *policyFname); err != nil { - return err - } - return nil - } -} - -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, client, tailnet, apiKey) - if err != nil { - return err - } - - localEtag, err := sumFile(*policyFname) - if err != nil { - return err - } - - if cache.PrevETag == "" { - log.Println("no previous etag found, assuming local file is correct and recording that") - cache.PrevETag = Shuck(localEtag) - } - - log.Printf("control: %s", controlEtag) - log.Printf("local: %s", localEtag) - log.Printf("cache: %s", cache.PrevETag) - - return nil - } -} - -func main() { - tailnet, ok := os.LookupEnv("TS_TAILNET") - if !ok { - log.Fatal("set envvar TS_TAILNET to your tailnet's name") - } - apiKey, ok := os.LookupEnv("TS_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 apiKey != "" && (oauthId != "" || oauthSecret != "") { - log.Fatal("set either the envvar TS_API_KEY or TS_OAUTH_ID and TS_OAUTH_SECRET") - } - var client *http.Client - if oiok && (oauthId != "" || oauthSecret != "") { - // Both should ideally be set, but if either are non-empty it means the user had an intent - // to set _something_, so they should receive the oauth error flow. - 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 { - if os.IsNotExist(err) { - cache = &Cache{} - } else { - log.Fatalf("error loading cache: %v", err) - } - } - defer cache.Save(*cacheFname) - - applyCmd := &ffcli.Command{ - Name: "apply", - ShortUsage: "gitops-pusher [options] apply", - ShortHelp: "Pushes changes to CONTROL", - LongHelp: `Pushes changes to CONTROL`, - Exec: apply(cache, client, tailnet, apiKey), - } - - testCmd := &ffcli.Command{ - Name: "test", - ShortUsage: "gitops-pusher [options] test", - ShortHelp: "Tests ACL changes", - LongHelp: "Tests ACL changes", - Exec: test(cache, client, tailnet, apiKey), - } - - cksumCmd := &ffcli.Command{ - Name: "checksum", - 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, client, tailnet, apiKey), - } - - root := &ffcli.Command{ - ShortUsage: "gitops-pusher [options] ", - ShortHelp: "Push Tailscale ACLs to CONTROL using a GitOps workflow", - Subcommands: []*ffcli.Command{applyCmd, cksumCmd, testCmd}, - FlagSet: rootFlagSet, - } - - if err := root.Parse(os.Args[1:]); err != nil { - log.Fatal(err) - } - - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - if err := root.Run(ctx); err != nil { - fmt.Println(err) - os.Exit(1) - } -} - -func sumFile(fname string) (string, error) { - data, err := os.ReadFile(fname) - if err != nil { - return "", err - } - - formatted, err := hujson.Format(data) - if err != nil { - return "", err - } - - h := sha256.New() - _, err = h.Write(formatted) - if err != nil { - return "", err - } - - return fmt.Sprintf("%x", h.Sum(nil)), nil -} - -func applyNewACL(ctx context.Context, client *http.Client, tailnet, apiKey, policyFname, oldEtag string) error { - fin, err := os.Open(policyFname) - if err != nil { - return err - } - defer fin.Close() - - req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), fin) - if err != nil { - return err - } - - req.SetBasicAuth(apiKey, "") - req.Header.Set("Content-Type", "application/hujson") - req.Header.Set("If-Match", `"`+oldEtag+`"`) - - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - got := resp.StatusCode - want := http.StatusOK - if got != want { - var ate ACLGitopsTestError - err := json.NewDecoder(resp.Body).Decode(&ate) - if err != nil { - return err - } - - return ate - } - - return nil -} - -func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, policyFname string) error { - data, err := os.ReadFile(policyFname) - if err != nil { - return err - } - data, err = hujson.Standardize(data) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl/validate", *apiServer, tailnet), bytes.NewBuffer(data)) - if err != nil { - return err - } - - req.SetBasicAuth(apiKey, "") - req.Header.Set("Content-Type", "application/hujson") - - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - var ate ACLGitopsTestError - err = json.NewDecoder(resp.Body).Decode(&ate) - if err != nil { - return err - } - - if len(ate.Message) != 0 || len(ate.Data) != 0 { - return ate - } - - got := resp.StatusCode - want := http.StatusOK - if got != want { - return fmt.Errorf("wanted HTTP status code %d but got %d", want, got) - } - - return nil -} - -var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`) - -// ACLGitopsTestError is redefined here so we can add a custom .Error() response -type ACLGitopsTestError struct { - tailscale.ACLTestError -} - -func (ate ACLGitopsTestError) Error() string { - var sb strings.Builder - - if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) { - sp := lineColMessageSplit.FindStringSubmatch(ate.Message) - - line := sp[1] - col := sp[2] - msg := sp[3] - - fmt.Fprintf(&sb, "::error file=%s,line=%s,col=%s::%s", *policyFname, line, col, msg) - } else { - fmt.Fprintln(&sb, ate.Message) - } - fmt.Fprintln(&sb) - - for _, data := range ate.Data { - if data.User != "" { - fmt.Fprintf(&sb, "For user %s:\n", data.User) - } - - if len(data.Errors) > 0 { - fmt.Fprint(&sb, "Errors found:\n") - for _, err := range data.Errors { - fmt.Fprintf(&sb, "- %s\n", err) - } - } - - if len(data.Warnings) > 0 { - fmt.Fprint(&sb, "Warnings found:\n") - for _, err := range data.Warnings { - fmt.Fprintf(&sb, "- %s\n", err) - } - } - } - - return sb.String() -} - -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 - } - - req.SetBasicAuth(apiKey, "") - req.Header.Set("Accept", "application/hujson") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - got := resp.StatusCode - want := http.StatusOK - if got != want { - return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got) - } - - return Shuck(resp.Header.Get("ETag")), nil -} diff --git a/cmd/gitops-pusher/gitops-pusher_test.go b/cmd/gitops-pusher/gitops-pusher_test.go deleted file mode 100644 index b050761d9832d..0000000000000 --- a/cmd/gitops-pusher/gitops-pusher_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause -package main - -import ( - "encoding/json" - "strings" - "testing" - - "tailscale.com/client/tailscale" -) - -func TestEmbeddedTypeUnmarshal(t *testing.T) { - var gitopsErr ACLGitopsTestError - gitopsErr.Message = "gitops response error" - gitopsErr.Data = []tailscale.ACLTestFailureSummary{ - { - User: "GitopsError", - Errors: []string{"this was initially created as a gitops error"}, - }, - } - - var aclTestErr tailscale.ACLTestError - aclTestErr.Message = "native ACL response error" - aclTestErr.Data = []tailscale.ACLTestFailureSummary{ - { - User: "ACLError", - Errors: []string{"this was initially created as an ACL error"}, - }, - } - - t.Run("unmarshal gitops type from acl type", func(t *testing.T) { - b, _ := json.Marshal(aclTestErr) - var e ACLGitopsTestError - err := json.Unmarshal(b, &e) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(e.Error(), "For user ACLError") { // the gitops error prints out the user, the acl error doesn't - t.Fatalf("user heading for 'ACLError' not found in gitops error: %v", e.Error()) - } - }) - t.Run("unmarshal acl type from gitops type", func(t *testing.T) { - b, _ := json.Marshal(gitopsErr) - var e tailscale.ACLTestError - err := json.Unmarshal(b, &e) - if err != nil { - t.Fatal(err) - } - expectedErr := `Status: 0, Message: "gitops response error", Data: [{User:GitopsError Errors:[this was initially created as a gitops error] Warnings:[]}]` - if e.Error() != expectedErr { - t.Fatalf("got %v\n, expected %v", e.Error(), expectedErr) - } - }) -} diff --git a/cmd/hello/hello.go b/cmd/hello/hello.go deleted file mode 100644 index e4b0ca8278095..0000000000000 --- a/cmd/hello/hello.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The hello binary runs hello.ts.net. -package main // import "tailscale.com/cmd/hello" - -import ( - "context" - "crypto/tls" - _ "embed" - "encoding/json" - "errors" - "flag" - "html/template" - "log" - "net/http" - "os" - "strings" - "time" - - "tailscale.com/client/tailscale" - "tailscale.com/client/tailscale/apitype" -) - -var ( - httpAddr = flag.String("http", ":80", "address to run an HTTP server on, or empty for none") - httpsAddr = flag.String("https", ":443", "address to run an HTTPS server on, or empty for none") - testIP = flag.String("test-ip", "", "if non-empty, look up IP and exit before running a server") -) - -//go:embed hello.tmpl.html -var embeddedTemplate string - -var localClient tailscale.LocalClient - -func main() { - flag.Parse() - if *testIP != "" { - res, err := localClient.WhoIs(context.Background(), *testIP) - if err != nil { - log.Fatal(err) - } - e := json.NewEncoder(os.Stdout) - e.SetIndent("", "\t") - e.Encode(res) - return - } - if devMode() { - // Parse it optimistically - var err error - tmpl, err = template.New("home").Parse(embeddedTemplate) - if err != nil { - log.Printf("ignoring template error in dev mode: %v", err) - } - } else { - if embeddedTemplate == "" { - log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+") - } - tmpl = template.Must(template.New("home").Parse(embeddedTemplate)) - } - - http.HandleFunc("/", root) - log.Printf("Starting hello server.") - - errc := make(chan error, 1) - if *httpAddr != "" { - log.Printf("running HTTP server on %s", *httpAddr) - go func() { - errc <- http.ListenAndServe(*httpAddr, nil) - }() - } - if *httpsAddr != "" { - log.Printf("running HTTPS server on %s", *httpsAddr) - go func() { - hs := &http.Server{ - Addr: *httpsAddr, - TLSConfig: &tls.Config{ - GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { - switch hi.ServerName { - case "hello.ts.net": - return localClient.GetCertificate(hi) - case "hello.ipn.dev": - c, err := tls.LoadX509KeyPair( - "/etc/hello/hello.ipn.dev.crt", - "/etc/hello/hello.ipn.dev.key", - ) - if err != nil { - return nil, err - } - return &c, nil - } - return nil, errors.New("invalid SNI name") - }, - }, - IdleTimeout: 30 * time.Second, - ReadHeaderTimeout: 20 * time.Second, - MaxHeaderBytes: 10 << 10, - } - errc <- hs.ListenAndServeTLS("", "") - }() - } - log.Fatal(<-errc) -} - -func devMode() bool { return *httpsAddr == "" && *httpAddr != "" } - -func getTmpl() (*template.Template, error) { - if devMode() { - tmplData, err := os.ReadFile("hello.tmpl.html") - if os.IsNotExist(err) { - log.Printf("using baked-in template in dev mode; can't find hello.tmpl.html in current directory") - return tmpl, nil - } - return template.New("home").Parse(string(tmplData)) - } - return tmpl, nil -} - -// tmpl is the template used in prod mode. -// In dev mode it's only used if the template file doesn't exist on disk. -// It's initialized by main after flag parsing. -var tmpl *template.Template - -type tmplData struct { - DisplayName string // "Foo Barberson" - LoginName string // "foo@bar.com" - ProfilePicURL string // "https://..." - MachineName string // "imac5k" - MachineOS string // "Linux" - IP string // "100.2.3.4" -} - -func tailscaleIP(who *apitype.WhoIsResponse) string { - if who == nil { - return "" - } - for _, nodeIP := range who.Node.Addresses { - if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() { - return nodeIP.Addr().String() - } - } - for _, nodeIP := range who.Node.Addresses { - if nodeIP.IsSingleIP() { - return nodeIP.Addr().String() - } - } - return "" -} - -func root(w http.ResponseWriter, r *http.Request) { - if r.TLS == nil && *httpsAddr != "" { - host := r.Host - if strings.Contains(r.Host, "100.101.102.103") || - strings.Contains(r.Host, "hello.ipn.dev") { - host = "hello.ts.net" - } - http.Redirect(w, r, "https://"+host, http.StatusFound) - return - } - if r.RequestURI != "/" { - http.Redirect(w, r, "/", http.StatusFound) - return - } - if r.TLS != nil && *httpsAddr != "" && strings.Contains(r.Host, "hello.ipn.dev") { - http.Redirect(w, r, "https://hello.ts.net", http.StatusFound) - return - } - tmpl, err := getTmpl() - if err != nil { - w.Header().Set("Content-Type", "text/plain") - http.Error(w, "template error: "+err.Error(), 500) - return - } - - who, err := localClient.WhoIs(r.Context(), r.RemoteAddr) - var data tmplData - if err != nil { - if devMode() { - log.Printf("warning: using fake data in dev mode due to whois lookup error: %v", err) - data = tmplData{ - DisplayName: "Taily Scalerson", - LoginName: "taily@scaler.son", - ProfilePicURL: "https://placekitten.com/200/200", - MachineName: "scaled", - MachineOS: "Linux", - IP: "100.1.2.3", - } - } else { - log.Printf("whois(%q) error: %v", r.RemoteAddr, err) - http.Error(w, "Your Tailscale works, but we failed to look you up.", 500) - return - } - } else { - data = tmplData{ - DisplayName: who.UserProfile.DisplayName, - LoginName: who.UserProfile.LoginName, - ProfilePicURL: who.UserProfile.ProfilePicURL, - MachineName: firstLabel(who.Node.ComputedName), - MachineOS: who.Node.Hostinfo.OS(), - IP: tailscaleIP(who), - } - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tmpl.Execute(w, data) -} - -// firstLabel s up until the first period, if any. -func firstLabel(s string) string { - s, _, _ = strings.Cut(s, ".") - return s -} diff --git a/cmd/hello/hello.tmpl.html b/cmd/hello/hello.tmpl.html deleted file mode 100644 index 3ecd1b58ab9b5..0000000000000 --- a/cmd/hello/hello.tmpl.html +++ /dev/null @@ -1,438 +0,0 @@ - - - - - - Hello from Tailscale - - - - -
- -
-

You're connected over Tailscale!

-

This device is signed in as…

-
-
-
- - - -
-
-
-
- {{ with .DisplayName }} -

{{.}}

- {{ end }} -
{{.LoginName}}
-
-
-
-
- - - - - - -

{{.MachineName}}

-
-
{{.IP}}
-
-
- -
- - diff --git a/cmd/k8s-nameserver/main.go b/cmd/k8s-nameserver/main.go deleted file mode 100644 index ca4b449358083..0000000000000 --- a/cmd/k8s-nameserver/main.go +++ /dev/null @@ -1,379 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// k8s-nameserver is a simple nameserver implementation meant to be used with -// k8s-operator to allow to resolve magicDNS names associated with tailnet -// proxies in cluster. -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net" - "os" - "os/signal" - "path/filepath" - "sync" - "syscall" - - "github.com/fsnotify/fsnotify" - "github.com/miekg/dns" - operatorutils "tailscale.com/k8s-operator" - "tailscale.com/util/dnsname" -) - -const ( - // tsNetDomain is the domain that this DNS nameserver has registered a handler for. - tsNetDomain = "ts.net" - // addr is the the address that the UDP and TCP listeners will listen on. - addr = ":1053" - - // The following constants are specific to the nameserver configuration - // provided by a mounted Kubernetes Configmap. The Configmap mounted at - // /config is the only supported way for configuring this nameserver. - defaultDNSConfigDir = "/config" - kubeletMountedConfigLn = "..data" -) - -// nameserver is a simple nameserver that responds to DNS queries for A records -// for ts.net domain names over UDP or TCP. It serves DNS responses from -// in-memory IPv4 host records. It is intended to be deployed on Kubernetes with -// a ConfigMap mounted at /config that should contain the host records. It -// dynamically reconfigures its in-memory mappings as the contents of the -// mounted ConfigMap changes. -type nameserver struct { - // configReader returns the latest desired configuration (host records) - // for the nameserver. By default it gets set to a reader that reads - // from a Kubernetes ConfigMap mounted at /config, but this can be - // overridden in tests. - configReader configReaderFunc - // configWatcher is a watcher that returns an event when the desired - // configuration has changed and the nameserver should update the - // in-memory records. - configWatcher <-chan string - - mu sync.Mutex // protects following - // ip4 are the in-memory hostname -> IP4 mappings that the nameserver - // uses to respond to A record queries. - ip4 map[dnsname.FQDN][]net.IP -} - -func main() { - ctx, cancel := context.WithCancel(context.Background()) - - // Ensure that we watch the kube Configmap mounted at /config for - // nameserver configuration updates and send events when updates happen. - c := ensureWatcherForKubeConfigMap(ctx) - - ns := &nameserver{ - configReader: configMapConfigReader, - configWatcher: c, - } - - // Ensure that in-memory records get set up to date now and will get - // reset when the configuration changes. - ns.runRecordsReconciler(ctx) - - // Register a DNS server handle for ts.net domain names. Not having a - // handle registered for any other domain names is how we enforce that - // this nameserver can only be used for ts.net domains - querying any - // other domain names returns Rcode Refused. - dns.HandleFunc(tsNetDomain, ns.handleFunc()) - - // Listen for DNS queries over UDP and TCP. - udpSig := make(chan os.Signal) - tcpSig := make(chan os.Signal) - go listenAndServe("udp", addr, udpSig) - go listenAndServe("tcp", addr, tcpSig) - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - s := <-sig - log.Printf("OS signal (%s) received, shutting down", s) - cancel() // exit the records reconciler and configmap watcher goroutines - udpSig <- s // stop the UDP listener - tcpSig <- s // stop the TCP listener -} - -// handleFunc is a DNS query handler that can respond to A record queries from -// the nameserver's in-memory records. -// - If an A record query is received and the -// nameserver's in-memory records contain records for the queried domain name, -// return a success response. -// - If an A record query is received, but the -// nameserver's in-memory records do not contain records for the queried domain name, -// return NXDOMAIN. -// - If an A record query is received, but the queried domain name is not valid, return Format Error. -// - If a query is received for any other record type than A, return Not Implemented. -func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) { - h := func(w dns.ResponseWriter, r *dns.Msg) { - m := new(dns.Msg) - defer func() { - w.WriteMsg(m) - }() - if len(r.Question) < 1 { - log.Print("[unexpected] nameserver received a request with no questions") - m = r.SetRcodeFormatError(r) - return - } - // TODO (irbekrm): maybe set message compression - switch r.Question[0].Qtype { - case dns.TypeA: - q := r.Question[0].Name - fqdn, err := dnsname.ToFQDN(q) - if err != nil { - m = r.SetRcodeFormatError(r) - return - } - // The only supported use of this nameserver is as a - // single source of truth for MagicDNS names by - // non-tailnet Kubernetes workloads. - m.Authoritative = true - m.RecursionAvailable = false - - ips := n.lookupIP4(fqdn) - if ips == nil || len(ips) == 0 { - // As we are the authoritative nameserver for MagicDNS - // names, if we do not have a record for this MagicDNS - // name, it does not exist. - m = m.SetRcode(r, dns.RcodeNameError) - return - } - // TODO (irbekrm): TTL is currently set to 0, meaning - // that cluster workloads will not cache the DNS - // records. Revisit this in future when we understand - // the usage patterns better- is it putting too much - // load on kube DNS server or is this fine? - for _, ip := range ips { - rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, A: ip} - m.SetRcode(r, dns.RcodeSuccess) - m.Answer = append(m.Answer, rr) - } - case dns.TypeAAAA: - // TODO (irbekrm): add IPv6 support. - // The nameserver currently does not support IPv6 - // (records are not being created for IPv6 Pod addresses). - // However, we can expect that some callers will - // nevertheless send AAAA queries. - // We have to return NOERROR if a query is received for - // an AAAA record for a DNS name that we have an A - // record for- else the caller might not follow with an - // A record query. - // https://github.com/tailscale/tailscale/issues/12321 - // https://datatracker.ietf.org/doc/html/rfc4074 - q := r.Question[0].Name - fqdn, err := dnsname.ToFQDN(q) - if err != nil { - m = r.SetRcodeFormatError(r) - return - } - // The only supported use of this nameserver is as a - // single source of truth for MagicDNS names by - // non-tailnet Kubernetes workloads. - m.Authoritative = true - ips := n.lookupIP4(fqdn) - if len(ips) == 0 { - // As we are the authoritative nameserver for MagicDNS - // names, if we do not have a record for this MagicDNS - // name, it does not exist. - m = m.SetRcode(r, dns.RcodeNameError) - return - } - m.SetRcode(r, dns.RcodeSuccess) - default: - log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String()) - m.SetRcode(r, dns.RcodeNotImplemented) - } - } - return h -} - -// runRecordsReconciler ensures that nameserver's in-memory records are -// reset when the provided configuration changes. -func (n *nameserver) runRecordsReconciler(ctx context.Context) { - log.Print("updating nameserver's records from the provided configuration...") - if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts - log.Fatalf("error setting nameserver's records: %v", err) - } - log.Print("nameserver's records were updated") - go func() { - for { - select { - case <-ctx.Done(): - log.Printf("context cancelled, exiting records reconciler") - return - case <-n.configWatcher: - log.Print("configuration update detected, resetting records") - if err := n.resetRecords(); err != nil { - // TODO (irbekrm): this runs in a - // container that will be thrown away, - // so this should be ok. But maybe still - // need to ensure that the DNS server - // terminates connections more - // gracefully. - log.Fatalf("error resetting records: %v", err) - } - log.Print("nameserver records were reset") - } - } - }() -} - -// resetRecords sets the in-memory DNS records of this nameserver from the -// provided configuration. It does not check for the diff, so the caller is -// expected to ensure that this is only called when reset is needed. -func (n *nameserver) resetRecords() error { - dnsCfgBytes, err := n.configReader() - if err != nil { - log.Printf("error reading nameserver's configuration: %v", err) - return err - } - if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 { - log.Print("nameserver's configuration is empty, any in-memory records will be unset") - n.mu.Lock() - n.ip4 = make(map[dnsname.FQDN][]net.IP) - n.mu.Unlock() - return nil - } - dnsCfg := &operatorutils.Records{} - err = json.Unmarshal(dnsCfgBytes, dnsCfg) - if err != nil { - return fmt.Errorf("error unmarshalling nameserver configuration: %v\n", err) - } - - if dnsCfg.Version != operatorutils.Alpha1Version { - return fmt.Errorf("unsupported configuration version %s, supported versions are %s\n", dnsCfg.Version, operatorutils.Alpha1Version) - } - - ip4 := make(map[dnsname.FQDN][]net.IP) - defer func() { - n.mu.Lock() - defer n.mu.Unlock() - n.ip4 = ip4 - }() - - if len(dnsCfg.IP4) == 0 { - log.Print("nameserver's configuration contains no records, any in-memory records will be unset") - return nil - } - - for fqdn, ips := range dnsCfg.IP4 { - fqdn, err := dnsname.ToFQDN(fqdn) - if err != nil { - log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err) - continue // one invalid hostname should not break the whole nameserver - } - for _, ipS := range ips { - ip := net.ParseIP(ipS).To4() - if ip == nil { // To4 returns nil if IP is not a IPv4 address - log.Printf("invalid nameserver's configuration: %v does not appear to be an IPv4 address; skipping this record", ipS) - continue // one invalid IP address should not break the whole nameserver - } - ip4[fqdn] = []net.IP{ip} - } - } - return nil -} - -// listenAndServe starts a DNS server for the provided network and address. -func listenAndServe(net, addr string, shutdown chan os.Signal) { - s := &dns.Server{Addr: addr, Net: net} - go func() { - <-shutdown - log.Printf("shutting down server for %s", net) - s.Shutdown() - }() - log.Printf("listening for %s queries on %s", net, addr) - if err := s.ListenAndServe(); err != nil { - log.Fatalf("error running %s server: %v", net, err) - } -} - -// ensureWatcherForKubeConfigMap sets up a new file watcher for the ConfigMap -// that's expected to be mounted at /config. Returns a channel that receives an -// event every time the contents get updated. -func ensureWatcherForKubeConfigMap(ctx context.Context) chan string { - c := make(chan string) - watcher, err := fsnotify.NewWatcher() - if err != nil { - log.Fatalf("error creating a new watcher for the mounted ConfigMap: %v", err) - } - // kubelet mounts configmap to a Pod using a series of symlinks, one of - // which is /..data that Kubernetes recommends consumers to - // use if they need to monitor changes - // https://github.com/kubernetes/kubernetes/blob/v1.28.1/pkg/volume/util/atomic_writer.go#L39-L61 - toWatch := filepath.Join(defaultDNSConfigDir, kubeletMountedConfigLn) - go func() { - defer watcher.Close() - log.Printf("starting file watch for %s", defaultDNSConfigDir) - for { - select { - case <-ctx.Done(): - log.Print("context cancelled, exiting ConfigMap watcher") - return - case event, ok := <-watcher.Events: - if !ok { - log.Fatal("watcher finished; exiting") - } - if event.Name == toWatch { - msg := fmt.Sprintf("ConfigMap update received: %s", event) - log.Print(msg) - c <- msg - } - case err, ok := <-watcher.Errors: - if err != nil { - // TODO (irbekrm): this runs in a - // container that will be thrown away, - // so this should be ok. But maybe still - // need to ensure that the DNS server - // terminates connections more - // gracefully. - log.Fatalf("[unexpected] error watching configuration: %v", err) - } - if !ok { - // TODO (irbekrm): this runs in a - // container that will be thrown away, - // so this should be ok. But maybe still - // need to ensure that the DNS server - // terminates connections more - // gracefully. - log.Fatalf("[unexpected] errors watcher exited") - } - } - } - }() - if err = watcher.Add(defaultDNSConfigDir); err != nil { - log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v", err) - } - return c -} - -// configReaderFunc is a function that returns the desired nameserver configuration. -type configReaderFunc func() ([]byte, error) - -// configMapConfigReader reads the desired nameserver configuration from a -// records.json file in a ConfigMap mounted at /config. -var configMapConfigReader configReaderFunc = func() ([]byte, error) { - if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, operatorutils.DNSRecordsCMKey)); err == nil { - return contents, nil - } else if os.IsNotExist(err) { - return nil, nil - } else { - return nil, err - } -} - -// lookupIP4 returns any IPv4 addresses for the given FQDN from nameserver's -// in-memory records. -func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP { - if n.ip4 == nil { - return nil - } - n.mu.Lock() - defer n.mu.Unlock() - f := n.ip4[fqdn] - return f -} diff --git a/cmd/k8s-nameserver/main_test.go b/cmd/k8s-nameserver/main_test.go deleted file mode 100644 index d9a33c4faffe5..0000000000000 --- a/cmd/k8s-nameserver/main_test.go +++ /dev/null @@ -1,229 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "net" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/miekg/dns" - "tailscale.com/util/dnsname" -) - -func TestNameserver(t *testing.T) { - - tests := []struct { - name string - ip4 map[dnsname.FQDN][]net.IP - query *dns.Msg - wantResp *dns.Msg - }{ - { - name: "A record query, record exists", - ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, - query: &dns.Msg{ - Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}}, - MsgHdr: dns.MsgHdr{Id: 1, RecursionDesired: true}, - }, - wantResp: &dns.Msg{ - Answer: []dns.RR{&dns.A{Hdr: dns.RR_Header{ - Name: "foo.bar.com", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, - A: net.IP{1, 2, 3, 4}}}, - Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}}, - MsgHdr: dns.MsgHdr{ - Id: 1, - Rcode: dns.RcodeSuccess, - RecursionAvailable: false, - RecursionDesired: true, - Response: true, - Opcode: dns.OpcodeQuery, - Authoritative: true, - }}, - }, - { - name: "A record query, record does not exist", - ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, - query: &dns.Msg{ - Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}}, - MsgHdr: dns.MsgHdr{Id: 1}, - }, - wantResp: &dns.Msg{ - Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}}, - MsgHdr: dns.MsgHdr{ - Id: 1, - Rcode: dns.RcodeNameError, - RecursionAvailable: false, - Response: true, - Opcode: dns.OpcodeQuery, - Authoritative: true, - }}, - }, - { - name: "A record query, but the name is not a valid FQDN", - ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, - query: &dns.Msg{ - Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}}, - MsgHdr: dns.MsgHdr{Id: 1}, - }, - wantResp: &dns.Msg{ - Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}}, - MsgHdr: dns.MsgHdr{ - Id: 1, - Rcode: dns.RcodeFormatError, - Response: true, - Opcode: dns.OpcodeQuery, - }}, - }, - { - name: "AAAA record query, A record exists", - ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, - query: &dns.Msg{ - Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}}, - MsgHdr: dns.MsgHdr{Id: 1}, - }, - wantResp: &dns.Msg{ - Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}}, - MsgHdr: dns.MsgHdr{ - Id: 1, - Rcode: dns.RcodeSuccess, - Response: true, - Opcode: dns.OpcodeQuery, - Authoritative: true, - }}, - }, - { - name: "AAAA record query, A record does not exist", - ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, - query: &dns.Msg{ - Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeAAAA}}, - MsgHdr: dns.MsgHdr{Id: 1}, - }, - wantResp: &dns.Msg{ - Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeAAAA}}, - MsgHdr: dns.MsgHdr{ - Id: 1, - Rcode: dns.RcodeNameError, - Response: true, - Opcode: dns.OpcodeQuery, - Authoritative: true, - }}, - }, - { - name: "CNAME record query", - ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, - query: &dns.Msg{ - Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}}, - MsgHdr: dns.MsgHdr{Id: 1}, - }, - wantResp: &dns.Msg{ - Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}}, - MsgHdr: dns.MsgHdr{ - Id: 1, - Rcode: dns.RcodeNotImplemented, - Response: true, - Opcode: dns.OpcodeQuery, - }}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ns := &nameserver{ - ip4: tt.ip4, - } - handler := ns.handleFunc() - fakeRespW := &fakeResponseWriter{} - handler(fakeRespW, tt.query) - if diff := cmp.Diff(*fakeRespW.msg, *tt.wantResp); diff != "" { - t.Fatalf("unexpected response (-got +want): \n%s", diff) - } - }) - } -} - -func TestResetRecords(t *testing.T) { - tests := []struct { - name string - config []byte - hasIp4 map[dnsname.FQDN][]net.IP - wantsIp4 map[dnsname.FQDN][]net.IP - wantsErr bool - }{ - { - name: "previously empty nameserver.ip4 gets set", - config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`), - wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}}, - }, - { - name: "nameserver.ip4 gets reset", - hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, - config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`), - wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}}, - }, - { - name: "configuration with incompatible version", - hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, - config: []byte(`{"version": "v1beta1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`), - wantsIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, - wantsErr: true, - }, - { - name: "nameserver.ip4 gets reset to empty config when no configuration is provided", - hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, - wantsIp4: make(map[dnsname.FQDN][]net.IP), - }, - { - name: "nameserver.ip4 gets reset to empty config when the provided configuration is empty", - hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, - config: []byte(`{"version": "v1alpha1", "ip4": {}}`), - wantsIp4: make(map[dnsname.FQDN][]net.IP), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ns := &nameserver{ - ip4: tt.hasIp4, - configReader: func() ([]byte, error) { return tt.config, nil }, - } - if err := ns.resetRecords(); err == nil == tt.wantsErr { - t.Errorf("resetRecords() returned err: %v, wantsErr: %v", err, tt.wantsErr) - } - if diff := cmp.Diff(ns.ip4, tt.wantsIp4); diff != "" { - t.Fatalf("unexpected nameserver.ip4 contents (-got +want): \n%s", diff) - } - }) - } -} - -// fakeResponseWriter is a faked out dns.ResponseWriter that can be used in -// tests that need to read the response message that was written. -type fakeResponseWriter struct { - msg *dns.Msg -} - -var _ dns.ResponseWriter = &fakeResponseWriter{} - -func (fr *fakeResponseWriter) WriteMsg(msg *dns.Msg) error { - fr.msg = msg - return nil -} -func (fr *fakeResponseWriter) LocalAddr() net.Addr { - return nil -} -func (fr *fakeResponseWriter) RemoteAddr() net.Addr { - return nil -} -func (fr *fakeResponseWriter) Write([]byte) (int, error) { - return 0, nil -} -func (fr *fakeResponseWriter) Close() error { - return nil -} -func (fr *fakeResponseWriter) TsigStatus() error { - return nil -} -func (fr *fakeResponseWriter) TsigTimersOnly(bool) {} -func (fr *fakeResponseWriter) Hijack() {} diff --git a/cmd/k8s-operator/connector.go b/cmd/k8s-operator/connector.go deleted file mode 100644 index 1c1df7c962b91..0000000000000 --- a/cmd/k8s-operator/connector.go +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "fmt" - "net/netip" - "slices" - "sync" - "time" - - "errors" - - "go.uber.org/zap" - xslices "golang.org/x/exp/slices" - corev1 "k8s.io/api/core/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/tstime" - "tailscale.com/util/clientmetric" - "tailscale.com/util/set" -) - -const ( - reasonConnectorCreationFailed = "ConnectorCreationFailed" - reasonConnectorCreated = "ConnectorCreated" - reasonConnectorInvalid = "ConnectorInvalid" - - messageConnectorCreationFailed = "Failed creating Connector: %v" - messageConnectorInvalid = "Connector is invalid: %v" - - shortRequeue = time.Second * 5 -) - -type ConnectorReconciler struct { - client.Client - - recorder record.EventRecorder - ssr *tailscaleSTSReconciler - logger *zap.SugaredLogger - - tsnamespace string - - clock tstime.Clock - - mu sync.Mutex // protects following - - subnetRouters set.Slice[types.UID] // for subnet routers gauge - exitNodes set.Slice[types.UID] // for exit nodes gauge - appConnectors set.Slice[types.UID] // for app connectors gauge -} - -var ( - // gaugeConnectorResources tracks the overall number of Connectors currently managed by this operator instance. - gaugeConnectorResources = clientmetric.NewGauge(kubetypes.MetricConnectorResourceCount) - // gaugeConnectorSubnetRouterResources tracks the number of Connectors managed by this operator instance that are subnet routers. - gaugeConnectorSubnetRouterResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithSubnetRouterCount) - // gaugeConnectorExitNodeResources tracks the number of Connectors currently managed by this operator instance that are exit nodes. - gaugeConnectorExitNodeResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithExitNodeCount) - // gaugeConnectorAppConnectorResources tracks the number of Connectors currently managed by this operator instance that are app connectors. - gaugeConnectorAppConnectorResources = clientmetric.NewGauge(kubetypes.MetricConnectorWithAppConnectorCount) -) - -func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { - logger := a.logger.With("Connector", req.Name) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - cn := new(tsapi.Connector) - err = a.Get(ctx, req.NamespacedName, cn) - if apierrors.IsNotFound(err) { - logger.Debugf("Connector not found, assuming it was deleted") - return reconcile.Result{}, nil - } else if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com Connector: %w", err) - } - if !cn.DeletionTimestamp.IsZero() { - logger.Debugf("Connector is being deleted or should not be exposed, cleaning up resources") - ix := xslices.Index(cn.Finalizers, FinalizerName) - if ix < 0 { - logger.Debugf("no finalizer, nothing to do") - return reconcile.Result{}, nil - } - - if done, err := a.maybeCleanupConnector(ctx, logger, cn); err != nil { - return reconcile.Result{}, err - } else if !done { - logger.Debugf("Connector resource cleanup not yet finished, will retry...") - return reconcile.Result{RequeueAfter: shortRequeue}, nil - } - - cn.Finalizers = append(cn.Finalizers[:ix], cn.Finalizers[ix+1:]...) - if err := a.Update(ctx, cn); err != nil { - return reconcile.Result{}, err - } - logger.Infof("Connector resources cleaned up") - return reconcile.Result{}, nil - } - - oldCnStatus := cn.Status.DeepCopy() - setStatus := func(cn *tsapi.Connector, _ tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) { - tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, status, reason, message, cn.Generation, a.clock, logger) - var updateErr error - if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) { - // An error encountered here should get returned by the Reconcile function. - updateErr = a.Client.Status().Update(ctx, cn) - } - return res, errors.Join(err, updateErr) - } - - if !slices.Contains(cn.Finalizers, FinalizerName) { - // This log line is printed exactly once during initial provisioning, - // because once the finalizer is in place this block gets skipped. So, - // this is a nice place to tell the operator that the high level, - // multi-reconcile operation is underway. - logger.Infof("ensuring Connector is set up") - cn.Finalizers = append(cn.Finalizers, FinalizerName) - if err := a.Update(ctx, cn); err != nil { - logger.Errorf("error adding finalizer: %w", err) - return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reasonConnectorCreationFailed, reasonConnectorCreationFailed) - } - } - - if err := a.validate(cn); err != nil { - logger.Errorf("error validating Connector spec: %w", err) - message := fmt.Sprintf(messageConnectorInvalid, err) - a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonConnectorInvalid, message) - return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reasonConnectorInvalid, message) - } - - if err = a.maybeProvisionConnector(ctx, logger, cn); err != nil { - logger.Errorf("error creating Connector resources: %w", err) - message := fmt.Sprintf(messageConnectorCreationFailed, err) - a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonConnectorCreationFailed, message) - return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reasonConnectorCreationFailed, message) - } - - logger.Info("Connector resources synced") - cn.Status.IsExitNode = cn.Spec.ExitNode - if cn.Spec.SubnetRouter != nil { - cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify() - return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionTrue, reasonConnectorCreated, reasonConnectorCreated) - } - if cn.Spec.AppConnector != nil { - cn.Status.IsAppConnector = true - } - cn.Status.SubnetRoutes = "" - return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionTrue, reasonConnectorCreated, reasonConnectorCreated) -} - -// maybeProvisionConnector ensures that any new resources required for this -// Connector instance are deployed to the cluster. -func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) error { - hostname := cn.Name + "-connector" - if cn.Spec.Hostname != "" { - hostname = string(cn.Spec.Hostname) - } - crl := childResourceLabels(cn.Name, a.tsnamespace, "connector") - - proxyClass := cn.Spec.ProxyClass - if proxyClass != "" { - if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil { - return fmt.Errorf("error verifying ProxyClass for Connector: %w", err) - } else if !ready { - logger.Infof("ProxyClass %s specified for the Connector, but is not (yet) Ready, waiting..", proxyClass) - return nil - } - } - - sts := &tailscaleSTSConfig{ - ParentResourceName: cn.Name, - ParentResourceUID: string(cn.UID), - Hostname: hostname, - ChildResourceLabels: crl, - Tags: cn.Spec.Tags.Stringify(), - Connector: &connector{ - isExitNode: cn.Spec.ExitNode, - }, - ProxyClassName: proxyClass, - } - - if cn.Spec.SubnetRouter != nil && len(cn.Spec.SubnetRouter.AdvertiseRoutes) > 0 { - sts.Connector.routes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify() - } - - if cn.Spec.AppConnector != nil { - sts.Connector.isAppConnector = true - if len(cn.Spec.AppConnector.Routes) != 0 { - sts.Connector.routes = cn.Spec.AppConnector.Routes.Stringify() - } - } - - a.mu.Lock() - if cn.Spec.ExitNode { - a.exitNodes.Add(cn.UID) - } else { - a.exitNodes.Remove(cn.UID) - } - if cn.Spec.SubnetRouter != nil { - a.subnetRouters.Add(cn.GetUID()) - } else { - a.subnetRouters.Remove(cn.GetUID()) - } - if cn.Spec.AppConnector != nil { - a.appConnectors.Add(cn.GetUID()) - } else { - a.appConnectors.Remove(cn.GetUID()) - } - a.mu.Unlock() - gaugeConnectorSubnetRouterResources.Set(int64(a.subnetRouters.Len())) - gaugeConnectorExitNodeResources.Set(int64(a.exitNodes.Len())) - gaugeConnectorAppConnectorResources.Set(int64(a.appConnectors.Len())) - var connectors set.Slice[types.UID] - connectors.AddSlice(a.exitNodes.Slice()) - connectors.AddSlice(a.subnetRouters.Slice()) - connectors.AddSlice(a.appConnectors.Slice()) - gaugeConnectorResources.Set(int64(connectors.Len())) - - _, err := a.ssr.Provision(ctx, logger, sts) - if err != nil { - return err - } - - _, tsHost, ips, err := a.ssr.DeviceInfo(ctx, crl) - if err != nil { - return err - } - - if tsHost == "" { - logger.Debugf("no Tailscale hostname known yet, waiting for connector pod to finish auth") - // No hostname yet. Wait for the connector pod to auth. - cn.Status.TailnetIPs = nil - cn.Status.Hostname = "" - return nil - } - - cn.Status.TailnetIPs = ips - cn.Status.Hostname = tsHost - - return nil -} - -func (a *ConnectorReconciler) maybeCleanupConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) (bool, error) { - if done, err := a.ssr.Cleanup(ctx, logger, childResourceLabels(cn.Name, a.tsnamespace, "connector")); err != nil { - return false, fmt.Errorf("failed to cleanup Connector resources: %w", err) - } else if !done { - logger.Debugf("Connector cleanup not done yet, waiting for next reconcile") - return false, nil - } - - // Unlike most log entries in the reconcile loop, this will get printed - // exactly once at the very end of cleanup, because the final step of - // cleanup removes the tailscale finalizer, which will make all future - // reconciles exit early. - logger.Infof("cleaned up Connector resources") - a.mu.Lock() - a.subnetRouters.Remove(cn.UID) - a.exitNodes.Remove(cn.UID) - a.appConnectors.Remove(cn.UID) - a.mu.Unlock() - gaugeConnectorExitNodeResources.Set(int64(a.exitNodes.Len())) - gaugeConnectorSubnetRouterResources.Set(int64(a.subnetRouters.Len())) - gaugeConnectorAppConnectorResources.Set(int64(a.appConnectors.Len())) - var connectors set.Slice[types.UID] - connectors.AddSlice(a.exitNodes.Slice()) - connectors.AddSlice(a.subnetRouters.Slice()) - connectors.AddSlice(a.appConnectors.Slice()) - gaugeConnectorResources.Set(int64(connectors.Len())) - return true, nil -} - -func (a *ConnectorReconciler) validate(cn *tsapi.Connector) error { - // Connector fields are already validated at apply time with CEL validation - // on custom resource fields. The checks here are a backup in case the - // CEL validation breaks without us noticing. - if cn.Spec.SubnetRouter == nil && !cn.Spec.ExitNode && cn.Spec.AppConnector == nil { - return errors.New("invalid spec: a Connector must be configured as at least one of subnet router, exit node or app connector") - } - if (cn.Spec.SubnetRouter != nil || cn.Spec.ExitNode) && cn.Spec.AppConnector != nil { - return errors.New("invalid spec: a Connector that is configured as an app connector must not be also configured as a subnet router or exit node") - } - if cn.Spec.AppConnector != nil { - return validateAppConnector(cn.Spec.AppConnector) - } - if cn.Spec.SubnetRouter == nil { - return nil - } - return validateSubnetRouter(cn.Spec.SubnetRouter) -} - -func validateSubnetRouter(sb *tsapi.SubnetRouter) error { - if len(sb.AdvertiseRoutes) == 0 { - return errors.New("invalid subnet router spec: no routes defined") - } - return validateRoutes(sb.AdvertiseRoutes) -} - -func validateAppConnector(ac *tsapi.AppConnector) error { - return validateRoutes(ac.Routes) -} - -func validateRoutes(routes tsapi.Routes) error { - var errs []error - for _, route := range routes { - pfx, e := netip.ParsePrefix(string(route)) - if e != nil { - errs = append(errs, fmt.Errorf("route %v is invalid: %v", route, e)) - continue - } - if pfx.Masked() != pfx { - errs = append(errs, fmt.Errorf("route %s has non-address bits set; expected %s", pfx, pfx.Masked())) - } - } - return errors.Join(errs...) -} diff --git a/cmd/k8s-operator/connector_test.go b/cmd/k8s-operator/connector_test.go deleted file mode 100644 index 7cdd83115e877..0000000000000 --- a/cmd/k8s-operator/connector_test.go +++ /dev/null @@ -1,397 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "testing" - "time" - - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/tstest" - "tailscale.com/util/mak" -) - -func TestConnector(t *testing.T) { - // Create a Connector that defines a Tailscale node that advertises - // 10.40.0.0/14 route and acts as an exit node. - cn := &tsapi.Connector{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - UID: types.UID("1234-UID"), - }, - TypeMeta: metav1.TypeMeta{ - Kind: tsapi.ConnectorKind, - APIVersion: "tailscale.com/v1alpha1", - }, - Spec: tsapi.ConnectorSpec{ - SubnetRouter: &tsapi.SubnetRouter{ - AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"}, - }, - ExitNode: true, - }, - } - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(cn). - WithStatusSubresource(cn). - Build() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - - cl := tstest.NewClock(tstest.ClockOpts{}) - cr := &ConnectorReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - clock: cl, - logger: zl.Sugar(), - } - - expectReconciled(t, cr, "", "test") - fullName, shortName := findGenName(t, fc, "", "test", "connector") - - opts := configOpts{ - stsName: shortName, - secretName: fullName, - parentType: "connector", - hostname: "test-connector", - isExitNode: true, - subnetRoutes: "10.40.0.0/14", - app: kubetypes.AppConnector, - } - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // Connector status should get updated with the IP/hostname info when available. - const hostname = "foo.tailnetxyz.ts.net" - mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) { - mak.Set(&secret.Data, "device_id", []byte("1234")) - mak.Set(&secret.Data, "device_fqdn", []byte(hostname)) - mak.Set(&secret.Data, "device_ips", []byte(`["127.0.0.1", "::1"]`)) - }) - expectReconciled(t, cr, "", "test") - cn.Finalizers = append(cn.Finalizers, "tailscale.com/finalizer") - cn.Status.IsExitNode = cn.Spec.ExitNode - cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify() - cn.Status.Hostname = hostname - cn.Status.TailnetIPs = []string{"127.0.0.1", "::1"} - expectEqual(t, fc, cn, func(o *tsapi.Connector) { - o.Status.Conditions = nil - }) - - // Add another route to be advertised. - mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"} - }) - opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20" - expectReconciled(t, cr, "", "test") - - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // Remove a route. - mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.44.0.0/20"} - }) - opts.subnetRoutes = "10.44.0.0/20" - expectReconciled(t, cr, "", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // Remove the subnet router. - mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.SubnetRouter = nil - }) - opts.subnetRoutes = "" - expectReconciled(t, cr, "", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // Re-add the subnet router. - mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.SubnetRouter = &tsapi.SubnetRouter{ - AdvertiseRoutes: []tsapi.Route{"10.44.0.0/20"}, - } - }) - opts.subnetRoutes = "10.44.0.0/20" - expectReconciled(t, cr, "", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // Delete the Connector. - if err = fc.Delete(context.Background(), cn); err != nil { - t.Fatalf("error deleting Connector: %v", err) - } - - expectRequeue(t, cr, "", "test") - expectReconciled(t, cr, "", "test") - - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) - - // Create a Connector that advertises a route and is not an exit node. - cn = &tsapi.Connector{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - UID: types.UID("1234-UID"), - }, - TypeMeta: metav1.TypeMeta{ - Kind: tsapi.ConnectorKind, - APIVersion: "tailscale.io/v1alpha1", - }, - Spec: tsapi.ConnectorSpec{ - SubnetRouter: &tsapi.SubnetRouter{ - AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"}, - }, - }, - } - opts.subnetRoutes = "10.44.0.0/14" - opts.isExitNode = false - mustCreate(t, fc, cn) - expectReconciled(t, cr, "", "test") - fullName, shortName = findGenName(t, fc, "", "test", "connector") - - opts = configOpts{ - stsName: shortName, - secretName: fullName, - parentType: "connector", - subnetRoutes: "10.40.0.0/14", - hostname: "test-connector", - app: kubetypes.AppConnector, - } - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // Add an exit node. - mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.ExitNode = true - }) - opts.isExitNode = true - expectReconciled(t, cr, "", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // Delete the Connector. - if err = fc.Delete(context.Background(), cn); err != nil { - t.Fatalf("error deleting Connector: %v", err) - } - - expectRequeue(t, cr, "", "test") - expectReconciled(t, cr, "", "test") - - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) -} - -func TestConnectorWithProxyClass(t *testing.T) { - // Setup - pc := &tsapi.ProxyClass{ - ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"}, - Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{ - Labels: map[string]string{"foo": "bar"}, - Annotations: map[string]string{"bar.io/foo": "some-val"}, - Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}}, - } - cn := &tsapi.Connector{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - UID: types.UID("1234-UID"), - }, - TypeMeta: metav1.TypeMeta{ - Kind: tsapi.ConnectorKind, - APIVersion: "tailscale.io/v1alpha1", - }, - Spec: tsapi.ConnectorSpec{ - SubnetRouter: &tsapi.SubnetRouter{ - AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"}, - }, - ExitNode: true, - }, - } - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(pc, cn). - WithStatusSubresource(pc, cn). - Build() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - cr := &ConnectorReconciler{ - Client: fc, - clock: cl, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - } - - // 1. Connector is created with no ProxyClass specified, create - // resources with the default configuration. - expectReconciled(t, cr, "", "test") - fullName, shortName := findGenName(t, fc, "", "test", "connector") - - opts := configOpts{ - stsName: shortName, - secretName: fullName, - parentType: "connector", - hostname: "test-connector", - isExitNode: true, - subnetRoutes: "10.40.0.0/14", - app: kubetypes.AppConnector, - } - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // 2. Update Connector to specify a ProxyClass. ProxyClass is not yet - // ready, so its configuration is NOT applied to the Connector - // resources. - mustUpdate(t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.ProxyClass = "custom-metadata" - }) - expectReconciled(t, cr, "", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // 3. ProxyClass is set to Ready by proxy-class reconciler. Connector - // get reconciled and configuration from the ProxyClass is applied to - // its resources. - mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) { - pc.Status = tsapi.ProxyClassStatus{ - Conditions: []metav1.Condition{{ - Status: metav1.ConditionTrue, - Type: string(tsapi.ProxyClassReady), - ObservedGeneration: pc.Generation, - }}} - }) - opts.proxyClass = pc.Name - expectReconciled(t, cr, "", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // 4. Connector.spec.proxyClass field is unset, Connector gets - // reconciled and configuration from the ProxyClass is removed from the - // cluster resources for the Connector. - mustUpdate(t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.ProxyClass = "" - }) - opts.proxyClass = "" - expectReconciled(t, cr, "", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) -} - -func TestConnectorWithAppConnector(t *testing.T) { - // Setup - cn := &tsapi.Connector{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - UID: types.UID("1234-UID"), - }, - TypeMeta: metav1.TypeMeta{ - Kind: tsapi.ConnectorKind, - APIVersion: "tailscale.io/v1alpha1", - }, - Spec: tsapi.ConnectorSpec{ - AppConnector: &tsapi.AppConnector{}, - }, - } - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(cn). - WithStatusSubresource(cn). - Build() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - fr := record.NewFakeRecorder(1) - cr := &ConnectorReconciler{ - Client: fc, - clock: cl, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - recorder: fr, - } - - // 1. Connector with app connnector is created and becomes ready - expectReconciled(t, cr, "", "test") - fullName, shortName := findGenName(t, fc, "", "test", "connector") - opts := configOpts{ - stsName: shortName, - secretName: fullName, - parentType: "connector", - hostname: "test-connector", - app: kubetypes.AppConnector, - isAppConnector: true, - } - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - // Connector's ready condition should be set to true - - cn.ObjectMeta.Finalizers = append(cn.ObjectMeta.Finalizers, "tailscale.com/finalizer") - cn.Status.IsAppConnector = true - cn.Status.Conditions = []metav1.Condition{{ - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionTrue, - LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, - Reason: reasonConnectorCreated, - Message: reasonConnectorCreated, - }} - expectEqual(t, fc, cn, nil) - - // 2. Connector with invalid app connector routes has status set to invalid - mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")} - }) - cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("1.2.3.4/5")} - expectReconciled(t, cr, "", "test") - cn.Status.Conditions = []metav1.Condition{{ - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionFalse, - LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, - Reason: reasonConnectorInvalid, - Message: "Connector is invalid: route 1.2.3.4/5 has non-address bits set; expected 0.0.0.0/5", - }} - expectEqual(t, fc, cn, nil) - - // 3. Connector with valid app connnector routes becomes ready - mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { - conn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")} - }) - cn.Spec.AppConnector.Routes = tsapi.Routes{tsapi.Route("10.88.2.21/32")} - cn.Status.Conditions = []metav1.Condition{{ - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionTrue, - LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, - Reason: reasonConnectorCreated, - Message: reasonConnectorCreated, - }} - expectReconciled(t, cr, "", "test") -} diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt deleted file mode 100644 index 900d10efedc99..0000000000000 --- a/cmd/k8s-operator/depaware.txt +++ /dev/null @@ -1,1013 +0,0 @@ -tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/depaware) - - filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus - filippo.io/edwards25519/field from filippo.io/edwards25519 - 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 - L github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+ - L github.com/aws/aws-sdk-go-v2/aws/arn from tailscale.com/ipn/store/awsstore - L github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+ - L github.com/aws/aws-sdk-go-v2/aws/middleware/private/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+ - L github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry - L github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+ - L github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4 - L github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+ - L github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/config from tailscale.com/ipn/store/awsstore - L github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds - L github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds - L github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+ - L github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/ssm/internal/endpoints+ - L github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds - L github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 - L github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws - L github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry - L github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/service/ssm from tailscale.com/ipn/store/awsstore - L github.com/aws/aws-sdk-go-v2/service/ssm/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm - L github.com/aws/aws-sdk-go-v2/service/ssm/types from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso - L github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso - L github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc - L github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc - L github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+ - L github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+ - L github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+ - L github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer - L github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+ - L github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+ - L github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer - L github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+ - L github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware+ - L github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws/middleware+ - L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http - L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm - github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus - github.com/bits-and-blooms/bitset from github.com/gaissmai/bart - 💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus - L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw - 💣 github.com/davecgh/go-spew/spew from k8s.io/apimachinery/pkg/util/dump - W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+ - W 💣 github.com/dblohm7/wingoes/com from tailscale.com/util/osdiag+ - W 💣 github.com/dblohm7/wingoes/com/automation from tailscale.com/util/osdiag/internal/wsc - W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com - W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+ - LW 💣 github.com/digitalocean/go-smbios/smbios from tailscale.com/posture - github.com/distribution/reference from tailscale.com/cmd/k8s-operator - github.com/emicklei/go-restful/v3 from k8s.io/kube-openapi/pkg/common - github.com/emicklei/go-restful/v3/log from github.com/emicklei/go-restful/v3 - github.com/evanphx/json-patch/v5 from sigs.k8s.io/controller-runtime/pkg/client - github.com/evanphx/json-patch/v5/internal/json from github.com/evanphx/json-patch/v5 - 💣 github.com/fsnotify/fsnotify from sigs.k8s.io/controller-runtime/pkg/certwatcher - github.com/fxamacker/cbor/v2 from tailscale.com/tka - github.com/gaissmai/bart from tailscale.com/net/ipset+ - github.com/go-json-experiment/json from tailscale.com/types/opt+ - github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+ - github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+ - github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+ - github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+ - github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+ - github.com/go-logr/logr from github.com/go-logr/logr/slogr+ - github.com/go-logr/logr/slogr from github.com/go-logr/zapr - github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+ - W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+ - W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet - github.com/go-openapi/jsonpointer from github.com/go-openapi/jsonreference - github.com/go-openapi/jsonreference from k8s.io/kube-openapi/pkg/internal+ - github.com/go-openapi/jsonreference/internal from github.com/go-openapi/jsonreference - github.com/go-openapi/swag from github.com/go-openapi/jsonpointer+ - L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns - 💣 github.com/gogo/protobuf/proto from k8s.io/api/admission/v1+ - github.com/gogo/protobuf/sortkeys from k8s.io/api/admission/v1+ - github.com/golang/groupcache/lru from k8s.io/client-go/tools/record+ - github.com/golang/protobuf/proto from k8s.io/client-go/discovery+ - github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header+ - github.com/google/gnostic-models/compiler from github.com/google/gnostic-models/openapiv2+ - github.com/google/gnostic-models/extensions from github.com/google/gnostic-models/compiler - github.com/google/gnostic-models/jsonschema from github.com/google/gnostic-models/compiler - github.com/google/gnostic-models/openapiv2 from k8s.io/client-go/discovery+ - github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+ - 💣 github.com/google/go-cmp/cmp from k8s.io/apimachinery/pkg/util/diff+ - github.com/google/go-cmp/cmp/internal/diff from github.com/google/go-cmp/cmp - github.com/google/go-cmp/cmp/internal/flags from github.com/google/go-cmp/cmp+ - github.com/google/go-cmp/cmp/internal/function from github.com/google/go-cmp/cmp - 💣 github.com/google/go-cmp/cmp/internal/value from github.com/google/go-cmp/cmp - github.com/google/gofuzz from k8s.io/apimachinery/pkg/apis/meta/v1+ - github.com/google/gofuzz/bytesource from github.com/google/gofuzz - L github.com/google/nftables from tailscale.com/util/linuxfw - L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt - L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+ - L github.com/google/nftables/expr from github.com/google/nftables+ - L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ - L github.com/google/nftables/xt from github.com/google/nftables/expr+ - github.com/google/uuid from github.com/prometheus-community/pro-bing+ - github.com/gorilla/csrf from tailscale.com/client/web - github.com/gorilla/securecookie from github.com/gorilla/csrf - github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ - L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns - github.com/imdario/mergo from k8s.io/client-go/tools/clientcmd - L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun - L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4 - L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4 - L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4 - L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm - github.com/josharian/intern from github.com/mailru/easyjson/jlexer - L github.com/josharian/native from github.com/mdlayher/netlink+ - L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon - L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink - 💣 github.com/json-iterator/go from sigs.k8s.io/structured-merge-diff/v4/fieldpath+ - github.com/klauspost/compress from github.com/klauspost/compress/zstd - github.com/klauspost/compress/fse from github.com/klauspost/compress/huff0 - github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd - github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+ - github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd - github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe - github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd - github.com/kortschak/wol from tailscale.com/ipn/ipnlocal - github.com/mailru/easyjson/buffer from github.com/mailru/easyjson/jwriter - 💣 github.com/mailru/easyjson/jlexer from github.com/go-openapi/swag - github.com/mailru/easyjson/jwriter from github.com/go-openapi/swag - L github.com/mdlayher/genetlink from tailscale.com/net/tstun - L 💣 github.com/mdlayher/netlink from github.com/google/nftables+ - L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ - L github.com/mdlayher/netlink/nltest from github.com/google/nftables - L github.com/mdlayher/sdnotify from tailscale.com/util/systemd - L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+ - github.com/miekg/dns from tailscale.com/net/dns/recursive - 💣 github.com/mitchellh/go-ps from tailscale.com/safesocket - github.com/modern-go/concurrent from github.com/json-iterator/go - 💣 github.com/modern-go/reflect2 from github.com/json-iterator/go - github.com/munnerz/goautoneg from k8s.io/kube-openapi/pkg/handler3 - github.com/opencontainers/go-digest from github.com/distribution/reference - L github.com/pierrec/lz4/v4 from github.com/u-root/uio/uio - L github.com/pierrec/lz4/v4/internal/lz4block from github.com/pierrec/lz4/v4+ - L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+ - L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4 - L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream - github.com/pkg/errors from github.com/evanphx/json-patch/v5+ - D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack - 💣 github.com/prometheus/client_golang/prometheus from github.com/prometheus/client_golang/prometheus/collectors+ - github.com/prometheus/client_golang/prometheus/collectors from sigs.k8s.io/controller-runtime/pkg/internal/controller/metrics - github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus+ - github.com/prometheus/client_golang/prometheus/promhttp from sigs.k8s.io/controller-runtime/pkg/metrics/server+ - 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 - L 💣 github.com/safchain/ethtool from tailscale.com/doctor/ethtool+ - github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd - W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient - W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket - W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio - W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio - W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs - W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - github.com/tailscale/golang-x-crypto/acme from tailscale.com/ipn/ipnlocal - LD github.com/tailscale/golang-x-crypto/internal/poly1305 from github.com/tailscale/golang-x-crypto/ssh - LD github.com/tailscale/golang-x-crypto/ssh from tailscale.com/ipn/ipnlocal - LD github.com/tailscale/golang-x-crypto/ssh/internal/bcrypt_pbkdf from github.com/tailscale/golang-x-crypto/ssh - github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+ - github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper - github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+ - github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp - github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+ - github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp - github.com/tailscale/hujson from tailscale.com/ipn/conffile - L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+ - L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink - github.com/tailscale/peercred from tailscale.com/ipn/ipnauth - github.com/tailscale/web-client-prebuilt from tailscale.com/client/web - 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ - W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn - 💣 github.com/tailscale/wireguard-go/device from tailscale.com/net/tstun+ - 💣 github.com/tailscale/wireguard-go/ipc from github.com/tailscale/wireguard-go/device - W 💣 github.com/tailscale/wireguard-go/ipc/namedpipe from github.com/tailscale/wireguard-go/ipc - github.com/tailscale/wireguard-go/ratelimiter from github.com/tailscale/wireguard-go/device - github.com/tailscale/wireguard-go/replay from github.com/tailscale/wireguard-go/device - github.com/tailscale/wireguard-go/rwcancel from github.com/tailscale/wireguard-go/device+ - github.com/tailscale/wireguard-go/tai64n from github.com/tailscale/wireguard-go/device - 💣 github.com/tailscale/wireguard-go/tun from github.com/tailscale/wireguard-go/device+ - github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck - L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4 - L github.com/u-root/uio/uio from github.com/insomniacslk/dhcp/dhcpv4+ - L github.com/vishvananda/netns from github.com/tailscale/netlink+ - github.com/x448/float16 from github.com/fxamacker/cbor/v2 - go.uber.org/multierr from go.uber.org/zap+ - go.uber.org/zap from github.com/go-logr/zapr+ - go.uber.org/zap/buffer from go.uber.org/zap/internal/bufferpool+ - go.uber.org/zap/internal from go.uber.org/zap - go.uber.org/zap/internal/bufferpool from go.uber.org/zap+ - go.uber.org/zap/internal/color from go.uber.org/zap/zapcore - go.uber.org/zap/internal/exit from go.uber.org/zap/zapcore - go.uber.org/zap/internal/pool from go.uber.org/zap+ - go.uber.org/zap/internal/stacktrace from go.uber.org/zap - go.uber.org/zap/zapcore from github.com/go-logr/zapr+ - 💣 go4.org/mem from tailscale.com/client/tailscale+ - go4.org/netipx from tailscale.com/ipn/ipnlocal+ - W 💣 golang.zx2c4.com/wintun from github.com/tailscale/wireguard-go/tun - W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/dns+ - gomodules.xyz/jsonpatch/v2 from sigs.k8s.io/controller-runtime/pkg/webhook+ - google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt - 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/editiondefaults from google.golang.org/protobuf/internal/filedesc+ - 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/protodelim+ - 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 github.com/google/gnostic-models/openapiv3+ - google.golang.org/protobuf/types/gofeaturespb from google.golang.org/protobuf/reflect/protodesc - google.golang.org/protobuf/types/known/anypb from github.com/google/gnostic-models/compiler+ - google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+ - gopkg.in/inf.v0 from k8s.io/apimachinery/pkg/api/resource - gopkg.in/yaml.v2 from k8s.io/kube-openapi/pkg/util/proto+ - gopkg.in/yaml.v3 from github.com/go-openapi/swag+ - gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/buffer+ - gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer - 💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+ - gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs - 💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+ - gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log - gvisor.dev/gvisor/pkg/log from gvisor.dev/gvisor/pkg/context+ - gvisor.dev/gvisor/pkg/rand from gvisor.dev/gvisor/pkg/tcpip/network/hash+ - gvisor.dev/gvisor/pkg/refs from gvisor.dev/gvisor/pkg/buffer+ - 💣 gvisor.dev/gvisor/pkg/sleep from gvisor.dev/gvisor/pkg/tcpip/transport/tcp - 💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+ - gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state - 💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/atomicbitops+ - 💣 gvisor.dev/gvisor/pkg/sync/locking from gvisor.dev/gvisor/pkg/tcpip/stack - gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/tcpip/adapters/gonet from tailscale.com/wgengine/netstack - 💣 gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+ - gvisor.dev/gvisor/pkg/tcpip/hash/jenkins from gvisor.dev/gvisor/pkg/tcpip/stack+ - gvisor.dev/gvisor/pkg/tcpip/header from gvisor.dev/gvisor/pkg/tcpip/header/parse+ - gvisor.dev/gvisor/pkg/tcpip/header/parse from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/internal/tcp from gvisor.dev/gvisor/pkg/tcpip/stack+ - gvisor.dev/gvisor/pkg/tcpip/network/hash from gvisor.dev/gvisor/pkg/tcpip/network/ipv4 - gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+ - gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack+ - gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+ - gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+ - 💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/tcpip/stack/gro from tailscale.com/wgengine/netstack/gro - gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+ - gvisor.dev/gvisor/pkg/tcpip/transport/icmp from tailscale.com/wgengine/netstack - gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+ - gvisor.dev/gvisor/pkg/tcpip/transport/internal/noop from gvisor.dev/gvisor/pkg/tcpip/transport/raw - gvisor.dev/gvisor/pkg/tcpip/transport/packet from gvisor.dev/gvisor/pkg/tcpip/transport/raw - gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+ - 💣 gvisor.dev/gvisor/pkg/tcpip/transport/tcp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/tcpip/transport/tcpconntrack from gvisor.dev/gvisor/pkg/tcpip/stack - gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+ - k8s.io/api/admission/v1 from sigs.k8s.io/controller-runtime/pkg/webhook/admission - k8s.io/api/admission/v1beta1 from sigs.k8s.io/controller-runtime/pkg/webhook/admission - k8s.io/api/admissionregistration/v1 from k8s.io/api/admissionregistration/v1alpha1+ - k8s.io/api/admissionregistration/v1alpha1 from k8s.io/client-go/applyconfigurations/admissionregistration/v1alpha1+ - k8s.io/api/admissionregistration/v1beta1 from k8s.io/client-go/applyconfigurations/admissionregistration/v1beta1+ - k8s.io/api/apidiscovery/v2 from k8s.io/client-go/discovery - k8s.io/api/apidiscovery/v2beta1 from k8s.io/client-go/discovery - k8s.io/api/apiserverinternal/v1alpha1 from k8s.io/client-go/applyconfigurations/apiserverinternal/v1alpha1+ - k8s.io/api/apps/v1 from k8s.io/client-go/applyconfigurations/apps/v1+ - k8s.io/api/apps/v1beta1 from k8s.io/api/extensions/v1beta1+ - k8s.io/api/apps/v1beta2 from k8s.io/client-go/applyconfigurations/apps/v1beta2+ - k8s.io/api/authentication/v1 from k8s.io/api/admission/v1+ - k8s.io/api/authentication/v1alpha1 from k8s.io/client-go/kubernetes/scheme+ - k8s.io/api/authentication/v1beta1 from k8s.io/client-go/kubernetes/scheme+ - k8s.io/api/authorization/v1 from k8s.io/client-go/kubernetes/scheme+ - k8s.io/api/authorization/v1beta1 from k8s.io/client-go/kubernetes/scheme+ - k8s.io/api/autoscaling/v1 from k8s.io/client-go/applyconfigurations/autoscaling/v1+ - k8s.io/api/autoscaling/v2 from k8s.io/client-go/applyconfigurations/autoscaling/v2+ - k8s.io/api/autoscaling/v2beta1 from k8s.io/client-go/applyconfigurations/autoscaling/v2beta1+ - k8s.io/api/autoscaling/v2beta2 from k8s.io/client-go/applyconfigurations/autoscaling/v2beta2+ - k8s.io/api/batch/v1 from k8s.io/api/batch/v1beta1+ - k8s.io/api/batch/v1beta1 from k8s.io/client-go/applyconfigurations/batch/v1beta1+ - k8s.io/api/certificates/v1 from k8s.io/client-go/applyconfigurations/certificates/v1+ - k8s.io/api/certificates/v1alpha1 from k8s.io/client-go/applyconfigurations/certificates/v1alpha1+ - k8s.io/api/certificates/v1beta1 from k8s.io/client-go/applyconfigurations/certificates/v1beta1+ - k8s.io/api/coordination/v1 from k8s.io/client-go/applyconfigurations/coordination/v1+ - k8s.io/api/coordination/v1beta1 from k8s.io/client-go/applyconfigurations/coordination/v1beta1+ - k8s.io/api/core/v1 from k8s.io/api/apps/v1+ - k8s.io/api/discovery/v1 from k8s.io/client-go/applyconfigurations/discovery/v1+ - k8s.io/api/discovery/v1beta1 from k8s.io/client-go/applyconfigurations/discovery/v1beta1+ - k8s.io/api/events/v1 from k8s.io/client-go/applyconfigurations/events/v1+ - k8s.io/api/events/v1beta1 from k8s.io/client-go/applyconfigurations/events/v1beta1+ - k8s.io/api/extensions/v1beta1 from k8s.io/client-go/applyconfigurations/extensions/v1beta1+ - k8s.io/api/flowcontrol/v1 from k8s.io/client-go/applyconfigurations/flowcontrol/v1+ - k8s.io/api/flowcontrol/v1beta1 from k8s.io/client-go/applyconfigurations/flowcontrol/v1beta1+ - k8s.io/api/flowcontrol/v1beta2 from k8s.io/client-go/applyconfigurations/flowcontrol/v1beta2+ - k8s.io/api/flowcontrol/v1beta3 from k8s.io/client-go/applyconfigurations/flowcontrol/v1beta3+ - k8s.io/api/networking/v1 from k8s.io/client-go/applyconfigurations/networking/v1+ - k8s.io/api/networking/v1alpha1 from k8s.io/client-go/applyconfigurations/networking/v1alpha1+ - k8s.io/api/networking/v1beta1 from k8s.io/client-go/applyconfigurations/networking/v1beta1+ - k8s.io/api/node/v1 from k8s.io/client-go/applyconfigurations/node/v1+ - k8s.io/api/node/v1alpha1 from k8s.io/client-go/applyconfigurations/node/v1alpha1+ - k8s.io/api/node/v1beta1 from k8s.io/client-go/applyconfigurations/node/v1beta1+ - k8s.io/api/policy/v1 from k8s.io/client-go/applyconfigurations/policy/v1+ - k8s.io/api/policy/v1beta1 from k8s.io/client-go/applyconfigurations/policy/v1beta1+ - k8s.io/api/rbac/v1 from k8s.io/client-go/applyconfigurations/rbac/v1+ - k8s.io/api/rbac/v1alpha1 from k8s.io/client-go/applyconfigurations/rbac/v1alpha1+ - k8s.io/api/rbac/v1beta1 from k8s.io/client-go/applyconfigurations/rbac/v1beta1+ - k8s.io/api/resource/v1alpha2 from k8s.io/client-go/applyconfigurations/resource/v1alpha2+ - k8s.io/api/scheduling/v1 from k8s.io/client-go/applyconfigurations/scheduling/v1+ - k8s.io/api/scheduling/v1alpha1 from k8s.io/client-go/applyconfigurations/scheduling/v1alpha1+ - k8s.io/api/scheduling/v1beta1 from k8s.io/client-go/applyconfigurations/scheduling/v1beta1+ - k8s.io/api/storage/v1 from k8s.io/client-go/applyconfigurations/storage/v1+ - k8s.io/api/storage/v1alpha1 from k8s.io/client-go/applyconfigurations/storage/v1alpha1+ - k8s.io/api/storage/v1beta1 from k8s.io/client-go/applyconfigurations/storage/v1beta1+ - k8s.io/api/storagemigration/v1alpha1 from k8s.io/client-go/applyconfigurations/storagemigration/v1alpha1+ - k8s.io/apiextensions-apiserver/pkg/apis/apiextensions from k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1 - 💣 k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1 from sigs.k8s.io/controller-runtime/pkg/webhook/conversion - k8s.io/apimachinery/pkg/api/equality from k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1+ - k8s.io/apimachinery/pkg/api/errors from k8s.io/apimachinery/pkg/util/managedfields/internal+ - k8s.io/apimachinery/pkg/api/meta from k8s.io/apimachinery/pkg/api/validation+ - k8s.io/apimachinery/pkg/api/resource from k8s.io/api/autoscaling/v1+ - k8s.io/apimachinery/pkg/api/validation from k8s.io/apimachinery/pkg/util/managedfields/internal+ - 💣 k8s.io/apimachinery/pkg/apis/meta/internalversion from k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme+ - k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme from k8s.io/client-go/metadata - 💣 k8s.io/apimachinery/pkg/apis/meta/v1 from k8s.io/api/admission/v1+ - k8s.io/apimachinery/pkg/apis/meta/v1/unstructured from k8s.io/apimachinery/pkg/runtime/serializer/versioning+ - k8s.io/apimachinery/pkg/apis/meta/v1/validation from k8s.io/apimachinery/pkg/api/validation+ - 💣 k8s.io/apimachinery/pkg/apis/meta/v1beta1 from k8s.io/apimachinery/pkg/apis/meta/internalversion - k8s.io/apimachinery/pkg/conversion from k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1+ - k8s.io/apimachinery/pkg/conversion/queryparams from k8s.io/apimachinery/pkg/runtime+ - k8s.io/apimachinery/pkg/fields from k8s.io/apimachinery/pkg/api/equality+ - k8s.io/apimachinery/pkg/labels from k8s.io/apimachinery/pkg/api/equality+ - k8s.io/apimachinery/pkg/runtime from k8s.io/api/admission/v1+ - k8s.io/apimachinery/pkg/runtime/schema from k8s.io/api/admission/v1+ - k8s.io/apimachinery/pkg/runtime/serializer from k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme+ - k8s.io/apimachinery/pkg/runtime/serializer/json from k8s.io/apimachinery/pkg/runtime/serializer+ - k8s.io/apimachinery/pkg/runtime/serializer/protobuf from k8s.io/apimachinery/pkg/runtime/serializer - k8s.io/apimachinery/pkg/runtime/serializer/recognizer from k8s.io/apimachinery/pkg/runtime/serializer+ - k8s.io/apimachinery/pkg/runtime/serializer/streaming from k8s.io/client-go/rest+ - k8s.io/apimachinery/pkg/runtime/serializer/versioning from k8s.io/apimachinery/pkg/runtime/serializer+ - k8s.io/apimachinery/pkg/selection from k8s.io/apimachinery/pkg/apis/meta/v1+ - k8s.io/apimachinery/pkg/types from k8s.io/api/admission/v1+ - k8s.io/apimachinery/pkg/util/cache from k8s.io/client-go/tools/cache - k8s.io/apimachinery/pkg/util/diff from k8s.io/client-go/tools/cache - k8s.io/apimachinery/pkg/util/dump from k8s.io/apimachinery/pkg/util/diff+ - k8s.io/apimachinery/pkg/util/errors from k8s.io/apimachinery/pkg/api/meta+ - k8s.io/apimachinery/pkg/util/framer from k8s.io/apimachinery/pkg/runtime/serializer/json+ - k8s.io/apimachinery/pkg/util/intstr from k8s.io/api/apps/v1+ - k8s.io/apimachinery/pkg/util/json from k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1+ - k8s.io/apimachinery/pkg/util/managedfields from k8s.io/client-go/applyconfigurations/admissionregistration/v1+ - k8s.io/apimachinery/pkg/util/managedfields/internal from k8s.io/apimachinery/pkg/util/managedfields - k8s.io/apimachinery/pkg/util/mergepatch from k8s.io/apimachinery/pkg/util/strategicpatch - k8s.io/apimachinery/pkg/util/naming from k8s.io/apimachinery/pkg/runtime+ - k8s.io/apimachinery/pkg/util/net from k8s.io/apimachinery/pkg/watch+ - k8s.io/apimachinery/pkg/util/rand from k8s.io/apiserver/pkg/storage/names - k8s.io/apimachinery/pkg/util/remotecommand from tailscale.com/k8s-operator/sessionrecording/ws - k8s.io/apimachinery/pkg/util/runtime from k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme+ - k8s.io/apimachinery/pkg/util/sets from k8s.io/apimachinery/pkg/api/meta+ - k8s.io/apimachinery/pkg/util/strategicpatch from k8s.io/client-go/tools/record+ - k8s.io/apimachinery/pkg/util/uuid from sigs.k8s.io/controller-runtime/pkg/internal/controller+ - k8s.io/apimachinery/pkg/util/validation from k8s.io/apimachinery/pkg/api/validation+ - k8s.io/apimachinery/pkg/util/validation/field from k8s.io/apimachinery/pkg/api/errors+ - k8s.io/apimachinery/pkg/util/wait from k8s.io/client-go/tools/cache+ - k8s.io/apimachinery/pkg/util/yaml from k8s.io/apimachinery/pkg/runtime/serializer/json - k8s.io/apimachinery/pkg/version from k8s.io/client-go/discovery+ - k8s.io/apimachinery/pkg/watch from k8s.io/apimachinery/pkg/apis/meta/v1+ - k8s.io/apimachinery/third_party/forked/golang/json from k8s.io/apimachinery/pkg/util/strategicpatch - k8s.io/apimachinery/third_party/forked/golang/reflect from k8s.io/apimachinery/pkg/conversion - k8s.io/apiserver/pkg/storage/names from tailscale.com/cmd/k8s-operator - k8s.io/client-go/applyconfigurations/admissionregistration/v1 from k8s.io/client-go/applyconfigurations/admissionregistration/v1alpha1+ - k8s.io/client-go/applyconfigurations/admissionregistration/v1alpha1 from k8s.io/client-go/kubernetes/typed/admissionregistration/v1alpha1 - k8s.io/client-go/applyconfigurations/admissionregistration/v1beta1 from k8s.io/client-go/kubernetes/typed/admissionregistration/v1beta1 - k8s.io/client-go/applyconfigurations/apiserverinternal/v1alpha1 from k8s.io/client-go/kubernetes/typed/apiserverinternal/v1alpha1 - k8s.io/client-go/applyconfigurations/apps/v1 from k8s.io/client-go/kubernetes/typed/apps/v1 - k8s.io/client-go/applyconfigurations/apps/v1beta1 from k8s.io/client-go/kubernetes/typed/apps/v1beta1 - k8s.io/client-go/applyconfigurations/apps/v1beta2 from k8s.io/client-go/kubernetes/typed/apps/v1beta2 - k8s.io/client-go/applyconfigurations/autoscaling/v1 from k8s.io/client-go/kubernetes/typed/apps/v1+ - k8s.io/client-go/applyconfigurations/autoscaling/v2 from k8s.io/client-go/kubernetes/typed/autoscaling/v2 - k8s.io/client-go/applyconfigurations/autoscaling/v2beta1 from k8s.io/client-go/kubernetes/typed/autoscaling/v2beta1 - k8s.io/client-go/applyconfigurations/autoscaling/v2beta2 from k8s.io/client-go/kubernetes/typed/autoscaling/v2beta2 - k8s.io/client-go/applyconfigurations/batch/v1 from k8s.io/client-go/applyconfigurations/batch/v1beta1+ - k8s.io/client-go/applyconfigurations/batch/v1beta1 from k8s.io/client-go/kubernetes/typed/batch/v1beta1 - k8s.io/client-go/applyconfigurations/certificates/v1 from k8s.io/client-go/kubernetes/typed/certificates/v1 - k8s.io/client-go/applyconfigurations/certificates/v1alpha1 from k8s.io/client-go/kubernetes/typed/certificates/v1alpha1 - k8s.io/client-go/applyconfigurations/certificates/v1beta1 from k8s.io/client-go/kubernetes/typed/certificates/v1beta1 - k8s.io/client-go/applyconfigurations/coordination/v1 from k8s.io/client-go/kubernetes/typed/coordination/v1 - k8s.io/client-go/applyconfigurations/coordination/v1beta1 from k8s.io/client-go/kubernetes/typed/coordination/v1beta1 - k8s.io/client-go/applyconfigurations/core/v1 from k8s.io/client-go/applyconfigurations/apps/v1+ - k8s.io/client-go/applyconfigurations/discovery/v1 from k8s.io/client-go/kubernetes/typed/discovery/v1 - k8s.io/client-go/applyconfigurations/discovery/v1beta1 from k8s.io/client-go/kubernetes/typed/discovery/v1beta1 - k8s.io/client-go/applyconfigurations/events/v1 from k8s.io/client-go/kubernetes/typed/events/v1 - k8s.io/client-go/applyconfigurations/events/v1beta1 from k8s.io/client-go/kubernetes/typed/events/v1beta1 - k8s.io/client-go/applyconfigurations/extensions/v1beta1 from k8s.io/client-go/kubernetes/typed/extensions/v1beta1 - k8s.io/client-go/applyconfigurations/flowcontrol/v1 from k8s.io/client-go/kubernetes/typed/flowcontrol/v1 - k8s.io/client-go/applyconfigurations/flowcontrol/v1beta1 from k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta1 - k8s.io/client-go/applyconfigurations/flowcontrol/v1beta2 from k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta2 - k8s.io/client-go/applyconfigurations/flowcontrol/v1beta3 from k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta3 - k8s.io/client-go/applyconfigurations/internal from k8s.io/client-go/applyconfigurations/admissionregistration/v1+ - k8s.io/client-go/applyconfigurations/meta/v1 from k8s.io/client-go/applyconfigurations/admissionregistration/v1+ - k8s.io/client-go/applyconfigurations/networking/v1 from k8s.io/client-go/kubernetes/typed/networking/v1 - k8s.io/client-go/applyconfigurations/networking/v1alpha1 from k8s.io/client-go/kubernetes/typed/networking/v1alpha1 - k8s.io/client-go/applyconfigurations/networking/v1beta1 from k8s.io/client-go/kubernetes/typed/networking/v1beta1 - k8s.io/client-go/applyconfigurations/node/v1 from k8s.io/client-go/kubernetes/typed/node/v1 - k8s.io/client-go/applyconfigurations/node/v1alpha1 from k8s.io/client-go/kubernetes/typed/node/v1alpha1 - k8s.io/client-go/applyconfigurations/node/v1beta1 from k8s.io/client-go/kubernetes/typed/node/v1beta1 - k8s.io/client-go/applyconfigurations/policy/v1 from k8s.io/client-go/kubernetes/typed/policy/v1 - k8s.io/client-go/applyconfigurations/policy/v1beta1 from k8s.io/client-go/kubernetes/typed/policy/v1beta1 - k8s.io/client-go/applyconfigurations/rbac/v1 from k8s.io/client-go/kubernetes/typed/rbac/v1 - k8s.io/client-go/applyconfigurations/rbac/v1alpha1 from k8s.io/client-go/kubernetes/typed/rbac/v1alpha1 - k8s.io/client-go/applyconfigurations/rbac/v1beta1 from k8s.io/client-go/kubernetes/typed/rbac/v1beta1 - k8s.io/client-go/applyconfigurations/resource/v1alpha2 from k8s.io/client-go/kubernetes/typed/resource/v1alpha2 - k8s.io/client-go/applyconfigurations/scheduling/v1 from k8s.io/client-go/kubernetes/typed/scheduling/v1 - k8s.io/client-go/applyconfigurations/scheduling/v1alpha1 from k8s.io/client-go/kubernetes/typed/scheduling/v1alpha1 - k8s.io/client-go/applyconfigurations/scheduling/v1beta1 from k8s.io/client-go/kubernetes/typed/scheduling/v1beta1 - k8s.io/client-go/applyconfigurations/storage/v1 from k8s.io/client-go/kubernetes/typed/storage/v1 - k8s.io/client-go/applyconfigurations/storage/v1alpha1 from k8s.io/client-go/kubernetes/typed/storage/v1alpha1 - k8s.io/client-go/applyconfigurations/storage/v1beta1 from k8s.io/client-go/kubernetes/typed/storage/v1beta1 - k8s.io/client-go/applyconfigurations/storagemigration/v1alpha1 from k8s.io/client-go/kubernetes/typed/storagemigration/v1alpha1 - k8s.io/client-go/discovery from k8s.io/client-go/applyconfigurations/meta/v1+ - k8s.io/client-go/dynamic from sigs.k8s.io/controller-runtime/pkg/cache/internal+ - k8s.io/client-go/features from k8s.io/client-go/tools/cache - k8s.io/client-go/kubernetes from k8s.io/client-go/tools/leaderelection/resourcelock - k8s.io/client-go/kubernetes/scheme from k8s.io/client-go/discovery+ - k8s.io/client-go/kubernetes/typed/admissionregistration/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/admissionregistration/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/admissionregistration/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/apiserverinternal/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/apps/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/apps/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/apps/v1beta2 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/authentication/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/authentication/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/authentication/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/authorization/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/authorization/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/autoscaling/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/autoscaling/v2 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/autoscaling/v2beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/autoscaling/v2beta2 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/batch/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/batch/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/certificates/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/certificates/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/certificates/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/coordination/v1 from k8s.io/client-go/kubernetes+ - k8s.io/client-go/kubernetes/typed/coordination/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/core/v1 from k8s.io/client-go/kubernetes+ - k8s.io/client-go/kubernetes/typed/discovery/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/discovery/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/events/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/events/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/extensions/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/flowcontrol/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta2 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta3 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/networking/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/networking/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/networking/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/node/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/node/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/node/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/policy/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/policy/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/rbac/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/rbac/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/rbac/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/resource/v1alpha2 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/scheduling/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/scheduling/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/scheduling/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/storage/v1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/storage/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/storage/v1beta1 from k8s.io/client-go/kubernetes - k8s.io/client-go/kubernetes/typed/storagemigration/v1alpha1 from k8s.io/client-go/kubernetes - k8s.io/client-go/metadata from sigs.k8s.io/controller-runtime/pkg/cache/internal+ - k8s.io/client-go/openapi from k8s.io/client-go/discovery - k8s.io/client-go/pkg/apis/clientauthentication from k8s.io/client-go/pkg/apis/clientauthentication/install+ - k8s.io/client-go/pkg/apis/clientauthentication/install from k8s.io/client-go/plugin/pkg/client/auth/exec - 💣 k8s.io/client-go/pkg/apis/clientauthentication/v1 from k8s.io/client-go/pkg/apis/clientauthentication/install+ - 💣 k8s.io/client-go/pkg/apis/clientauthentication/v1beta1 from k8s.io/client-go/pkg/apis/clientauthentication/install+ - k8s.io/client-go/pkg/version from k8s.io/client-go/rest - k8s.io/client-go/plugin/pkg/client/auth/exec from k8s.io/client-go/rest - k8s.io/client-go/rest from k8s.io/client-go/discovery+ - k8s.io/client-go/rest/watch from k8s.io/client-go/rest - k8s.io/client-go/restmapper from sigs.k8s.io/controller-runtime/pkg/client/apiutil - k8s.io/client-go/tools/auth from k8s.io/client-go/tools/clientcmd - k8s.io/client-go/tools/cache from sigs.k8s.io/controller-runtime/pkg/cache+ - k8s.io/client-go/tools/cache/synctrack from k8s.io/client-go/tools/cache - k8s.io/client-go/tools/clientcmd from sigs.k8s.io/controller-runtime/pkg/client/config - k8s.io/client-go/tools/clientcmd/api from k8s.io/client-go/plugin/pkg/client/auth/exec+ - k8s.io/client-go/tools/clientcmd/api/latest from k8s.io/client-go/tools/clientcmd - 💣 k8s.io/client-go/tools/clientcmd/api/v1 from k8s.io/client-go/tools/clientcmd/api/latest - k8s.io/client-go/tools/internal/events from k8s.io/client-go/tools/record - k8s.io/client-go/tools/leaderelection from sigs.k8s.io/controller-runtime/pkg/manager+ - k8s.io/client-go/tools/leaderelection/resourcelock from k8s.io/client-go/tools/leaderelection+ - k8s.io/client-go/tools/metrics from k8s.io/client-go/plugin/pkg/client/auth/exec+ - k8s.io/client-go/tools/pager from k8s.io/client-go/tools/cache - k8s.io/client-go/tools/record from sigs.k8s.io/controller-runtime/pkg/cluster+ - k8s.io/client-go/tools/record/util from k8s.io/client-go/tools/record - k8s.io/client-go/tools/reference from k8s.io/client-go/kubernetes/typed/core/v1+ - k8s.io/client-go/transport from k8s.io/client-go/plugin/pkg/client/auth/exec+ - k8s.io/client-go/util/cert from k8s.io/client-go/rest+ - k8s.io/client-go/util/connrotation from k8s.io/client-go/plugin/pkg/client/auth/exec+ - k8s.io/client-go/util/flowcontrol from k8s.io/client-go/kubernetes+ - k8s.io/client-go/util/homedir from k8s.io/client-go/tools/clientcmd - k8s.io/client-go/util/keyutil from k8s.io/client-go/util/cert - k8s.io/client-go/util/workqueue from k8s.io/client-go/transport+ - k8s.io/klog/v2 from k8s.io/apimachinery/pkg/api/meta+ - k8s.io/klog/v2/internal/buffer from k8s.io/klog/v2 - k8s.io/klog/v2/internal/clock from k8s.io/klog/v2 - k8s.io/klog/v2/internal/dbg from k8s.io/klog/v2 - k8s.io/klog/v2/internal/serialize from k8s.io/klog/v2 - k8s.io/klog/v2/internal/severity from k8s.io/klog/v2+ - k8s.io/klog/v2/internal/sloghandler from k8s.io/klog/v2 - k8s.io/kube-openapi/pkg/cached from k8s.io/kube-openapi/pkg/handler3 - k8s.io/kube-openapi/pkg/common from k8s.io/kube-openapi/pkg/handler3 - k8s.io/kube-openapi/pkg/handler3 from k8s.io/client-go/openapi - k8s.io/kube-openapi/pkg/internal from k8s.io/kube-openapi/pkg/spec3+ - k8s.io/kube-openapi/pkg/internal/third_party/go-json-experiment/json from k8s.io/kube-openapi/pkg/internal+ - k8s.io/kube-openapi/pkg/schemaconv from k8s.io/apimachinery/pkg/util/managedfields+ - k8s.io/kube-openapi/pkg/spec3 from k8s.io/client-go/openapi+ - k8s.io/kube-openapi/pkg/util/proto from k8s.io/apimachinery/pkg/util/managedfields+ - k8s.io/kube-openapi/pkg/validation/spec from k8s.io/apimachinery/pkg/util/managedfields+ - k8s.io/utils/buffer from k8s.io/client-go/tools/cache - k8s.io/utils/clock from k8s.io/apimachinery/pkg/util/cache+ - k8s.io/utils/clock/testing from k8s.io/client-go/util/flowcontrol - k8s.io/utils/internal/third_party/forked/golang/net from k8s.io/utils/net - k8s.io/utils/net from k8s.io/apimachinery/pkg/util/net+ - k8s.io/utils/pointer from k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1+ - k8s.io/utils/ptr from k8s.io/client-go/tools/cache+ - k8s.io/utils/strings/slices from k8s.io/apimachinery/pkg/labels - k8s.io/utils/trace from k8s.io/client-go/tools/cache - sigs.k8s.io/controller-runtime/pkg/builder from tailscale.com/cmd/k8s-operator - sigs.k8s.io/controller-runtime/pkg/cache from sigs.k8s.io/controller-runtime/pkg/cluster+ - sigs.k8s.io/controller-runtime/pkg/cache/internal from sigs.k8s.io/controller-runtime/pkg/cache - sigs.k8s.io/controller-runtime/pkg/certwatcher from sigs.k8s.io/controller-runtime/pkg/metrics/server+ - sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics from sigs.k8s.io/controller-runtime/pkg/certwatcher - sigs.k8s.io/controller-runtime/pkg/client from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/client/apiutil from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/client/config from tailscale.com/cmd/k8s-operator - sigs.k8s.io/controller-runtime/pkg/cluster from sigs.k8s.io/controller-runtime/pkg/manager - sigs.k8s.io/controller-runtime/pkg/config from sigs.k8s.io/controller-runtime/pkg/manager - sigs.k8s.io/controller-runtime/pkg/controller from sigs.k8s.io/controller-runtime/pkg/builder - sigs.k8s.io/controller-runtime/pkg/conversion from sigs.k8s.io/controller-runtime/pkg/webhook/conversion - sigs.k8s.io/controller-runtime/pkg/event from sigs.k8s.io/controller-runtime/pkg/handler+ - sigs.k8s.io/controller-runtime/pkg/handler from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/healthz from sigs.k8s.io/controller-runtime/pkg/manager+ - sigs.k8s.io/controller-runtime/pkg/internal/controller from sigs.k8s.io/controller-runtime/pkg/controller - sigs.k8s.io/controller-runtime/pkg/internal/controller/metrics from sigs.k8s.io/controller-runtime/pkg/internal/controller - sigs.k8s.io/controller-runtime/pkg/internal/field/selector from sigs.k8s.io/controller-runtime/pkg/cache/internal - sigs.k8s.io/controller-runtime/pkg/internal/httpserver from sigs.k8s.io/controller-runtime/pkg/manager+ - sigs.k8s.io/controller-runtime/pkg/internal/log from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/internal/recorder from sigs.k8s.io/controller-runtime/pkg/cluster+ - sigs.k8s.io/controller-runtime/pkg/internal/source from sigs.k8s.io/controller-runtime/pkg/source - sigs.k8s.io/controller-runtime/pkg/internal/syncs from sigs.k8s.io/controller-runtime/pkg/cache/internal - sigs.k8s.io/controller-runtime/pkg/leaderelection from sigs.k8s.io/controller-runtime/pkg/manager - sigs.k8s.io/controller-runtime/pkg/log from sigs.k8s.io/controller-runtime/pkg/client+ - sigs.k8s.io/controller-runtime/pkg/log/zap from tailscale.com/cmd/k8s-operator - sigs.k8s.io/controller-runtime/pkg/manager from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/manager/signals from tailscale.com/cmd/k8s-operator - sigs.k8s.io/controller-runtime/pkg/metrics from sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics+ - sigs.k8s.io/controller-runtime/pkg/metrics/server from sigs.k8s.io/controller-runtime/pkg/manager - sigs.k8s.io/controller-runtime/pkg/predicate from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/ratelimiter from sigs.k8s.io/controller-runtime/pkg/controller+ - sigs.k8s.io/controller-runtime/pkg/reconcile from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/recorder from sigs.k8s.io/controller-runtime/pkg/leaderelection+ - sigs.k8s.io/controller-runtime/pkg/source from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/webhook from sigs.k8s.io/controller-runtime/pkg/manager - sigs.k8s.io/controller-runtime/pkg/webhook/admission from sigs.k8s.io/controller-runtime/pkg/builder+ - sigs.k8s.io/controller-runtime/pkg/webhook/conversion from sigs.k8s.io/controller-runtime/pkg/builder - sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics from sigs.k8s.io/controller-runtime/pkg/webhook+ - sigs.k8s.io/json from k8s.io/apimachinery/pkg/runtime/serializer/json+ - sigs.k8s.io/json/internal/golang/encoding/json from sigs.k8s.io/json - 💣 sigs.k8s.io/structured-merge-diff/v4/fieldpath from k8s.io/apimachinery/pkg/util/managedfields+ - sigs.k8s.io/structured-merge-diff/v4/merge from k8s.io/apimachinery/pkg/util/managedfields/internal - sigs.k8s.io/structured-merge-diff/v4/schema from k8s.io/apimachinery/pkg/util/managedfields+ - sigs.k8s.io/structured-merge-diff/v4/typed from k8s.io/apimachinery/pkg/util/managedfields+ - sigs.k8s.io/structured-merge-diff/v4/value from k8s.io/apimachinery/pkg/runtime+ - sigs.k8s.io/yaml from k8s.io/apimachinery/pkg/runtime/serializer/json+ - sigs.k8s.io/yaml/goyaml.v2 from sigs.k8s.io/yaml - tailscale.com from tailscale.com/version - tailscale.com/appc from tailscale.com/ipn/ipnlocal - tailscale.com/atomicfile from tailscale.com/ipn+ - tailscale.com/client/tailscale from tailscale.com/client/web+ - tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+ - tailscale.com/client/web from tailscale.com/ipn/ipnlocal - tailscale.com/clientupdate from tailscale.com/client/web+ - LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate - tailscale.com/control/controlbase from tailscale.com/control/controlhttp+ - tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+ - tailscale.com/control/controlhttp from tailscale.com/control/controlclient - tailscale.com/control/controlhttp/controlhttpcommon from tailscale.com/control/controlhttp - tailscale.com/control/controlknobs from tailscale.com/control/controlclient+ - tailscale.com/derp from tailscale.com/derp/derphttp+ - tailscale.com/derp/derphttp from tailscale.com/ipn/localapi+ - tailscale.com/disco from tailscale.com/derp+ - tailscale.com/doctor from tailscale.com/ipn/ipnlocal - tailscale.com/doctor/ethtool from tailscale.com/ipn/ipnlocal - 💣 tailscale.com/doctor/permissions from tailscale.com/ipn/ipnlocal - tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal - tailscale.com/drive from tailscale.com/client/tailscale+ - tailscale.com/envknob from tailscale.com/client/tailscale+ - tailscale.com/envknob/featureknob from tailscale.com/client/web+ - tailscale.com/health from tailscale.com/control/controlclient+ - tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal - tailscale.com/hostinfo from tailscale.com/client/web+ - tailscale.com/internal/noiseconn from tailscale.com/control/controlclient - tailscale.com/ipn from tailscale.com/client/tailscale+ - tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+ - 💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+ - tailscale.com/ipn/ipnlocal from tailscale.com/ipn/localapi+ - tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+ - tailscale.com/ipn/localapi from tailscale.com/tsnet - tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal - tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+ - L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store - tailscale.com/ipn/store/kubestore from tailscale.com/cmd/k8s-operator+ - tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+ - tailscale.com/k8s-operator from tailscale.com/cmd/k8s-operator - tailscale.com/k8s-operator/apis from tailscale.com/k8s-operator/apis/v1alpha1 - tailscale.com/k8s-operator/apis/v1alpha1 from tailscale.com/cmd/k8s-operator+ - tailscale.com/k8s-operator/sessionrecording from tailscale.com/cmd/k8s-operator - tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording - tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+ - tailscale.com/k8s-operator/sessionrecording/ws from tailscale.com/k8s-operator/sessionrecording - tailscale.com/kube/egressservices from tailscale.com/cmd/k8s-operator - tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+ - tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore - tailscale.com/kube/kubetypes from tailscale.com/cmd/k8s-operator+ - tailscale.com/licenses from tailscale.com/client/web - tailscale.com/log/filelogger from tailscale.com/logpolicy - tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal - tailscale.com/logpolicy from tailscale.com/ipn/ipnlocal+ - tailscale.com/logtail from tailscale.com/control/controlclient+ - tailscale.com/logtail/backoff from tailscale.com/control/controlclient+ - tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+ - tailscale.com/metrics from tailscale.com/derp+ - tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+ - tailscale.com/net/connstats from tailscale.com/net/tstun+ - tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+ - tailscale.com/net/dns/publicdns from tailscale.com/net/dns+ - tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback - tailscale.com/net/dns/resolvconffile from tailscale.com/cmd/k8s-operator+ - tailscale.com/net/dns/resolver from tailscale.com/net/dns - tailscale.com/net/dnscache from tailscale.com/control/controlclient+ - tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+ - tailscale.com/net/flowtrack from tailscale.com/net/packet+ - tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+ - tailscale.com/net/memnet from tailscale.com/tsnet - tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+ - tailscale.com/net/neterror from tailscale.com/net/dns/resolver+ - tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal - tailscale.com/net/netknob from tailscale.com/logpolicy+ - 💣 tailscale.com/net/netmon from tailscale.com/control/controlclient+ - 💣 tailscale.com/net/netns from tailscale.com/derp/derphttp+ - W 💣 tailscale.com/net/netstat from tailscale.com/portlist - tailscale.com/net/netutil from tailscale.com/client/tailscale+ - tailscale.com/net/packet from tailscale.com/net/connstats+ - tailscale.com/net/packet/checksum from tailscale.com/net/tstun - tailscale.com/net/ping from tailscale.com/net/netcheck+ - tailscale.com/net/portmapper from tailscale.com/ipn/localapi+ - tailscale.com/net/proxymux from tailscale.com/tsnet - tailscale.com/net/routetable from tailscale.com/doctor/routetable - tailscale.com/net/socks5 from tailscale.com/tsnet - tailscale.com/net/sockstats from tailscale.com/control/controlclient+ - tailscale.com/net/stun from tailscale.com/ipn/localapi+ - L tailscale.com/net/tcpinfo from tailscale.com/derp - tailscale.com/net/tlsdial from tailscale.com/control/controlclient+ - tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial - tailscale.com/net/tsaddr from tailscale.com/client/web+ - tailscale.com/net/tsdial from tailscale.com/control/controlclient+ - 💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+ - tailscale.com/net/tstun from tailscale.com/tsd+ - tailscale.com/omit from tailscale.com/ipn/conffile - tailscale.com/paths from tailscale.com/client/tailscale+ - 💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal - tailscale.com/posture from tailscale.com/ipn/ipnlocal - tailscale.com/proxymap from tailscale.com/tsd+ - 💣 tailscale.com/safesocket from tailscale.com/client/tailscale+ - tailscale.com/sessionrecording from tailscale.com/k8s-operator/sessionrecording+ - tailscale.com/syncs from tailscale.com/control/controlknobs+ - tailscale.com/tailcfg from tailscale.com/client/tailscale+ - tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+ - tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock - tailscale.com/tka from tailscale.com/client/tailscale+ - tailscale.com/tsconst from tailscale.com/net/netmon+ - tailscale.com/tsd from tailscale.com/ipn/ipnlocal+ - tailscale.com/tsnet from tailscale.com/cmd/k8s-operator+ - tailscale.com/tstime from tailscale.com/cmd/k8s-operator+ - tailscale.com/tstime/mono from tailscale.com/net/tstun+ - tailscale.com/tstime/rate from tailscale.com/derp+ - tailscale.com/tsweb/varz from tailscale.com/util/usermetric - tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal - tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/empty from tailscale.com/ipn+ - tailscale.com/types/ipproto from tailscale.com/net/flowtrack+ - tailscale.com/types/key from tailscale.com/client/tailscale+ - tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/logger from tailscale.com/appc+ - tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/netlogtype from tailscale.com/net/connstats+ - tailscale.com/types/netmap from tailscale.com/control/controlclient+ - tailscale.com/types/nettype from tailscale.com/ipn/localapi+ - tailscale.com/types/opt from tailscale.com/client/tailscale+ - tailscale.com/types/persist from tailscale.com/control/controlclient+ - tailscale.com/types/preftype from tailscale.com/ipn+ - tailscale.com/types/ptr from tailscale.com/cmd/k8s-operator+ - tailscale.com/types/result from tailscale.com/util/lineiter - tailscale.com/types/structs from tailscale.com/control/controlclient+ - tailscale.com/types/tkatype from tailscale.com/client/tailscale+ - tailscale.com/types/views from tailscale.com/appc+ - tailscale.com/util/cibuild from tailscale.com/health - tailscale.com/util/clientmetric from tailscale.com/cmd/k8s-operator+ - tailscale.com/util/cloudenv from tailscale.com/hostinfo+ - tailscale.com/util/cmpver from tailscale.com/clientupdate+ - tailscale.com/util/ctxkey from tailscale.com/cmd/k8s-operator+ - 💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+ - L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+ - tailscale.com/util/dnsname from tailscale.com/appc+ - tailscale.com/util/execqueue from tailscale.com/appc+ - tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal - tailscale.com/util/groupmember from tailscale.com/client/web+ - 💣 tailscale.com/util/hashx from tailscale.com/util/deephash - tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/httpm from tailscale.com/client/tailscale+ - tailscale.com/util/lineiter from tailscale.com/hostinfo+ - L tailscale.com/util/linuxfw from tailscale.com/net/netns+ - tailscale.com/util/mak from tailscale.com/appc+ - tailscale.com/util/multierr from tailscale.com/control/controlclient+ - tailscale.com/util/must from tailscale.com/clientupdate/distsign+ - tailscale.com/util/nocasemaps from tailscale.com/types/ipproto - 💣 tailscale.com/util/osdiag from tailscale.com/ipn/localapi - W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag - tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal - tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal - tailscale.com/util/progresstracking from tailscale.com/ipn/localapi - tailscale.com/util/race from tailscale.com/net/dns/resolver - tailscale.com/util/racebuild from tailscale.com/logpolicy - tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/ringbuffer from tailscale.com/wgengine/magicsock - tailscale.com/util/set from tailscale.com/cmd/k8s-operator+ - tailscale.com/util/singleflight from tailscale.com/control/controlclient+ - tailscale.com/util/slicesx from tailscale.com/appc+ - tailscale.com/util/syspolicy from tailscale.com/control/controlclient+ - tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ - tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+ - tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source - tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy+ - tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+ - tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+ - tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock - tailscale.com/util/systemd from tailscale.com/control/controlclient+ - tailscale.com/util/testenv from tailscale.com/control/controlclient+ - tailscale.com/util/truncate from tailscale.com/logtail - tailscale.com/util/uniq from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/usermetric from tailscale.com/health+ - tailscale.com/util/vizerror from tailscale.com/tailcfg+ - 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+ - W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+ - W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns+ - W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal - W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ - tailscale.com/util/zstdframe from tailscale.com/control/controlclient+ - tailscale.com/version from tailscale.com/client/web+ - tailscale.com/version/distro from tailscale.com/client/web+ - tailscale.com/wgengine from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/capture from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/filter from tailscale.com/control/controlclient+ - tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap+ - 💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/netlog from tailscale.com/wgengine - tailscale.com/wgengine/netstack from tailscale.com/tsnet - tailscale.com/wgengine/netstack/gro from tailscale.com/net/tstun+ - tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal - 💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+ - tailscale.com/wgengine/wglog from tailscale.com/wgengine - W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router - golang.org/x/crypto/argon2 from tailscale.com/tka - golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ - golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ - LD golang.org/x/crypto/blowfish from github.com/tailscale/golang-x-crypto/ssh/internal/bcrypt_pbkdf - golang.org/x/crypto/chacha20 from github.com/tailscale/golang-x-crypto/ssh+ - golang.org/x/crypto/chacha20poly1305 from crypto/tls+ - golang.org/x/crypto/cryptobyte from crypto/ecdsa+ - golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+ - golang.org/x/crypto/curve25519 from github.com/tailscale/golang-x-crypto/ssh+ - golang.org/x/crypto/hkdf from crypto/tls+ - golang.org/x/crypto/nacl/box from tailscale.com/types/key - golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box - golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device - golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/sha3 from crypto/internal/mlkem768+ - golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+ - golang.org/x/exp/maps from sigs.k8s.io/controller-runtime/pkg/cache+ - golang.org/x/exp/slices from tailscale.com/cmd/k8s-operator+ - golang.org/x/net/bpf from github.com/mdlayher/genetlink+ - golang.org/x/net/dns/dnsmessage from net+ - golang.org/x/net/http/httpguts from golang.org/x/net/http2+ - golang.org/x/net/http/httpproxy from net/http+ - golang.org/x/net/http2 from golang.org/x/net/http2/h2c+ - golang.org/x/net/http2/h2c from tailscale.com/ipn/ipnlocal - golang.org/x/net/http2/hpack from golang.org/x/net/http2+ - golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+ - golang.org/x/net/idna from golang.org/x/net/http/httpguts+ - golang.org/x/net/ipv4 from github.com/miekg/dns+ - golang.org/x/net/ipv6 from github.com/miekg/dns+ - golang.org/x/net/proxy from tailscale.com/net/netns - D golang.org/x/net/route from net+ - golang.org/x/net/websocket from tailscale.com/k8s-operator/sessionrecording/ws - golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials+ - golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/k8s-operator - golang.org/x/oauth2/internal from golang.org/x/oauth2+ - golang.org/x/sync/errgroup from github.com/mdlayher/socket+ - golang.org/x/sys/cpu from github.com/josharian/native+ - LD golang.org/x/sys/unix from github.com/fsnotify/fsnotify+ - W golang.org/x/sys/windows from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+ - W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil - golang.org/x/term from k8s.io/client-go/plugin/pkg/client/auth/exec+ - golang.org/x/text/secure/bidirule from golang.org/x/net/idna - golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ - golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ - golang.org/x/text/unicode/norm from golang.org/x/net/idna - golang.org/x/time/rate from gvisor.dev/gvisor/pkg/log+ - archive/tar from tailscale.com/clientupdate - bufio from compress/flate+ - bytes from archive/tar+ - cmp from github.com/gaissmai/bart+ - compress/flate from compress/gzip+ - compress/gzip from github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding+ - compress/zlib from debug/pe+ - container/heap from gvisor.dev/gvisor/pkg/tcpip/transport/tcp+ - container/list from crypto/tls+ - context from crypto/tls+ - crypto from crypto/ecdh+ - crypto/aes from crypto/ecdsa+ - crypto/cipher from crypto/aes+ - crypto/des from crypto/tls+ - crypto/dsa from crypto/x509+ - crypto/ecdh from crypto/ecdsa+ - crypto/ecdsa from crypto/tls+ - crypto/ed25519 from crypto/tls+ - crypto/elliptic from crypto/ecdsa+ - crypto/hmac from crypto/tls+ - crypto/md5 from crypto/tls+ - crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls+ - crypto/rsa from crypto/tls+ - crypto/sha1 from crypto/tls+ - crypto/sha256 from crypto/tls+ - crypto/sha512 from crypto/ecdsa+ - crypto/subtle from crypto/aes+ - crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+ - crypto/x509 from crypto/tls+ - crypto/x509/pkix from crypto/x509+ - database/sql from github.com/prometheus/client_golang/prometheus/collectors - database/sql/driver from database/sql+ - W debug/dwarf from debug/pe - W debug/pe from github.com/dblohm7/wingoes/pe - embed from crypto/internal/nistec+ - encoding from encoding/gob+ - encoding/asn1 from crypto/x509+ - encoding/base32 from github.com/fxamacker/cbor/v2+ - encoding/base64 from encoding/json+ - encoding/binary from compress/gzip+ - encoding/csv from github.com/spf13/pflag - encoding/gob from github.com/gorilla/securecookie - encoding/hex from crypto/x509+ - encoding/json from expvar+ - encoding/pem from crypto/tls+ - encoding/xml from github.com/aws/aws-sdk-go-v2/aws/protocol/xml+ - errors from archive/tar+ - expvar from github.com/prometheus/client_golang/prometheus+ - flag from github.com/spf13/pflag+ - fmt from archive/tar+ - go/ast from go/doc+ - go/build/constraint from go/parser - go/doc from k8s.io/apimachinery/pkg/runtime - go/doc/comment from go/doc - go/parser from k8s.io/apimachinery/pkg/runtime - go/scanner from go/ast+ - go/token from go/ast+ - hash from compress/zlib+ - hash/adler32 from compress/zlib+ - hash/crc32 from compress/gzip+ - hash/fnv from google.golang.org/protobuf/internal/detrand - hash/maphash from go4.org/mem - html from html/template+ - html/template from github.com/gorilla/csrf - io from archive/tar+ - io/fs from archive/tar+ - io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+ - iter from go/ast+ - log from expvar+ - log/internal from log+ - log/slog from github.com/go-logr/logr+ - log/slog/internal from log/slog - maps from sigs.k8s.io/controller-runtime/pkg/predicate+ - math from archive/tar+ - math/big from crypto/dsa+ - math/bits from compress/flate+ - math/rand from github.com/google/go-cmp/cmp+ - math/rand/v2 from tailscale.com/derp+ - mime from github.com/prometheus/common/expfmt+ - mime/multipart from github.com/go-openapi/swag+ - mime/quotedprintable from mime/multipart - net from crypto/tls+ - net/http from expvar+ - net/http/httptest from tailscale.com/control/controlclient - net/http/httptrace from github.com/prometheus-community/pro-bing+ - net/http/httputil from github.com/aws/smithy-go/transport/http+ - net/http/internal from net/http+ - net/http/pprof from sigs.k8s.io/controller-runtime/pkg/manager+ - net/netip from github.com/gaissmai/bart+ - net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+ - net/url from crypto/x509+ - os from crypto/rand+ - os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+ - os/signal from sigs.k8s.io/controller-runtime/pkg/manager/signals - os/user from archive/tar+ - path from archive/tar+ - path/filepath from archive/tar+ - reflect from archive/tar+ - regexp from github.com/aws/aws-sdk-go-v2/internal/endpoints+ - regexp/syntax from regexp - runtime/debug from github.com/aws/aws-sdk-go-v2/internal/sync/singleflight+ - runtime/metrics from github.com/prometheus/client_golang/prometheus+ - runtime/pprof from net/http/pprof+ - runtime/trace from net/http/pprof - slices from encoding/base32+ - sort from compress/flate+ - strconv from archive/tar+ - strings from archive/tar+ - sync from archive/tar+ - sync/atomic from context+ - syscall from archive/tar+ - text/tabwriter from k8s.io/apimachinery/pkg/util/diff+ - text/template from html/template - text/template/parse from html/template+ - time from archive/tar+ - unicode from bytes+ - unicode/utf16 from crypto/x509+ - unicode/utf8 from bufio+ - unique from net/netip diff --git a/cmd/k8s-operator/deploy/README.md b/cmd/k8s-operator/deploy/README.md deleted file mode 100644 index 516d6f9cddc88..0000000000000 --- a/cmd/k8s-operator/deploy/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Tailscale Kubernetes operator deployment manifests - -./cmd/k8s-operator/deploy contain various Tailscale Kubernetes operator deployment manifests. - -## Helm chart - -`./cmd/k8s-operator/deploy/chart` contains Tailscale operator Helm chart templates. -The chart templates are also used to generate the static manifest, so developers must ensure that any changes applied to the chart have been propagated to the static manifest by running `go generate tailscale.com/cmd/k8s-operator` - -## Static manifests - -`./cmd/k8s-operator/deploy/manifests/operator.yaml` is a static manifest for the operator generated from the Helm chart templates for the operator. \ No newline at end of file diff --git a/cmd/k8s-operator/deploy/chart/.helmignore b/cmd/k8s-operator/deploy/chart/.helmignore deleted file mode 100644 index 0e8a0eb36f4ca..0000000000000 --- a/cmd/k8s-operator/deploy/chart/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/cmd/k8s-operator/deploy/chart/Chart.yaml b/cmd/k8s-operator/deploy/chart/Chart.yaml deleted file mode 100644 index 363d87d15954a..0000000000000 --- a/cmd/k8s-operator/deploy/chart/Chart.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -apiVersion: v2 -name: tailscale-operator -description: A Helm chart for Tailscale Kubernetes operator -home: https://github.com/tailscale/tailscale - -keywords: - - "tailscale" - - "vpn" - - "ingress" - - "egress" - - "wireguard" - -sources: -- https://github.com/tailscale/tailscale - -type: application - -maintainers: - - name: tailscale-maintainers - url: https://tailscale.com/ - -# version will be set to Tailscale repo tag (without 'v') at release time. -version: 0.1.0 - -# appVersion will be set to Tailscale repo tag at release time. -appVersion: "unstable" diff --git a/cmd/k8s-operator/deploy/chart/templates/apiserverproxy-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/apiserverproxy-rbac.yaml deleted file mode 100644 index 072ecf6d22e2f..0000000000000 --- a/cmd/k8s-operator/deploy/chart/templates/apiserverproxy-rbac.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -{{ if eq .Values.apiServerProxyConfig.mode "true" }} -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: tailscale-auth-proxy -rules: -- apiGroups: [""] - resources: ["users", "groups"] - verbs: ["impersonate"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: tailscale-auth-proxy -subjects: -- kind: ServiceAccount - name: operator - namespace: {{ .Release.Namespace }} -roleRef: - kind: ClusterRole - name: tailscale-auth-proxy - apiGroup: rbac.authorization.k8s.io -{{ end }} diff --git a/cmd/k8s-operator/deploy/chart/templates/deployment.yaml b/cmd/k8s-operator/deploy/chart/templates/deployment.yaml deleted file mode 100644 index 2653f21595ba7..0000000000000 --- a/cmd/k8s-operator/deploy/chart/templates/deployment.yaml +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -apiVersion: apps/v1 -kind: Deployment -metadata: - name: operator - namespace: {{ .Release.Namespace }} -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app: operator - template: - metadata: - {{- with .Values.operatorConfig.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - app: operator - {{- with .Values.operatorConfig.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: operator - {{- with .Values.operatorConfig.podSecurityContext }} - securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} - volumes: - - name: oauth - secret: - secretName: operator-oauth - containers: - - name: operator - {{- with .Values.operatorConfig.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.operatorConfig.resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- $operatorTag:= printf ":%s" ( .Values.operatorConfig.image.tag | default .Chart.AppVersion )}} - image: {{ coalesce .Values.operatorConfig.image.repo .Values.operatorConfig.image.repository }}{{- if .Values.operatorConfig.image.digest -}}{{ printf "@%s" .Values.operatorConfig.image.digest}}{{- else -}}{{ printf "%s" $operatorTag }}{{- end }} - imagePullPolicy: {{ .Values.operatorConfig.image.pullPolicy }} - env: - - name: OPERATOR_INITIAL_TAGS - value: {{ join "," .Values.operatorConfig.defaultTags }} - - name: OPERATOR_HOSTNAME - value: {{ .Values.operatorConfig.hostname }} - - name: OPERATOR_SECRET - value: operator - - name: OPERATOR_LOGGING - value: {{ .Values.operatorConfig.logging }} - - name: OPERATOR_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: CLIENT_ID_FILE - value: /oauth/client_id - - name: CLIENT_SECRET_FILE - value: /oauth/client_secret - {{- $proxyTag := printf ":%s" ( .Values.proxyConfig.image.tag | default .Chart.AppVersion )}} - - name: PROXY_IMAGE - value: {{ coalesce .Values.proxyConfig.image.repo .Values.proxyConfig.image.repository }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }} - - name: PROXY_TAGS - value: {{ .Values.proxyConfig.defaultTags }} - - name: APISERVER_PROXY - value: "{{ .Values.apiServerProxyConfig.mode }}" - - name: PROXY_FIREWALL_MODE - value: {{ .Values.proxyConfig.firewallMode }} - {{- if .Values.proxyConfig.defaultProxyClass }} - - name: PROXY_DEFAULT_CLASS - value: {{ .Values.proxyConfig.defaultProxyClass }} - {{- end }} - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: POD_UID - valueFrom: - fieldRef: - fieldPath: metadata.uid - {{- with .Values.operatorConfig.extraEnv }} - {{- toYaml . | nindent 12 }} - {{- end }} - volumeMounts: - - name: oauth - mountPath: /oauth - readOnly: true - {{- with .Values.operatorConfig.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.operatorConfig.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.operatorConfig.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/cmd/k8s-operator/deploy/chart/templates/ingressclass.yaml b/cmd/k8s-operator/deploy/chart/templates/ingressclass.yaml deleted file mode 100644 index 208d58ee10f08..0000000000000 --- a/cmd/k8s-operator/deploy/chart/templates/ingressclass.yaml +++ /dev/null @@ -1,10 +0,0 @@ -{{- if .Values.ingressClass.enabled }} -apiVersion: networking.k8s.io/v1 -kind: IngressClass -metadata: - name: tailscale # class name currently can not be changed - annotations: {} # we do not support default IngressClass annotation https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class -spec: - controller: tailscale.com/ts-ingress # controller name currently can not be changed - # parameters: {} # currently no parameters are supported -{{- end }} diff --git a/cmd/k8s-operator/deploy/chart/templates/oauth-secret.yaml b/cmd/k8s-operator/deploy/chart/templates/oauth-secret.yaml deleted file mode 100644 index b44fde0a17b49..0000000000000 --- a/cmd/k8s-operator/deploy/chart/templates/oauth-secret.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -{{ if and .Values.oauth .Values.oauth.clientId -}} -apiVersion: v1 -kind: Secret -metadata: - name: operator-oauth - namespace: {{ .Release.Namespace }} -stringData: - client_id: {{ .Values.oauth.clientId }} - client_secret: {{ .Values.oauth.clientSecret }} -{{- end -}} diff --git a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml deleted file mode 100644 index ede61070b4399..0000000000000 --- a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -apiVersion: v1 -kind: ServiceAccount -metadata: - name: operator - namespace: {{ .Release.Namespace }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: tailscale-operator -rules: -- apiGroups: [""] - resources: ["events", "services", "services/status"] - verbs: ["create","delete","deletecollection","get","list","patch","update","watch"] -- apiGroups: ["networking.k8s.io"] - resources: ["ingresses", "ingresses/status"] - verbs: ["create","delete","deletecollection","get","list","patch","update","watch"] -- apiGroups: ["networking.k8s.io"] - resources: ["ingressclasses"] - verbs: ["get", "list", "watch"] -- apiGroups: ["tailscale.com"] - resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status", "proxygroups", "proxygroups/status"] - verbs: ["get", "list", "watch", "update"] -- apiGroups: ["tailscale.com"] - resources: ["dnsconfigs", "dnsconfigs/status"] - verbs: ["get", "list", "watch", "update"] -- apiGroups: ["tailscale.com"] - resources: ["recorders", "recorders/status"] - verbs: ["get", "list", "watch", "update"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: tailscale-operator -subjects: -- kind: ServiceAccount - name: operator - namespace: {{ .Release.Namespace }} -roleRef: - kind: ClusterRole - name: tailscale-operator - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: operator - namespace: {{ .Release.Namespace }} -rules: -- apiGroups: [""] - resources: ["secrets", "serviceaccounts", "configmaps"] - verbs: ["create","delete","deletecollection","get","list","patch","update","watch"] -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] -- apiGroups: ["apps"] - resources: ["statefulsets", "deployments"] - verbs: ["create","delete","deletecollection","get","list","patch","update","watch"] -- apiGroups: ["discovery.k8s.io"] - resources: ["endpointslices"] - verbs: ["get", "list", "watch", "create", "update", "deletecollection"] -- apiGroups: ["rbac.authorization.k8s.io"] - resources: ["roles", "rolebindings"] - verbs: ["get", "create", "patch", "update", "list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: operator - namespace: {{ .Release.Namespace }} -subjects: -- kind: ServiceAccount - name: operator - namespace: {{ .Release.Namespace }} -roleRef: - kind: Role - name: operator - apiGroup: rbac.authorization.k8s.io diff --git a/cmd/k8s-operator/deploy/chart/templates/proxy-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/proxy-rbac.yaml deleted file mode 100644 index fa552a7c7e39a..0000000000000 --- a/cmd/k8s-operator/deploy/chart/templates/proxy-rbac.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -apiVersion: v1 -kind: ServiceAccount -metadata: - name: proxies - namespace: {{ .Release.Namespace }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: proxies - namespace: {{ .Release.Namespace }} -rules: -- apiGroups: [""] - resources: ["secrets"] - verbs: ["create","delete","deletecollection","get","list","patch","update","watch"] -- apiGroups: [""] - resources: ["events"] - verbs: ["create", "patch", "get"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: proxies - namespace: {{ .Release.Namespace }} -subjects: -- kind: ServiceAccount - name: proxies - namespace: {{ .Release.Namespace }} -roleRef: - kind: Role - name: proxies - apiGroup: rbac.authorization.k8s.io diff --git a/cmd/k8s-operator/deploy/chart/values.yaml b/cmd/k8s-operator/deploy/chart/values.yaml deleted file mode 100644 index b24ba37b05360..0000000000000 --- a/cmd/k8s-operator/deploy/chart/values.yaml +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -# Operator oauth credentials. If set a Kubernetes Secret with the provided -# values will be created in the operator namespace. If unset a Secret named -# operator-oauth must be precreated. -oauth: {} - # clientId: "" - # clientSecret: "" - -# installCRDs determines whether tailscale.com CRDs should be installed as part -# of chart installation. We do not use Helm's CRD installation mechanism as that -# does not allow for upgrading CRDs. -# https://helm.sh/docs/chart_best_practices/custom_resource_definitions/ -installCRDs: true - -operatorConfig: - # ACL tag that operator will be tagged with. Operator must be made owner of - # these tags - # https://tailscale.com/kb/1236/kubernetes-operator/?q=operator#setting-up-the-kubernetes-operator - # Multiple tags are defined as array items and passed to the operator as a comma-separated string - defaultTags: - - "tag:k8s-operator" - - image: - # Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/k8s-operator. - repository: tailscale/k8s-operator - # Digest will be prioritized over tag. If neither are set appVersion will be - # used. - tag: "" - digest: "" - pullPolicy: Always - logging: "info" # info, debug, dev - hostname: "tailscale-operator" - nodeSelector: - kubernetes.io/os: linux - - resources: {} - - podAnnotations: {} - podLabels: {} - - tolerations: [] - - affinity: {} - - podSecurityContext: {} - - securityContext: {} - - extraEnv: [] - # - name: EXTRA_VAR1 - # value: "value1" - # - name: EXTRA_VAR2 - # value: "value2" - -# In the case that you already have a tailscale ingressclass in your cluster (or vcluster), you can disable the creation here -ingressClass: - enabled: true - -# proxyConfig contains configuraton that will be applied to any ingress/egress -# proxies created by the operator. -# https://tailscale.com/kb/1439/kubernetes-operator-cluster-ingress -# https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress -# Note that this section contains only a few global configuration options and -# will not be updated with more configuration options in the future. -# If you need more configuration options, take a look at ProxyClass: -# https://tailscale.com/kb/1445/kubernetes-operator-customization#cluster-resource-customization-using-proxyclass-custom-resource -proxyConfig: - image: - # Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/tailscale. - repository: tailscale/tailscale - # Digest will be prioritized over tag. If neither are set appVersion will be - # used. - tag: "" - digest: "" - # ACL tag that operator will tag proxies with. Operator must be made owner of - # these tags - # https://tailscale.com/kb/1236/kubernetes-operator/?q=operator#setting-up-the-kubernetes-operator - # Multiple tags can be passed as a comma-separated string i.e 'tag:k8s-proxies,tag:prod'. - # Note that if you pass multiple tags to this field via `--set` flag to helm upgrade/install commands you must escape the comma (for example, "tag:k8s-proxies\,tag:prod"). See https://github.com/helm/helm/issues/1556 - defaultTags: "tag:k8s" - firewallMode: auto - # If defined, this proxy class will be used as the default proxy class for - # service and ingress resources that do not have a proxy class defined. It - # does not apply to Connector resources. - defaultProxyClass: "" - -# apiServerProxyConfig allows to configure whether the operator should expose -# Kubernetes API server. -# https://tailscale.com/kb/1437/kubernetes-operator-api-server-proxy -apiServerProxyConfig: - mode: "false" # "true", "false", "noauth" - -imagePullSecrets: [] diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_connectors.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_connectors.yaml deleted file mode 100644 index 4434c12835ba1..0000000000000 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_connectors.yaml +++ /dev/null @@ -1,262 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: connectors.tailscale.com -spec: - group: tailscale.com - names: - kind: Connector - listKind: ConnectorList - plural: connectors - shortNames: - - cn - singular: connector - scope: Cluster - versions: - - additionalPrinterColumns: - - description: CIDR ranges exposed to tailnet by a subnet router defined via this Connector instance. - jsonPath: .status.subnetRoutes - name: SubnetRoutes - type: string - - description: Whether this Connector instance defines an exit node. - jsonPath: .status.isExitNode - name: IsExitNode - type: string - - description: Whether this Connector instance is an app connector. - jsonPath: .status.isAppConnector - name: IsAppConnector - type: string - - description: Status of the deployed Connector resources. - jsonPath: .status.conditions[?(@.type == "ConnectorReady")].reason - name: Status - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - Connector defines a Tailscale node that will be deployed in the cluster. The - node can be configured to act as a Tailscale subnet router and/or a Tailscale - exit node. - Connector is a cluster-scoped resource. - More info: - https://tailscale.com/kb/1441/kubernetes-operator-connector - type: object - required: - - spec - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - ConnectorSpec describes the desired Tailscale component. - More info: - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - type: object - properties: - appConnector: - description: |- - AppConnector defines whether the Connector device should act as a Tailscale app connector. A Connector that is - configured as an app connector cannot be a subnet router or an exit node. If this field is unset, the - Connector does not act as an app connector. - Note that you will need to manually configure the permissions and the domains for the app connector via the - Admin panel. - Note also that the main tested and supported use case of this config option is to deploy an app connector on - Kubernetes to access SaaS applications available on the public internet. Using the app connector to expose - cluster workloads or other internal workloads to tailnet might work, but this is not a use case that we have - tested or optimised for. - If you are using the app connector to access SaaS applications because you need a predictable egress IP that - can be whitelisted, it is also your responsibility to ensure that cluster traffic from the connector flows - via that predictable IP, for example by enforcing that cluster egress traffic is routed via an egress NAT - device with a static IP address. - https://tailscale.com/kb/1281/app-connectors - type: object - properties: - routes: - description: |- - Routes are optional preconfigured routes for the domains routed via the app connector. - If not set, routes for the domains will be discovered dynamically. - If set, the app connector will immediately be able to route traffic using the preconfigured routes, but may - also dynamically discover other routes. - https://tailscale.com/kb/1332/apps-best-practices#preconfiguration - type: array - minItems: 1 - items: - type: string - format: cidr - exitNode: - description: |- - ExitNode defines whether the Connector device should act as a Tailscale exit node. Defaults to false. - This field is mutually exclusive with the appConnector field. - https://tailscale.com/kb/1103/exit-nodes - type: boolean - hostname: - description: |- - Hostname is the tailnet hostname that should be assigned to the - Connector node. If unset, hostname defaults to -connector. Hostname can contain lower case letters, numbers and - dashes, it must not start or end with a dash and must be between 2 - and 63 characters long. - type: string - pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$ - proxyClass: - description: |- - ProxyClass is the name of the ProxyClass custom resource that - contains configuration options that should be applied to the - resources created for this Connector. If unset, the operator will - create resources with the default configuration. - type: string - subnetRouter: - description: |- - SubnetRouter defines subnet routes that the Connector device should - expose to tailnet as a Tailscale subnet router. - https://tailscale.com/kb/1019/subnets/ - If this field is unset, the device does not get configured as a Tailscale subnet router. - This field is mutually exclusive with the appConnector field. - type: object - required: - - advertiseRoutes - properties: - advertiseRoutes: - description: |- - AdvertiseRoutes refer to CIDRs that the subnet router should make - available. Route values must be strings that represent a valid IPv4 - or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. - https://tailscale.com/kb/1201/4via6-subnets/ - type: array - minItems: 1 - items: - type: string - format: cidr - tags: - description: |- - Tags that the Tailscale node will be tagged with. - Defaults to [tag:k8s]. - To autoapprove the subnet routes or exit node defined by a Connector, - you can configure Tailscale ACLs to give these tags the necessary - permissions. - See https://tailscale.com/kb/1337/acl-syntax#autoapprovers. - If you specify custom tags here, you must also make the operator an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a Connector node has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - type: array - items: - type: string - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - x-kubernetes-validations: - - rule: has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true) || has(self.appConnector) - message: A Connector needs to have at least one of exit node, subnet router or app connector configured. - - rule: '!((has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true)) && has(self.appConnector))' - message: The appConnector field is mutually exclusive with exitNode and subnetRouter fields. - status: - description: |- - ConnectorStatus describes the status of the Connector. This is set - and managed by the Tailscale operator. - type: object - properties: - conditions: - description: |- - List of status conditions to indicate the status of the Connector. - Known condition types are `ConnectorReady`. - type: array - items: - description: Condition contains details for one aspect of the current state of this API Resource. - type: object - required: - - lastTransitionTime - - message - - reason - - status - - type - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - type: string - format: date-time - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - type: string - maxLength: 32768 - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - type: integer - format: int64 - minimum: 0 - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - type: string - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - status: - description: status of the condition, one of True, False, Unknown. - type: string - enum: - - "True" - - "False" - - Unknown - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - type: string - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - hostname: - description: |- - Hostname is the fully qualified domain name of the Connector node. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - isAppConnector: - description: IsAppConnector is set to true if the Connector acts as an app connector. - type: boolean - isExitNode: - description: IsExitNode is set to true if the Connector acts as an exit node. - type: boolean - subnetRoutes: - description: |- - SubnetRoutes are the routes currently exposed to tailnet via this - Connector instance. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the Connector node. - type: array - items: - type: string - served: true - storage: true - subresources: - status: {} diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml deleted file mode 100644 index 13aee9b9e9ebf..0000000000000 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml +++ /dev/null @@ -1,181 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: dnsconfigs.tailscale.com -spec: - group: tailscale.com - names: - kind: DNSConfig - listKind: DNSConfigList - plural: dnsconfigs - shortNames: - - dc - singular: dnsconfig - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Service IP address of the nameserver - jsonPath: .status.nameserver.ip - name: NameserverIP - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS - names resolvable by cluster workloads. Use this if: A) you need to refer to - tailnet services, exposed to cluster via Tailscale Kubernetes operator egress - proxies by the MagicDNS names of those tailnet services (usually because the - services run over HTTPS) - B) you have exposed a cluster workload to the tailnet using Tailscale Ingress - and you also want to refer to the workload from within the cluster over the - Ingress's MagicDNS name (usually because you have some callback component - that needs to use the same URL as that used by a non-cluster client on - tailnet). - When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will - deploy a nameserver for ts.net DNS names and automatically populate it with records - for any Tailscale egress or Ingress proxies deployed to that cluster. - Currently you must manually update your cluster DNS configuration to add the - IP address of the deployed nameserver as a ts.net stub nameserver. - Instructions for how to do it: - https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), - https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). - Tailscale Kubernetes operator will write the address of a Service fronting - the nameserver to dsnconfig.status.nameserver.ip. - DNSConfig is a singleton - you must not create more than one. - NB: if you want cluster workloads to be able to refer to Tailscale Ingress - using its MagicDNS name, you must also annotate the Ingress resource with - tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to - ensure that the proxy created for the Ingress listens on its Pod IP address. - NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported. - type: object - required: - - spec - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - Spec describes the desired DNS configuration. - More info: - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - type: object - required: - - nameserver - properties: - nameserver: - description: |- - Configuration for a nameserver that can resolve ts.net DNS names - associated with in-cluster proxies for Tailscale egress Services and - Tailscale Ingresses. The operator will always deploy this nameserver - when a DNSConfig is applied. - type: object - properties: - image: - description: Nameserver image. Defaults to tailscale/k8s-nameserver:unstable. - type: object - properties: - repo: - description: Repo defaults to tailscale/k8s-nameserver. - type: string - tag: - description: Tag defaults to unstable. - type: string - status: - description: |- - Status describes the status of the DNSConfig. This is set - and managed by the Tailscale operator. - type: object - properties: - conditions: - type: array - items: - description: Condition contains details for one aspect of the current state of this API Resource. - type: object - required: - - lastTransitionTime - - message - - reason - - status - - type - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - type: string - format: date-time - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - type: string - maxLength: 32768 - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - type: integer - format: int64 - minimum: 0 - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - type: string - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - status: - description: status of the condition, one of True, False, Unknown. - type: string - enum: - - "True" - - "False" - - Unknown - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - type: string - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - nameserver: - description: Nameserver describes the status of nameserver cluster resources. - type: object - properties: - ip: - description: |- - IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. - Currently you must manually update your cluster DNS config to add - this address as a stub nameserver for ts.net for cluster workloads to be - able to resolve MagicDNS names associated with egress or Ingress - proxies. - The IP address will change if you delete and recreate the DNSConfig. - type: string - served: true - storage: true - subresources: - status: {} diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml deleted file mode 100644 index 7086138c03afd..0000000000000 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml +++ /dev/null @@ -1,2160 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: proxyclasses.tailscale.com -spec: - group: tailscale.com - names: - kind: ProxyClass - listKind: ProxyClassList - plural: proxyclasses - singular: proxyclass - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the ProxyClass. - jsonPath: .status.conditions[?(@.type == "ProxyClassReady")].reason - name: Status - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - ProxyClass describes a set of configuration parameters that can be applied to - proxy resources created by the Tailscale Kubernetes operator. - To apply a given ProxyClass to resources created for a tailscale Ingress or - Service, use tailscale.com/proxy-class= label. To apply a - given ProxyClass to resources created for a Connector, use - connector.spec.proxyClass field. - ProxyClass is a cluster scoped resource. - More info: - https://tailscale.com/kb/1445/kubernetes-operator-customization#cluster-resource-customization-using-proxyclass-custom-resource - type: object - required: - - spec - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - Specification of the desired state of the ProxyClass resource. - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - type: object - properties: - metrics: - description: |- - Configuration for proxy metrics. Metrics are currently not supported - for egress proxies and for Ingress proxies that have been configured - with tailscale.com/experimental-forward-cluster-traffic-via-ingress - annotation. Note that the metrics are currently considered unstable - and will likely change in breaking ways in the future - we only - recommend that you use those for debugging purposes. - type: object - required: - - enable - properties: - enable: - description: |- - Setting enable to true will make the proxy serve Tailscale metrics - at :9001/debug/metrics. - Defaults to false. - type: boolean - statefulSet: - description: |- - Configuration parameters for the proxy's StatefulSet. Tailscale - Kubernetes operator deploys a StatefulSet for each of the user - configured proxies (Tailscale Ingress, Tailscale Service, Connector). - type: object - properties: - annotations: - description: |- - Annotations that will be added to the StatefulSet created for the proxy. - Any Annotations specified here will be merged with the default annotations - applied to the StatefulSet by the Tailscale Kubernetes operator as - well as any other annotations that might have been applied by other - actors. - Annotations must be valid Kubernetes annotations. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - additionalProperties: - type: string - labels: - description: |- - Labels that will be added to the StatefulSet created for the proxy. - Any labels specified here will be merged with the default labels - applied to the StatefulSet by the Tailscale Kubernetes operator as - well as any other labels that might have been applied by other - actors. - Label keys and values must be valid Kubernetes label keys and values. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - additionalProperties: - type: string - pod: - description: Configuration for the proxy Pod. - type: object - properties: - affinity: - description: |- - Proxy Pod's affinity rules. - By default, the Tailscale Kubernetes operator does not apply any affinity rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - type: object - properties: - nodeAffinity: - description: Describes node affinity scheduling rules for the pod. - type: object - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node matches the corresponding matchExpressions; the - node(s) with the highest sum are the most preferred. - type: array - items: - description: |- - An empty preferred scheduling term matches all objects with implicit weight 0 - (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). - type: object - required: - - preference - - weight - properties: - preference: - description: A node selector term, associated with the corresponding weight. - type: object - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - x-kubernetes-map-type: atomic - weight: - description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. - type: integer - format: int32 - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to an update), the system - may or may not try to eventually evict the pod from its node. - type: object - required: - - nodeSelectorTerms - properties: - nodeSelectorTerms: - description: Required. A list of node selector terms. The terms are ORed. - type: array - items: - description: |- - A null or empty node selector term matches no objects. The requirements of - them are ANDed. - The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. - type: object - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - x-kubernetes-map-type: atomic - x-kubernetes-list-type: atomic - x-kubernetes-map-type: atomic - podAffinity: - description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). - type: object - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - type: array - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - type: object - required: - - podAffinityTerm - - weight - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - type: integer - format: int32 - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - type: array - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - x-kubernetes-list-type: atomic - podAntiAffinity: - description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). - type: object - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the anti-affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - type: array - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - type: object - required: - - podAffinityTerm - - weight - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - type: integer - format: int32 - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the anti-affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the anti-affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - type: array - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - x-kubernetes-list-type: atomic - annotations: - description: |- - Annotations that will be added to the proxy Pod. - Any annotations specified here will be merged with the default - annotations applied to the Pod by the Tailscale Kubernetes operator. - Annotations must be valid Kubernetes annotations. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - additionalProperties: - type: string - imagePullSecrets: - description: |- - Proxy Pod's image pull Secrets. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - type: array - items: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - type: object - properties: - name: - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - default: "" - x-kubernetes-map-type: atomic - labels: - description: |- - Labels that will be added to the proxy Pod. - Any labels specified here will be merged with the default labels - applied to the Pod by the Tailscale Kubernetes operator. - Label keys and values must be valid Kubernetes label keys and values. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - additionalProperties: - type: string - nodeName: - description: |- - Proxy Pod's node name. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: string - nodeSelector: - description: |- - Proxy Pod's node selector. - By default Tailscale Kubernetes operator does not apply any node - selector. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: object - additionalProperties: - type: string - securityContext: - description: |- - Proxy Pod's security context. - By default Tailscale Kubernetes operator does not apply any Pod - security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - type: object - properties: - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - fsGroup: - description: |- - A special supplemental group that applies to all containers in a pod. - Some volume types allow the Kubelet to change the ownership of that volume - to be owned by the pod: - - 1. The owning GID will be the FSGroup - 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) - 3. The permission bits are OR'd with rw-rw---- - - If unset, the Kubelet will not modify the ownership and permissions of any volume. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - fsGroupChangePolicy: - description: |- - fsGroupChangePolicy defines behavior of changing ownership and permission of the volume - before being exposed inside Pod. This field will only apply to - volume types which support fsGroup based ownership(and permissions). - It will have no effect on ephemeral volume types such as: secret, configmaps - and emptydir. - Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. - Note that this field cannot be set when spec.os.name is windows. - type: string - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - seLinuxOptions: - description: |- - The SELinux context to be applied to all containers. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in SecurityContext. If set in - both SecurityContext and PodSecurityContext, the value specified in SecurityContext - takes precedence for that container. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - seccompProfile: - description: |- - The seccomp options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - supplementalGroups: - description: |- - A list of groups applied to the first process run in each container, in addition - to the container's primary GID, the fsGroup (if specified), and group memberships - defined in the container image for the uid of the container process. If unspecified, - no additional groups are added to any container. Note that group memberships - defined in the container image for the uid of the container process are still effective, - even if they are not included in this list. - Note that this field cannot be set when spec.os.name is windows. - type: array - items: - type: integer - format: int64 - x-kubernetes-list-type: atomic - sysctls: - description: |- - Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported - sysctls (by the container runtime) might fail to launch. - Note that this field cannot be set when spec.os.name is windows. - type: array - items: - description: Sysctl defines a kernel parameter to be set - type: object - required: - - name - - value - properties: - name: - description: Name of a property to set - type: string - value: - description: Value of a property to set - type: string - x-kubernetes-list-type: atomic - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options within a container's SecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - type: object - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - tailscaleContainer: - description: Configuration for the proxy container running tailscale. - type: object - properties: - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - type: array - items: - type: object - required: - - name - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - type: string - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - image: - description: |- - Container image name. By default images are pulled from - docker.io/tailscale/tailscale, but the official images are also - available at ghcr.io/tailscale/tailscale. Specifying image name here - will override any proxy image values specified via the Kubernetes - operator's Helm chart values or PROXY_IMAGE env var in the operator - Deployment. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - enum: - - Always - - Never - - IfNotPresent - resources: - description: |- - Container resource requirements. - By default Tailscale Kubernetes operator does not apply any resource - requirements. The amount of resources required wil depend on the - amount of resources the operator needs to parse, usage patterns and - cluster size. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - type: object - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - type: array - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - type: object - required: - - name - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - additionalProperties: - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - anyOf: - - type: integer - - type: string - x-kubernetes-int-or-string: true - requests: - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - additionalProperties: - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - anyOf: - - type: integer - - type: string - x-kubernetes-int-or-string: true - securityContext: - description: |- - Container security context. - Security context specified here will override the security context by the operator. - By default the operator: - - sets 'privileged: true' for the init container - - set NET_ADMIN capability for tailscale container for proxies that - are created for Services or Connector. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - type: object - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - add: - description: Added capabilities - type: array - items: - description: Capability represent POSIX capabilities type - type: string - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - type: array - items: - description: Capability represent POSIX capabilities type - type: string - x-kubernetes-list-type: atomic - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - type: object - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - tailscaleInitContainer: - description: Configuration for the proxy init container that enables forwarding. - type: object - properties: - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - type: array - items: - type: object - required: - - name - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - type: string - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - image: - description: |- - Container image name. By default images are pulled from - docker.io/tailscale/tailscale, but the official images are also - available at ghcr.io/tailscale/tailscale. Specifying image name here - will override any proxy image values specified via the Kubernetes - operator's Helm chart values or PROXY_IMAGE env var in the operator - Deployment. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - enum: - - Always - - Never - - IfNotPresent - resources: - description: |- - Container resource requirements. - By default Tailscale Kubernetes operator does not apply any resource - requirements. The amount of resources required wil depend on the - amount of resources the operator needs to parse, usage patterns and - cluster size. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - type: object - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - type: array - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - type: object - required: - - name - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - additionalProperties: - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - anyOf: - - type: integer - - type: string - x-kubernetes-int-or-string: true - requests: - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - additionalProperties: - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - anyOf: - - type: integer - - type: string - x-kubernetes-int-or-string: true - securityContext: - description: |- - Container security context. - Security context specified here will override the security context by the operator. - By default the operator: - - sets 'privileged: true' for the init container - - set NET_ADMIN capability for tailscale container for proxies that - are created for Services or Connector. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - type: object - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - add: - description: Added capabilities - type: array - items: - description: Capability represent POSIX capabilities type - type: string - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - type: array - items: - description: Capability represent POSIX capabilities type - type: string - x-kubernetes-list-type: atomic - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - type: object - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - tolerations: - description: |- - Proxy Pod's tolerations. - By default Tailscale Kubernetes operator does not apply any - tolerations. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: array - items: - description: |- - The pod this Toleration is attached to tolerates any taint that matches - the triple using the matching operator . - type: object - properties: - effect: - description: |- - Effect indicates the taint effect to match. Empty means match all taint effects. - When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. - type: string - key: - description: |- - Key is the taint key that the toleration applies to. Empty means match all taint keys. - If the key is empty, operator must be Exists; this combination means to match all values and all keys. - type: string - operator: - description: |- - Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. - Exists is equivalent to wildcard for value, so that a pod can - tolerate all taints of a particular category. - type: string - tolerationSeconds: - description: |- - TolerationSeconds represents the period of time the toleration (which must be - of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, - it is not set, which means tolerate the taint forever (do not evict). Zero and - negative values will be treated as 0 (evict immediately) by the system. - type: integer - format: int64 - value: - description: |- - Value is the taint value the toleration matches to. - If the operator is Exists, the value should be empty, otherwise just a regular string. - type: string - topologySpreadConstraints: - description: |- - Proxy Pod's topology spread constraints. - By default Tailscale Kubernetes operator does not apply any topology spread constraints. - https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ - type: array - items: - description: TopologySpreadConstraint specifies how to spread matching pods among the given topology. - type: object - required: - - maxSkew - - topologyKey - - whenUnsatisfiable - properties: - labelSelector: - description: |- - LabelSelector is used to find matching pods. - Pods that match this label selector are counted to determine the number of pods - in their corresponding topology domain. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select the pods over which - spreading will be calculated. The keys are used to lookup values from the - incoming pod labels, those key-value labels are ANDed with labelSelector - to select the group of existing pods over which spreading will be calculated - for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. - MatchLabelKeys cannot be set when LabelSelector isn't set. - Keys that don't exist in the incoming pod labels will - be ignored. A null or empty list means only match against labelSelector. - - This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). - type: array - items: - type: string - x-kubernetes-list-type: atomic - maxSkew: - description: |- - MaxSkew describes the degree to which pods may be unevenly distributed. - When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference - between the number of matching pods in the target topology and the global minimum. - The global minimum is the minimum number of matching pods in an eligible domain - or zero if the number of eligible domains is less than MinDomains. - For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same - labelSelector spread as 2/2/1: - In this case, the global minimum is 1. - | zone1 | zone2 | zone3 | - | P P | P P | P | - - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; - scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) - violate MaxSkew(1). - - if MaxSkew is 2, incoming pod can be scheduled onto any zone. - When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence - to topologies that satisfy it. - It's a required field. Default value is 1 and 0 is not allowed. - type: integer - format: int32 - minDomains: - description: |- - MinDomains indicates a minimum number of eligible domains. - When the number of eligible domains with matching topology keys is less than minDomains, - Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. - And when the number of eligible domains with matching topology keys equals or greater than minDomains, - this value has no effect on scheduling. - As a result, when the number of eligible domains is less than minDomains, - scheduler won't schedule more than maxSkew Pods to those domains. - If value is nil, the constraint behaves as if MinDomains is equal to 1. - Valid values are integers greater than 0. - When value is not nil, WhenUnsatisfiable must be DoNotSchedule. - - For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same - labelSelector spread as 2/2/2: - | zone1 | zone2 | zone3 | - | P P | P P | P P | - The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. - In this situation, new pod with the same labelSelector cannot be scheduled, - because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, - it will violate MaxSkew. - type: integer - format: int32 - nodeAffinityPolicy: - description: |- - NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector - when calculating pod topology spread skew. Options are: - - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. - - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. - - If this value is nil, the behavior is equivalent to the Honor policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. - type: string - nodeTaintsPolicy: - description: |- - NodeTaintsPolicy indicates how we will treat node taints when calculating - pod topology spread skew. Options are: - - Honor: nodes without taints, along with tainted nodes for which the incoming pod - has a toleration, are included. - - Ignore: node taints are ignored. All nodes are included. - - If this value is nil, the behavior is equivalent to the Ignore policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. - type: string - topologyKey: - description: |- - TopologyKey is the key of node labels. Nodes that have a label with this key - and identical values are considered to be in the same topology. - We consider each as a "bucket", and try to put balanced number - of pods into each bucket. - We define a domain as a particular instance of a topology. - Also, we define an eligible domain as a domain whose nodes meet the requirements of - nodeAffinityPolicy and nodeTaintsPolicy. - e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. - And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. - It's a required field. - type: string - whenUnsatisfiable: - description: |- - WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy - the spread constraint. - - DoNotSchedule (default) tells the scheduler not to schedule it. - - ScheduleAnyway tells the scheduler to schedule the pod in any location, - but giving higher precedence to topologies that would help reduce the - skew. - A constraint is considered "Unsatisfiable" for an incoming pod - if and only if every possible node assignment for that pod would violate - "MaxSkew" on some topology. - For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same - labelSelector spread as 3/1/1: - | zone1 | zone2 | zone3 | - | P P P | P | P | - If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled - to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies - MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler - won't make it *more* imbalanced. - It's a required field. - type: string - tailscale: - description: |- - TailscaleConfig contains options to configure the tailscale-specific - parameters of proxies. - type: object - properties: - acceptRoutes: - description: |- - AcceptRoutes can be set to true to make the proxy instance accept - routes advertized by other nodes on the tailnet, such as subnet - routes. - This is equivalent of passing --accept-routes flag to a tailscale Linux client. - https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices - Defaults to false. - type: boolean - status: - description: |- - Status of the ProxyClass. This is set and managed automatically. - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - type: object - properties: - conditions: - description: |- - List of status conditions to indicate the status of the ProxyClass. - Known condition types are `ProxyClassReady`. - type: array - items: - description: Condition contains details for one aspect of the current state of this API Resource. - type: object - required: - - lastTransitionTime - - message - - reason - - status - - type - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - type: string - format: date-time - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - type: string - maxLength: 32768 - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - type: integer - format: int64 - minimum: 0 - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - type: string - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - status: - description: status of the condition, one of True, False, Unknown. - type: string - enum: - - "True" - - "False" - - Unknown - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - type: string - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - served: true - storage: true - subresources: - status: {} diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml deleted file mode 100644 index 66701bdf4afbd..0000000000000 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml +++ /dev/null @@ -1,187 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: proxygroups.tailscale.com -spec: - group: tailscale.com - names: - kind: ProxyGroup - listKind: ProxyGroupList - plural: proxygroups - shortNames: - - pg - singular: proxygroup - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the deployed ProxyGroup resources. - jsonPath: .status.conditions[?(@.type == "ProxyGroupReady")].reason - name: Status - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - type: object - required: - - spec - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec describes the desired ProxyGroup instances. - type: object - required: - - type - properties: - hostnamePrefix: - description: |- - HostnamePrefix is the hostname prefix to use for tailnet devices created - by the ProxyGroup. Each device will have the integer number from its - StatefulSet pod appended to this prefix to form the full hostname. - HostnamePrefix can contain lower case letters, numbers and dashes, it - must not start with a dash and must be between 1 and 62 characters long. - type: string - pattern: ^[a-z0-9][a-z0-9-]{0,61}$ - proxyClass: - description: |- - ProxyClass is the name of the ProxyClass custom resource that contains - configuration options that should be applied to the resources created - for this ProxyGroup. If unset, and there is no default ProxyClass - configured, the operator will create resources with the default - configuration. - type: string - replicas: - description: |- - Replicas specifies how many replicas to create the StatefulSet with. - Defaults to 2. - type: integer - format: int32 - tags: - description: |- - Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s]. - If you specify custom tags here, make sure you also make the operator - an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a ProxyGroup device has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - type: array - items: - type: string - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - type: - description: Type of the ProxyGroup proxies. Currently the only supported type is egress. - type: string - enum: - - egress - status: - description: |- - ProxyGroupStatus describes the status of the ProxyGroup resources. This is - set and managed by the Tailscale operator. - type: object - properties: - conditions: - description: |- - List of status conditions to indicate the status of the ProxyGroup - resources. Known condition types are `ProxyGroupReady`. - type: array - items: - description: Condition contains details for one aspect of the current state of this API Resource. - type: object - required: - - lastTransitionTime - - message - - reason - - status - - type - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - type: string - format: date-time - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - type: string - maxLength: 32768 - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - type: integer - format: int64 - minimum: 0 - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - type: string - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - status: - description: status of the condition, one of True, False, Unknown. - type: string - enum: - - "True" - - "False" - - Unknown - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - type: string - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - devices: - description: List of tailnet devices associated with the ProxyGroup StatefulSet. - type: array - items: - type: object - required: - - hostname - properties: - hostname: - description: |- - Hostname is the fully qualified domain name of the device. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the device. - type: array - items: - type: string - x-kubernetes-list-map-keys: - - hostname - x-kubernetes-list-type: map - served: true - storage: true - subresources: - status: {} diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_recorders.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_recorders.yaml deleted file mode 100644 index fda8bcebdbe53..0000000000000 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_recorders.yaml +++ /dev/null @@ -1,1705 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: recorders.tailscale.com -spec: - group: tailscale.com - names: - kind: Recorder - listKind: RecorderList - plural: recorders - shortNames: - - rec - singular: recorder - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the deployed Recorder resources. - jsonPath: .status.conditions[?(@.type == "RecorderReady")].reason - name: Status - type: string - - description: URL on which the UI is exposed if enabled. - jsonPath: .status.devices[?(@.url != "")].url - name: URL - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - type: object - required: - - spec - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec describes the desired recorder instance. - type: object - properties: - enableUI: - description: |- - Set to true to enable the Recorder UI. The UI lists and plays recorded sessions. - The UI will be served at :443. Defaults to false. - Corresponds to --ui tsrecorder flag https://tailscale.com/kb/1246/tailscale-ssh-session-recording#deploy-a-recorder-node. - Required if S3 storage is not set up, to ensure that recordings are accessible. - type: boolean - statefulSet: - description: |- - Configuration parameters for the Recorder's StatefulSet. The operator - deploys a StatefulSet for each Recorder resource. - type: object - properties: - annotations: - description: |- - Annotations that will be added to the StatefulSet created for the Recorder. - Any Annotations specified here will be merged with the default annotations - applied to the StatefulSet by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - additionalProperties: - type: string - labels: - description: |- - Labels that will be added to the StatefulSet created for the Recorder. - Any labels specified here will be merged with the default labels applied - to the StatefulSet by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - additionalProperties: - type: string - pod: - description: Configuration for pods created by the Recorder's StatefulSet. - type: object - properties: - affinity: - description: |- - Affinity rules for Recorder Pods. By default, the operator does not - apply any affinity rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - type: object - properties: - nodeAffinity: - description: Describes node affinity scheduling rules for the pod. - type: object - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node matches the corresponding matchExpressions; the - node(s) with the highest sum are the most preferred. - type: array - items: - description: |- - An empty preferred scheduling term matches all objects with implicit weight 0 - (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). - type: object - required: - - preference - - weight - properties: - preference: - description: A node selector term, associated with the corresponding weight. - type: object - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - x-kubernetes-map-type: atomic - weight: - description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. - type: integer - format: int32 - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to an update), the system - may or may not try to eventually evict the pod from its node. - type: object - required: - - nodeSelectorTerms - properties: - nodeSelectorTerms: - description: Required. A list of node selector terms. The terms are ORed. - type: array - items: - description: |- - A null or empty node selector term matches no objects. The requirements of - them are ANDed. - The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. - type: object - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - type: array - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - x-kubernetes-map-type: atomic - x-kubernetes-list-type: atomic - x-kubernetes-map-type: atomic - podAffinity: - description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). - type: object - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - type: array - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - type: object - required: - - podAffinityTerm - - weight - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - type: integer - format: int32 - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - type: array - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - x-kubernetes-list-type: atomic - podAntiAffinity: - description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). - type: object - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the anti-affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - type: array - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - type: object - required: - - podAffinityTerm - - weight - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - type: integer - format: int32 - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the anti-affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the anti-affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - type: array - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - type: object - required: - - topologyKey - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - type: array - items: - type: string - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - type: object - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - type: array - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - type: object - required: - - key - - operator - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - type: array - items: - type: string - x-kubernetes-list-type: atomic - x-kubernetes-list-type: atomic - matchLabels: - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - additionalProperties: - type: string - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - type: array - items: - type: string - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - x-kubernetes-list-type: atomic - annotations: - description: |- - Annotations that will be added to Recorder Pods. Any annotations - specified here will be merged with the default annotations applied to - the Pod by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - additionalProperties: - type: string - container: - description: Configuration for the Recorder container running tailscale. - type: object - properties: - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - type: array - items: - type: object - required: - - name - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - type: string - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - image: - description: |- - Container image name including tag. Defaults to docker.io/tailscale/tsrecorder - with the same tag as the operator, but the official images are also - available at ghcr.io/tailscale/tsrecorder. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - enum: - - Always - - Never - - IfNotPresent - resources: - description: |- - Container resource requirements. - By default, the operator does not apply any resource requirements. The - amount of resources required wil depend on the volume of recordings sent. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - type: object - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - type: array - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - type: object - required: - - name - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - additionalProperties: - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - anyOf: - - type: integer - - type: string - x-kubernetes-int-or-string: true - requests: - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - additionalProperties: - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - anyOf: - - type: integer - - type: string - x-kubernetes-int-or-string: true - securityContext: - description: |- - Container security context. By default, the operator does not apply any - container security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - type: object - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - add: - description: Added capabilities - type: array - items: - description: Capability represent POSIX capabilities type - type: string - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - type: array - items: - description: Capability represent POSIX capabilities type - type: string - x-kubernetes-list-type: atomic - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - type: object - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - imagePullSecrets: - description: |- - Image pull Secrets for Recorder Pods. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - type: array - items: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - type: object - properties: - name: - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - default: "" - x-kubernetes-map-type: atomic - labels: - description: |- - Labels that will be added to Recorder Pods. Any labels specified here - will be merged with the default labels applied to the Pod by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - additionalProperties: - type: string - nodeSelector: - description: |- - Node selector rules for Recorder Pods. By default, the operator does - not apply any node selector rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: object - additionalProperties: - type: string - securityContext: - description: |- - Security context for Recorder Pods. By default, the operator does not - apply any Pod security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - type: object - properties: - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - fsGroup: - description: |- - A special supplemental group that applies to all containers in a pod. - Some volume types allow the Kubelet to change the ownership of that volume - to be owned by the pod: - - 1. The owning GID will be the FSGroup - 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) - 3. The permission bits are OR'd with rw-rw---- - - If unset, the Kubelet will not modify the ownership and permissions of any volume. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - fsGroupChangePolicy: - description: |- - fsGroupChangePolicy defines behavior of changing ownership and permission of the volume - before being exposed inside Pod. This field will only apply to - volume types which support fsGroup based ownership(and permissions). - It will have no effect on ephemeral volume types such as: secret, configmaps - and emptydir. - Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. - Note that this field cannot be set when spec.os.name is windows. - type: string - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - type: integer - format: int64 - seLinuxOptions: - description: |- - The SELinux context to be applied to all containers. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in SecurityContext. If set in - both SecurityContext and PodSecurityContext, the value specified in SecurityContext - takes precedence for that container. - Note that this field cannot be set when spec.os.name is windows. - type: object - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - seccompProfile: - description: |- - The seccomp options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - type: object - required: - - type - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - supplementalGroups: - description: |- - A list of groups applied to the first process run in each container, in addition - to the container's primary GID, the fsGroup (if specified), and group memberships - defined in the container image for the uid of the container process. If unspecified, - no additional groups are added to any container. Note that group memberships - defined in the container image for the uid of the container process are still effective, - even if they are not included in this list. - Note that this field cannot be set when spec.os.name is windows. - type: array - items: - type: integer - format: int64 - x-kubernetes-list-type: atomic - sysctls: - description: |- - Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported - sysctls (by the container runtime) might fail to launch. - Note that this field cannot be set when spec.os.name is windows. - type: array - items: - description: Sysctl defines a kernel parameter to be set - type: object - required: - - name - - value - properties: - name: - description: Name of a property to set - type: string - value: - description: Value of a property to set - type: string - x-kubernetes-list-type: atomic - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options within a container's SecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - type: object - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - tolerations: - description: |- - Tolerations for Recorder Pods. By default, the operator does not apply - any tolerations. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: array - items: - description: |- - The pod this Toleration is attached to tolerates any taint that matches - the triple using the matching operator . - type: object - properties: - effect: - description: |- - Effect indicates the taint effect to match. Empty means match all taint effects. - When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. - type: string - key: - description: |- - Key is the taint key that the toleration applies to. Empty means match all taint keys. - If the key is empty, operator must be Exists; this combination means to match all values and all keys. - type: string - operator: - description: |- - Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. - Exists is equivalent to wildcard for value, so that a pod can - tolerate all taints of a particular category. - type: string - tolerationSeconds: - description: |- - TolerationSeconds represents the period of time the toleration (which must be - of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, - it is not set, which means tolerate the taint forever (do not evict). Zero and - negative values will be treated as 0 (evict immediately) by the system. - type: integer - format: int64 - value: - description: |- - Value is the taint value the toleration matches to. - If the operator is Exists, the value should be empty, otherwise just a regular string. - type: string - storage: - description: |- - Configure where to store session recordings. By default, recordings will - be stored in a local ephemeral volume, and will not be persisted past the - lifetime of a specific pod. - type: object - properties: - s3: - description: |- - Configure an S3-compatible API for storage. Required if the UI is not - enabled, to ensure that recordings are accessible. - type: object - properties: - bucket: - description: |- - Bucket name to write to. The bucket is expected to be used solely for - recordings, as there is no stable prefix for written object names. - type: string - credentials: - description: |- - Configure environment variable credentials for managing objects in the - configured bucket. If not set, tsrecorder will try to acquire credentials - first from the file system and then the STS API. - type: object - properties: - secret: - description: |- - Use a Kubernetes Secret from the operator's namespace as the source of - credentials. - type: object - properties: - name: - description: |- - The name of a Kubernetes Secret in the operator's namespace that contains - credentials for writing to the configured bucket. Each key-value pair - from the secret's data will be mounted as an environment variable. It - should include keys for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY if - using a static access key. - type: string - endpoint: - description: S3-compatible endpoint, e.g. s3.us-east-1.amazonaws.com. - type: string - tags: - description: |- - Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s]. - If you specify custom tags here, make sure you also make the operator - an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a Recorder node has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - type: array - items: - type: string - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - status: - description: |- - RecorderStatus describes the status of the recorder. This is set - and managed by the Tailscale operator. - type: object - properties: - conditions: - description: |- - List of status conditions to indicate the status of the Recorder. - Known condition types are `RecorderReady`. - type: array - items: - description: Condition contains details for one aspect of the current state of this API Resource. - type: object - required: - - lastTransitionTime - - message - - reason - - status - - type - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - type: string - format: date-time - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - type: string - maxLength: 32768 - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - type: integer - format: int64 - minimum: 0 - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - type: string - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - status: - description: status of the condition, one of True, False, Unknown. - type: string - enum: - - "True" - - "False" - - Unknown - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - type: string - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - devices: - description: List of tailnet devices associated with the Recorder StatefulSet. - type: array - items: - type: object - required: - - hostname - properties: - hostname: - description: |- - Hostname is the fully qualified domain name of the device. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the device. - type: array - items: - type: string - url: - description: |- - URL where the UI is available if enabled for replaying recordings. This - will be an HTTPS MagicDNS URL. You must be connected to the same tailnet - as the recorder to access it. - type: string - x-kubernetes-list-map-keys: - - hostname - x-kubernetes-list-type: map - served: true - storage: true - subresources: - status: {} diff --git a/cmd/k8s-operator/deploy/examples/connector.yaml b/cmd/k8s-operator/deploy/examples/connector.yaml deleted file mode 100644 index d29f27cf51c98..0000000000000 --- a/cmd/k8s-operator/deploy/examples/connector.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Before applying ensure that the operator owns tag:prod. -# https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. -# To set up autoapproval set tag:prod as approver for 10.40.0.0/14 route and exit node. -# Otherwise approve it manually in Machines panel once the -# ts-prod Tailscale node has been created. -# See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes -apiVersion: tailscale.com/v1alpha1 -kind: Connector -metadata: - name: prod -spec: - tags: - - "tag:prod" - hostname: ts-prod - subnetRouter: - advertiseRoutes: - - "10.40.0.0/14" - - "192.168.0.0/14" - exitNode: true diff --git a/cmd/k8s-operator/deploy/examples/dnsconfig.yaml b/cmd/k8s-operator/deploy/examples/dnsconfig.yaml deleted file mode 100644 index ca9bf0f243312..0000000000000 --- a/cmd/k8s-operator/deploy/examples/dnsconfig.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: tailscale.com/v1alpha1 -kind: DNSConfig -metadata: - name: ts-dns -spec: - nameserver: {} diff --git a/cmd/k8s-operator/deploy/examples/proxyclass.yaml b/cmd/k8s-operator/deploy/examples/proxyclass.yaml deleted file mode 100644 index b36e9ac1db9be..0000000000000 --- a/cmd/k8s-operator/deploy/examples/proxyclass.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: tailscale.com/v1alpha1 -kind: ProxyClass -metadata: - name: prod -spec: - metrics: - enable: true - statefulSet: - annotations: - platform-component: infra - pod: - labels: - team: eng - nodeSelector: - kubernetes.io/os: "linux" - imagePullSecrets: - - name: "foo" - tailscaleContainer: - image: "ghcr.io/tailscale/tailscale:v1.64" - imagePullPolicy: IfNotPresent - tailscaleInitContainer: - image: "ghcr.io/tailscale/tailscale:v1.64" - imagePullPolicy: IfNotPresent diff --git a/cmd/k8s-operator/deploy/examples/proxygroup.yaml b/cmd/k8s-operator/deploy/examples/proxygroup.yaml deleted file mode 100644 index 337d87f0b7e80..0000000000000 --- a/cmd/k8s-operator/deploy/examples/proxygroup.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: tailscale.com/v1alpha1 -kind: ProxyGroup -metadata: - name: egress-proxies -spec: - type: egress - replicas: 3 diff --git a/cmd/k8s-operator/deploy/examples/recorder.yaml b/cmd/k8s-operator/deploy/examples/recorder.yaml deleted file mode 100644 index 24d24323b02c6..0000000000000 --- a/cmd/k8s-operator/deploy/examples/recorder.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: tailscale.com/v1alpha1 -kind: Recorder -metadata: - name: recorder -spec: - enableUI: true diff --git a/cmd/k8s-operator/deploy/manifests/authproxy-rbac.yaml b/cmd/k8s-operator/deploy/manifests/authproxy-rbac.yaml deleted file mode 100644 index ddbdda32e476e..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/authproxy-rbac.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: tailscale-auth-proxy -rules: -- apiGroups: [""] - resources: ["users", "groups"] - verbs: ["impersonate"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: tailscale-auth-proxy -subjects: -- kind: ServiceAccount - name: operator - namespace: tailscale -roleRef: - kind: ClusterRole - name: tailscale-auth-proxy - apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml deleted file mode 100644 index 43bc5d0d598ba..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: dnsrecords diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml deleted file mode 100644 index c3a16e03e9a42..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml +++ /dev/null @@ -1,37 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nameserver -spec: - replicas: 1 - revisionHistoryLimit: 5 - selector: - matchLabels: - app: nameserver - strategy: - type: Recreate - template: - metadata: - labels: - app: nameserver - spec: - containers: - - imagePullPolicy: IfNotPresent - name: nameserver - ports: - - name: tcp - protocol: TCP - containerPort: 1053 - - name: udp - protocol: UDP - containerPort: 1053 - volumeMounts: - - name: dnsrecords - mountPath: /config - restartPolicy: Always - serviceAccount: nameserver - serviceAccountName: nameserver - volumes: - - name: dnsrecords - configMap: - name: dnsrecords diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml deleted file mode 100644 index 96edece2c3ca4..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: nameserver diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml deleted file mode 100644 index 08a90c176476f..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: nameserver -spec: - selector: - app: nameserver - ports: - - name: udp - targetPort: 1053 - port: 53 - protocol: UDP - - name: tcp - targetPort: 1053 - port: 53 - protocol: TCP diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml deleted file mode 100644 index 4035afabaf4ab..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/operator.yaml +++ /dev/null @@ -1,4815 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -apiVersion: v1 -kind: Namespace -metadata: - name: tailscale ---- -apiVersion: v1 -kind: Secret -metadata: - name: operator-oauth - namespace: tailscale -stringData: - client_id: # SET CLIENT ID HERE - client_secret: # SET CLIENT SECRET HERE ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: operator - namespace: tailscale ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: proxies - namespace: tailscale ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: connectors.tailscale.com -spec: - group: tailscale.com - names: - kind: Connector - listKind: ConnectorList - plural: connectors - shortNames: - - cn - singular: connector - scope: Cluster - versions: - - additionalPrinterColumns: - - description: CIDR ranges exposed to tailnet by a subnet router defined via this Connector instance. - jsonPath: .status.subnetRoutes - name: SubnetRoutes - type: string - - description: Whether this Connector instance defines an exit node. - jsonPath: .status.isExitNode - name: IsExitNode - type: string - - description: Whether this Connector instance is an app connector. - jsonPath: .status.isAppConnector - name: IsAppConnector - type: string - - description: Status of the deployed Connector resources. - jsonPath: .status.conditions[?(@.type == "ConnectorReady")].reason - name: Status - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - Connector defines a Tailscale node that will be deployed in the cluster. The - node can be configured to act as a Tailscale subnet router and/or a Tailscale - exit node. - Connector is a cluster-scoped resource. - More info: - https://tailscale.com/kb/1441/kubernetes-operator-connector - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - ConnectorSpec describes the desired Tailscale component. - More info: - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - appConnector: - description: |- - AppConnector defines whether the Connector device should act as a Tailscale app connector. A Connector that is - configured as an app connector cannot be a subnet router or an exit node. If this field is unset, the - Connector does not act as an app connector. - Note that you will need to manually configure the permissions and the domains for the app connector via the - Admin panel. - Note also that the main tested and supported use case of this config option is to deploy an app connector on - Kubernetes to access SaaS applications available on the public internet. Using the app connector to expose - cluster workloads or other internal workloads to tailnet might work, but this is not a use case that we have - tested or optimised for. - If you are using the app connector to access SaaS applications because you need a predictable egress IP that - can be whitelisted, it is also your responsibility to ensure that cluster traffic from the connector flows - via that predictable IP, for example by enforcing that cluster egress traffic is routed via an egress NAT - device with a static IP address. - https://tailscale.com/kb/1281/app-connectors - properties: - routes: - description: |- - Routes are optional preconfigured routes for the domains routed via the app connector. - If not set, routes for the domains will be discovered dynamically. - If set, the app connector will immediately be able to route traffic using the preconfigured routes, but may - also dynamically discover other routes. - https://tailscale.com/kb/1332/apps-best-practices#preconfiguration - items: - format: cidr - type: string - minItems: 1 - type: array - type: object - exitNode: - description: |- - ExitNode defines whether the Connector device should act as a Tailscale exit node. Defaults to false. - This field is mutually exclusive with the appConnector field. - https://tailscale.com/kb/1103/exit-nodes - type: boolean - hostname: - description: |- - Hostname is the tailnet hostname that should be assigned to the - Connector node. If unset, hostname defaults to -connector. Hostname can contain lower case letters, numbers and - dashes, it must not start or end with a dash and must be between 2 - and 63 characters long. - pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$ - type: string - proxyClass: - description: |- - ProxyClass is the name of the ProxyClass custom resource that - contains configuration options that should be applied to the - resources created for this Connector. If unset, the operator will - create resources with the default configuration. - type: string - subnetRouter: - description: |- - SubnetRouter defines subnet routes that the Connector device should - expose to tailnet as a Tailscale subnet router. - https://tailscale.com/kb/1019/subnets/ - If this field is unset, the device does not get configured as a Tailscale subnet router. - This field is mutually exclusive with the appConnector field. - properties: - advertiseRoutes: - description: |- - AdvertiseRoutes refer to CIDRs that the subnet router should make - available. Route values must be strings that represent a valid IPv4 - or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. - https://tailscale.com/kb/1201/4via6-subnets/ - items: - format: cidr - type: string - minItems: 1 - type: array - required: - - advertiseRoutes - type: object - tags: - description: |- - Tags that the Tailscale node will be tagged with. - Defaults to [tag:k8s]. - To autoapprove the subnet routes or exit node defined by a Connector, - you can configure Tailscale ACLs to give these tags the necessary - permissions. - See https://tailscale.com/kb/1337/acl-syntax#autoapprovers. - If you specify custom tags here, you must also make the operator an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a Connector node has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - items: - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - type: string - type: array - type: object - x-kubernetes-validations: - - message: A Connector needs to have at least one of exit node, subnet router or app connector configured. - rule: has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true) || has(self.appConnector) - - message: The appConnector field is mutually exclusive with exitNode and subnetRouter fields. - rule: '!((has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true)) && has(self.appConnector))' - status: - description: |- - ConnectorStatus describes the status of the Connector. This is set - and managed by the Tailscale operator. - properties: - conditions: - description: |- - List of status conditions to indicate the status of the Connector. - Known condition types are `ConnectorReady`. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - hostname: - description: |- - Hostname is the fully qualified domain name of the Connector node. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - isAppConnector: - description: IsAppConnector is set to true if the Connector acts as an app connector. - type: boolean - isExitNode: - description: IsExitNode is set to true if the Connector acts as an exit node. - type: boolean - subnetRoutes: - description: |- - SubnetRoutes are the routes currently exposed to tailnet via this - Connector instance. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the Connector node. - items: - type: string - type: array - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: dnsconfigs.tailscale.com -spec: - group: tailscale.com - names: - kind: DNSConfig - listKind: DNSConfigList - plural: dnsconfigs - shortNames: - - dc - singular: dnsconfig - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Service IP address of the nameserver - jsonPath: .status.nameserver.ip - name: NameserverIP - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS - names resolvable by cluster workloads. Use this if: A) you need to refer to - tailnet services, exposed to cluster via Tailscale Kubernetes operator egress - proxies by the MagicDNS names of those tailnet services (usually because the - services run over HTTPS) - B) you have exposed a cluster workload to the tailnet using Tailscale Ingress - and you also want to refer to the workload from within the cluster over the - Ingress's MagicDNS name (usually because you have some callback component - that needs to use the same URL as that used by a non-cluster client on - tailnet). - When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will - deploy a nameserver for ts.net DNS names and automatically populate it with records - for any Tailscale egress or Ingress proxies deployed to that cluster. - Currently you must manually update your cluster DNS configuration to add the - IP address of the deployed nameserver as a ts.net stub nameserver. - Instructions for how to do it: - https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), - https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). - Tailscale Kubernetes operator will write the address of a Service fronting - the nameserver to dsnconfig.status.nameserver.ip. - DNSConfig is a singleton - you must not create more than one. - NB: if you want cluster workloads to be able to refer to Tailscale Ingress - using its MagicDNS name, you must also annotate the Ingress resource with - tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to - ensure that the proxy created for the Ingress listens on its Pod IP address. - NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - Spec describes the desired DNS configuration. - More info: - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - nameserver: - description: |- - Configuration for a nameserver that can resolve ts.net DNS names - associated with in-cluster proxies for Tailscale egress Services and - Tailscale Ingresses. The operator will always deploy this nameserver - when a DNSConfig is applied. - properties: - image: - description: Nameserver image. Defaults to tailscale/k8s-nameserver:unstable. - properties: - repo: - description: Repo defaults to tailscale/k8s-nameserver. - type: string - tag: - description: Tag defaults to unstable. - type: string - type: object - type: object - required: - - nameserver - type: object - status: - description: |- - Status describes the status of the DNSConfig. This is set - and managed by the Tailscale operator. - properties: - conditions: - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - nameserver: - description: Nameserver describes the status of nameserver cluster resources. - properties: - ip: - description: |- - IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. - Currently you must manually update your cluster DNS config to add - this address as a stub nameserver for ts.net for cluster workloads to be - able to resolve MagicDNS names associated with egress or Ingress - proxies. - The IP address will change if you delete and recreate the DNSConfig. - type: string - type: object - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: proxyclasses.tailscale.com -spec: - group: tailscale.com - names: - kind: ProxyClass - listKind: ProxyClassList - plural: proxyclasses - singular: proxyclass - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the ProxyClass. - jsonPath: .status.conditions[?(@.type == "ProxyClassReady")].reason - name: Status - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - ProxyClass describes a set of configuration parameters that can be applied to - proxy resources created by the Tailscale Kubernetes operator. - To apply a given ProxyClass to resources created for a tailscale Ingress or - Service, use tailscale.com/proxy-class= label. To apply a - given ProxyClass to resources created for a Connector, use - connector.spec.proxyClass field. - ProxyClass is a cluster scoped resource. - More info: - https://tailscale.com/kb/1445/kubernetes-operator-customization#cluster-resource-customization-using-proxyclass-custom-resource - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - Specification of the desired state of the ProxyClass resource. - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - metrics: - description: |- - Configuration for proxy metrics. Metrics are currently not supported - for egress proxies and for Ingress proxies that have been configured - with tailscale.com/experimental-forward-cluster-traffic-via-ingress - annotation. Note that the metrics are currently considered unstable - and will likely change in breaking ways in the future - we only - recommend that you use those for debugging purposes. - properties: - enable: - description: |- - Setting enable to true will make the proxy serve Tailscale metrics - at :9001/debug/metrics. - Defaults to false. - type: boolean - required: - - enable - type: object - statefulSet: - description: |- - Configuration parameters for the proxy's StatefulSet. Tailscale - Kubernetes operator deploys a StatefulSet for each of the user - configured proxies (Tailscale Ingress, Tailscale Service, Connector). - properties: - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to the StatefulSet created for the proxy. - Any Annotations specified here will be merged with the default annotations - applied to the StatefulSet by the Tailscale Kubernetes operator as - well as any other annotations that might have been applied by other - actors. - Annotations must be valid Kubernetes annotations. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - labels: - additionalProperties: - type: string - description: |- - Labels that will be added to the StatefulSet created for the proxy. - Any labels specified here will be merged with the default labels - applied to the StatefulSet by the Tailscale Kubernetes operator as - well as any other labels that might have been applied by other - actors. - Label keys and values must be valid Kubernetes label keys and values. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - pod: - description: Configuration for the proxy Pod. - properties: - affinity: - description: |- - Proxy Pod's affinity rules. - By default, the Tailscale Kubernetes operator does not apply any affinity rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - properties: - nodeAffinity: - description: Describes node affinity scheduling rules for the pod. - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node matches the corresponding matchExpressions; the - node(s) with the highest sum are the most preferred. - items: - description: |- - An empty preferred scheduling term matches all objects with implicit weight 0 - (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). - properties: - preference: - description: A node selector term, associated with the corresponding weight. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - weight: - description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. - format: int32 - type: integer - required: - - preference - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to an update), the system - may or may not try to eventually evict the pod from its node. - properties: - nodeSelectorTerms: - description: Required. A list of node selector terms. The terms are ORed. - items: - description: |- - A null or empty node selector term matches no objects. The requirements of - them are ANDed. - The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - type: array - x-kubernetes-list-type: atomic - required: - - nodeSelectorTerms - type: object - x-kubernetes-map-type: atomic - type: object - podAffinity: - description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - podAntiAffinity: - description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the anti-affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the anti-affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the anti-affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - type: object - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to the proxy Pod. - Any annotations specified here will be merged with the default - annotations applied to the Pod by the Tailscale Kubernetes operator. - Annotations must be valid Kubernetes annotations. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - imagePullSecrets: - description: |- - Proxy Pod's image pull Secrets. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - items: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - type: array - labels: - additionalProperties: - type: string - description: |- - Labels that will be added to the proxy Pod. - Any labels specified here will be merged with the default labels - applied to the Pod by the Tailscale Kubernetes operator. - Label keys and values must be valid Kubernetes label keys and values. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - nodeName: - description: |- - Proxy Pod's node name. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: string - nodeSelector: - additionalProperties: - type: string - description: |- - Proxy Pod's node selector. - By default Tailscale Kubernetes operator does not apply any node - selector. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: object - securityContext: - description: |- - Proxy Pod's security context. - By default Tailscale Kubernetes operator does not apply any Pod - security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - properties: - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - fsGroup: - description: |- - A special supplemental group that applies to all containers in a pod. - Some volume types allow the Kubelet to change the ownership of that volume - to be owned by the pod: - - 1. The owning GID will be the FSGroup - 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) - 3. The permission bits are OR'd with rw-rw---- - - If unset, the Kubelet will not modify the ownership and permissions of any volume. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - fsGroupChangePolicy: - description: |- - fsGroupChangePolicy defines behavior of changing ownership and permission of the volume - before being exposed inside Pod. This field will only apply to - volume types which support fsGroup based ownership(and permissions). - It will have no effect on ephemeral volume types such as: secret, configmaps - and emptydir. - Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. - Note that this field cannot be set when spec.os.name is windows. - type: string - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to all containers. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in SecurityContext. If set in - both SecurityContext and PodSecurityContext, the value specified in SecurityContext - takes precedence for that container. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - supplementalGroups: - description: |- - A list of groups applied to the first process run in each container, in addition - to the container's primary GID, the fsGroup (if specified), and group memberships - defined in the container image for the uid of the container process. If unspecified, - no additional groups are added to any container. Note that group memberships - defined in the container image for the uid of the container process are still effective, - even if they are not included in this list. - Note that this field cannot be set when spec.os.name is windows. - items: - format: int64 - type: integer - type: array - x-kubernetes-list-type: atomic - sysctls: - description: |- - Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported - sysctls (by the container runtime) might fail to launch. - Note that this field cannot be set when spec.os.name is windows. - items: - description: Sysctl defines a kernel parameter to be set - properties: - name: - description: Name of a property to set - type: string - value: - description: Value of a property to set - type: string - required: - - name - - value - type: object - type: array - x-kubernetes-list-type: atomic - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options within a container's SecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - tailscaleContainer: - description: Configuration for the proxy container running tailscale. - properties: - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - items: - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - required: - - name - type: object - type: array - image: - description: |- - Container image name. By default images are pulled from - docker.io/tailscale/tailscale, but the official images are also - available at ghcr.io/tailscale/tailscale. Specifying image name here - will override any proxy image values specified via the Kubernetes - operator's Helm chart values or PROXY_IMAGE env var in the operator - Deployment. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - enum: - - Always - - Never - - IfNotPresent - type: string - resources: - description: |- - Container resource requirements. - By default Tailscale Kubernetes operator does not apply any resource - requirements. The amount of resources required wil depend on the - amount of resources the operator needs to parse, usage patterns and - cluster size. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - securityContext: - description: |- - Container security context. - Security context specified here will override the security context by the operator. - By default the operator: - - sets 'privileged: true' for the init container - - set NET_ADMIN capability for tailscale container for proxies that - are created for Services or Connector. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - properties: - add: - description: Added capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - type: object - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - type: object - tailscaleInitContainer: - description: Configuration for the proxy init container that enables forwarding. - properties: - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - items: - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - required: - - name - type: object - type: array - image: - description: |- - Container image name. By default images are pulled from - docker.io/tailscale/tailscale, but the official images are also - available at ghcr.io/tailscale/tailscale. Specifying image name here - will override any proxy image values specified via the Kubernetes - operator's Helm chart values or PROXY_IMAGE env var in the operator - Deployment. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - enum: - - Always - - Never - - IfNotPresent - type: string - resources: - description: |- - Container resource requirements. - By default Tailscale Kubernetes operator does not apply any resource - requirements. The amount of resources required wil depend on the - amount of resources the operator needs to parse, usage patterns and - cluster size. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - securityContext: - description: |- - Container security context. - Security context specified here will override the security context by the operator. - By default the operator: - - sets 'privileged: true' for the init container - - set NET_ADMIN capability for tailscale container for proxies that - are created for Services or Connector. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - properties: - add: - description: Added capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - type: object - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - type: object - tolerations: - description: |- - Proxy Pod's tolerations. - By default Tailscale Kubernetes operator does not apply any - tolerations. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - items: - description: |- - The pod this Toleration is attached to tolerates any taint that matches - the triple using the matching operator . - properties: - effect: - description: |- - Effect indicates the taint effect to match. Empty means match all taint effects. - When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. - type: string - key: - description: |- - Key is the taint key that the toleration applies to. Empty means match all taint keys. - If the key is empty, operator must be Exists; this combination means to match all values and all keys. - type: string - operator: - description: |- - Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. - Exists is equivalent to wildcard for value, so that a pod can - tolerate all taints of a particular category. - type: string - tolerationSeconds: - description: |- - TolerationSeconds represents the period of time the toleration (which must be - of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, - it is not set, which means tolerate the taint forever (do not evict). Zero and - negative values will be treated as 0 (evict immediately) by the system. - format: int64 - type: integer - value: - description: |- - Value is the taint value the toleration matches to. - If the operator is Exists, the value should be empty, otherwise just a regular string. - type: string - type: object - type: array - topologySpreadConstraints: - description: |- - Proxy Pod's topology spread constraints. - By default Tailscale Kubernetes operator does not apply any topology spread constraints. - https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ - items: - description: TopologySpreadConstraint specifies how to spread matching pods among the given topology. - properties: - labelSelector: - description: |- - LabelSelector is used to find matching pods. - Pods that match this label selector are counted to determine the number of pods - in their corresponding topology domain. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select the pods over which - spreading will be calculated. The keys are used to lookup values from the - incoming pod labels, those key-value labels are ANDed with labelSelector - to select the group of existing pods over which spreading will be calculated - for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. - MatchLabelKeys cannot be set when LabelSelector isn't set. - Keys that don't exist in the incoming pod labels will - be ignored. A null or empty list means only match against labelSelector. - - This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). - items: - type: string - type: array - x-kubernetes-list-type: atomic - maxSkew: - description: |- - MaxSkew describes the degree to which pods may be unevenly distributed. - When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference - between the number of matching pods in the target topology and the global minimum. - The global minimum is the minimum number of matching pods in an eligible domain - or zero if the number of eligible domains is less than MinDomains. - For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same - labelSelector spread as 2/2/1: - In this case, the global minimum is 1. - | zone1 | zone2 | zone3 | - | P P | P P | P | - - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; - scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) - violate MaxSkew(1). - - if MaxSkew is 2, incoming pod can be scheduled onto any zone. - When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence - to topologies that satisfy it. - It's a required field. Default value is 1 and 0 is not allowed. - format: int32 - type: integer - minDomains: - description: |- - MinDomains indicates a minimum number of eligible domains. - When the number of eligible domains with matching topology keys is less than minDomains, - Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. - And when the number of eligible domains with matching topology keys equals or greater than minDomains, - this value has no effect on scheduling. - As a result, when the number of eligible domains is less than minDomains, - scheduler won't schedule more than maxSkew Pods to those domains. - If value is nil, the constraint behaves as if MinDomains is equal to 1. - Valid values are integers greater than 0. - When value is not nil, WhenUnsatisfiable must be DoNotSchedule. - - For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same - labelSelector spread as 2/2/2: - | zone1 | zone2 | zone3 | - | P P | P P | P P | - The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. - In this situation, new pod with the same labelSelector cannot be scheduled, - because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, - it will violate MaxSkew. - format: int32 - type: integer - nodeAffinityPolicy: - description: |- - NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector - when calculating pod topology spread skew. Options are: - - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. - - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. - - If this value is nil, the behavior is equivalent to the Honor policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. - type: string - nodeTaintsPolicy: - description: |- - NodeTaintsPolicy indicates how we will treat node taints when calculating - pod topology spread skew. Options are: - - Honor: nodes without taints, along with tainted nodes for which the incoming pod - has a toleration, are included. - - Ignore: node taints are ignored. All nodes are included. - - If this value is nil, the behavior is equivalent to the Ignore policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. - type: string - topologyKey: - description: |- - TopologyKey is the key of node labels. Nodes that have a label with this key - and identical values are considered to be in the same topology. - We consider each as a "bucket", and try to put balanced number - of pods into each bucket. - We define a domain as a particular instance of a topology. - Also, we define an eligible domain as a domain whose nodes meet the requirements of - nodeAffinityPolicy and nodeTaintsPolicy. - e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. - And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. - It's a required field. - type: string - whenUnsatisfiable: - description: |- - WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy - the spread constraint. - - DoNotSchedule (default) tells the scheduler not to schedule it. - - ScheduleAnyway tells the scheduler to schedule the pod in any location, - but giving higher precedence to topologies that would help reduce the - skew. - A constraint is considered "Unsatisfiable" for an incoming pod - if and only if every possible node assignment for that pod would violate - "MaxSkew" on some topology. - For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same - labelSelector spread as 3/1/1: - | zone1 | zone2 | zone3 | - | P P P | P | P | - If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled - to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies - MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler - won't make it *more* imbalanced. - It's a required field. - type: string - required: - - maxSkew - - topologyKey - - whenUnsatisfiable - type: object - type: array - type: object - type: object - tailscale: - description: |- - TailscaleConfig contains options to configure the tailscale-specific - parameters of proxies. - properties: - acceptRoutes: - description: |- - AcceptRoutes can be set to true to make the proxy instance accept - routes advertized by other nodes on the tailnet, such as subnet - routes. - This is equivalent of passing --accept-routes flag to a tailscale Linux client. - https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices - Defaults to false. - type: boolean - type: object - type: object - status: - description: |- - Status of the ProxyClass. This is set and managed automatically. - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - conditions: - description: |- - List of status conditions to indicate the status of the ProxyClass. - Known condition types are `ProxyClassReady`. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: proxygroups.tailscale.com -spec: - group: tailscale.com - names: - kind: ProxyGroup - listKind: ProxyGroupList - plural: proxygroups - shortNames: - - pg - singular: proxygroup - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the deployed ProxyGroup resources. - jsonPath: .status.conditions[?(@.type == "ProxyGroupReady")].reason - name: Status - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec describes the desired ProxyGroup instances. - properties: - hostnamePrefix: - description: |- - HostnamePrefix is the hostname prefix to use for tailnet devices created - by the ProxyGroup. Each device will have the integer number from its - StatefulSet pod appended to this prefix to form the full hostname. - HostnamePrefix can contain lower case letters, numbers and dashes, it - must not start with a dash and must be between 1 and 62 characters long. - pattern: ^[a-z0-9][a-z0-9-]{0,61}$ - type: string - proxyClass: - description: |- - ProxyClass is the name of the ProxyClass custom resource that contains - configuration options that should be applied to the resources created - for this ProxyGroup. If unset, and there is no default ProxyClass - configured, the operator will create resources with the default - configuration. - type: string - replicas: - description: |- - Replicas specifies how many replicas to create the StatefulSet with. - Defaults to 2. - format: int32 - type: integer - tags: - description: |- - Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s]. - If you specify custom tags here, make sure you also make the operator - an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a ProxyGroup device has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - items: - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - type: string - type: array - type: - description: Type of the ProxyGroup proxies. Currently the only supported type is egress. - enum: - - egress - type: string - required: - - type - type: object - status: - description: |- - ProxyGroupStatus describes the status of the ProxyGroup resources. This is - set and managed by the Tailscale operator. - properties: - conditions: - description: |- - List of status conditions to indicate the status of the ProxyGroup - resources. Known condition types are `ProxyGroupReady`. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - devices: - description: List of tailnet devices associated with the ProxyGroup StatefulSet. - items: - properties: - hostname: - description: |- - Hostname is the fully qualified domain name of the device. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the device. - items: - type: string - type: array - required: - - hostname - type: object - type: array - x-kubernetes-list-map-keys: - - hostname - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab - name: recorders.tailscale.com -spec: - group: tailscale.com - names: - kind: Recorder - listKind: RecorderList - plural: recorders - shortNames: - - rec - singular: recorder - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the deployed Recorder resources. - jsonPath: .status.conditions[?(@.type == "RecorderReady")].reason - name: Status - type: string - - description: URL on which the UI is exposed if enabled. - jsonPath: .status.devices[?(@.url != "")].url - name: URL - type: string - name: v1alpha1 - schema: - openAPIV3Schema: - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec describes the desired recorder instance. - properties: - enableUI: - description: |- - Set to true to enable the Recorder UI. The UI lists and plays recorded sessions. - The UI will be served at :443. Defaults to false. - Corresponds to --ui tsrecorder flag https://tailscale.com/kb/1246/tailscale-ssh-session-recording#deploy-a-recorder-node. - Required if S3 storage is not set up, to ensure that recordings are accessible. - type: boolean - statefulSet: - description: |- - Configuration parameters for the Recorder's StatefulSet. The operator - deploys a StatefulSet for each Recorder resource. - properties: - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to the StatefulSet created for the Recorder. - Any Annotations specified here will be merged with the default annotations - applied to the StatefulSet by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - labels: - additionalProperties: - type: string - description: |- - Labels that will be added to the StatefulSet created for the Recorder. - Any labels specified here will be merged with the default labels applied - to the StatefulSet by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - pod: - description: Configuration for pods created by the Recorder's StatefulSet. - properties: - affinity: - description: |- - Affinity rules for Recorder Pods. By default, the operator does not - apply any affinity rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - properties: - nodeAffinity: - description: Describes node affinity scheduling rules for the pod. - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node matches the corresponding matchExpressions; the - node(s) with the highest sum are the most preferred. - items: - description: |- - An empty preferred scheduling term matches all objects with implicit weight 0 - (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). - properties: - preference: - description: A node selector term, associated with the corresponding weight. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - weight: - description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. - format: int32 - type: integer - required: - - preference - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to an update), the system - may or may not try to eventually evict the pod from its node. - properties: - nodeSelectorTerms: - description: Required. A list of node selector terms. The terms are ORed. - items: - description: |- - A null or empty node selector term matches no objects. The requirements of - them are ANDed. - The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - type: array - x-kubernetes-list-type: atomic - required: - - nodeSelectorTerms - type: object - x-kubernetes-map-type: atomic - type: object - podAffinity: - description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - podAntiAffinity: - description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the anti-affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the anti-affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the anti-affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - type: object - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to Recorder Pods. Any annotations - specified here will be merged with the default annotations applied to - the Pod by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - container: - description: Configuration for the Recorder container running tailscale. - properties: - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - items: - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - required: - - name - type: object - type: array - image: - description: |- - Container image name including tag. Defaults to docker.io/tailscale/tsrecorder - with the same tag as the operator, but the official images are also - available at ghcr.io/tailscale/tsrecorder. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - enum: - - Always - - Never - - IfNotPresent - type: string - resources: - description: |- - Container resource requirements. - By default, the operator does not apply any resource requirements. The - amount of resources required wil depend on the volume of recordings sent. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - securityContext: - description: |- - Container security context. By default, the operator does not apply any - container security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - properties: - add: - description: Added capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - type: object - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - type: object - imagePullSecrets: - description: |- - Image pull Secrets for Recorder Pods. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - items: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - type: array - labels: - additionalProperties: - type: string - description: |- - Labels that will be added to Recorder Pods. Any labels specified here - will be merged with the default labels applied to the Pod by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - nodeSelector: - additionalProperties: - type: string - description: |- - Node selector rules for Recorder Pods. By default, the operator does - not apply any node selector rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: object - securityContext: - description: |- - Security context for Recorder Pods. By default, the operator does not - apply any Pod security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - properties: - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - fsGroup: - description: |- - A special supplemental group that applies to all containers in a pod. - Some volume types allow the Kubelet to change the ownership of that volume - to be owned by the pod: - - 1. The owning GID will be the FSGroup - 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) - 3. The permission bits are OR'd with rw-rw---- - - If unset, the Kubelet will not modify the ownership and permissions of any volume. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - fsGroupChangePolicy: - description: |- - fsGroupChangePolicy defines behavior of changing ownership and permission of the volume - before being exposed inside Pod. This field will only apply to - volume types which support fsGroup based ownership(and permissions). - It will have no effect on ephemeral volume types such as: secret, configmaps - and emptydir. - Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. - Note that this field cannot be set when spec.os.name is windows. - type: string - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to all containers. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in SecurityContext. If set in - both SecurityContext and PodSecurityContext, the value specified in SecurityContext - takes precedence for that container. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - supplementalGroups: - description: |- - A list of groups applied to the first process run in each container, in addition - to the container's primary GID, the fsGroup (if specified), and group memberships - defined in the container image for the uid of the container process. If unspecified, - no additional groups are added to any container. Note that group memberships - defined in the container image for the uid of the container process are still effective, - even if they are not included in this list. - Note that this field cannot be set when spec.os.name is windows. - items: - format: int64 - type: integer - type: array - x-kubernetes-list-type: atomic - sysctls: - description: |- - Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported - sysctls (by the container runtime) might fail to launch. - Note that this field cannot be set when spec.os.name is windows. - items: - description: Sysctl defines a kernel parameter to be set - properties: - name: - description: Name of a property to set - type: string - value: - description: Value of a property to set - type: string - required: - - name - - value - type: object - type: array - x-kubernetes-list-type: atomic - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options within a container's SecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - tolerations: - description: |- - Tolerations for Recorder Pods. By default, the operator does not apply - any tolerations. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - items: - description: |- - The pod this Toleration is attached to tolerates any taint that matches - the triple using the matching operator . - properties: - effect: - description: |- - Effect indicates the taint effect to match. Empty means match all taint effects. - When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. - type: string - key: - description: |- - Key is the taint key that the toleration applies to. Empty means match all taint keys. - If the key is empty, operator must be Exists; this combination means to match all values and all keys. - type: string - operator: - description: |- - Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. - Exists is equivalent to wildcard for value, so that a pod can - tolerate all taints of a particular category. - type: string - tolerationSeconds: - description: |- - TolerationSeconds represents the period of time the toleration (which must be - of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, - it is not set, which means tolerate the taint forever (do not evict). Zero and - negative values will be treated as 0 (evict immediately) by the system. - format: int64 - type: integer - value: - description: |- - Value is the taint value the toleration matches to. - If the operator is Exists, the value should be empty, otherwise just a regular string. - type: string - type: object - type: array - type: object - type: object - storage: - description: |- - Configure where to store session recordings. By default, recordings will - be stored in a local ephemeral volume, and will not be persisted past the - lifetime of a specific pod. - properties: - s3: - description: |- - Configure an S3-compatible API for storage. Required if the UI is not - enabled, to ensure that recordings are accessible. - properties: - bucket: - description: |- - Bucket name to write to. The bucket is expected to be used solely for - recordings, as there is no stable prefix for written object names. - type: string - credentials: - description: |- - Configure environment variable credentials for managing objects in the - configured bucket. If not set, tsrecorder will try to acquire credentials - first from the file system and then the STS API. - properties: - secret: - description: |- - Use a Kubernetes Secret from the operator's namespace as the source of - credentials. - properties: - name: - description: |- - The name of a Kubernetes Secret in the operator's namespace that contains - credentials for writing to the configured bucket. Each key-value pair - from the secret's data will be mounted as an environment variable. It - should include keys for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY if - using a static access key. - type: string - type: object - type: object - endpoint: - description: S3-compatible endpoint, e.g. s3.us-east-1.amazonaws.com. - type: string - type: object - type: object - tags: - description: |- - Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s]. - If you specify custom tags here, make sure you also make the operator - an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a Recorder node has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - items: - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - type: string - type: array - type: object - status: - description: |- - RecorderStatus describes the status of the recorder. This is set - and managed by the Tailscale operator. - properties: - conditions: - description: |- - List of status conditions to indicate the status of the Recorder. - Known condition types are `RecorderReady`. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - devices: - description: List of tailnet devices associated with the Recorder StatefulSet. - items: - properties: - hostname: - description: |- - Hostname is the fully qualified domain name of the device. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the device. - items: - type: string - type: array - url: - description: |- - URL where the UI is available if enabled for replaying recordings. This - will be an HTTPS MagicDNS URL. You must be connected to the same tailnet - as the recorder to access it. - type: string - required: - - hostname - type: object - type: array - x-kubernetes-list-map-keys: - - hostname - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: tailscale-operator -rules: - - apiGroups: - - "" - resources: - - events - - services - - services/status - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - networking.k8s.io - resources: - - ingresses - - ingresses/status - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - networking.k8s.io - resources: - - ingressclasses - verbs: - - get - - list - - watch - - apiGroups: - - tailscale.com - resources: - - connectors - - connectors/status - - proxyclasses - - proxyclasses/status - - proxygroups - - proxygroups/status - verbs: - - get - - list - - watch - - update - - apiGroups: - - tailscale.com - resources: - - dnsconfigs - - dnsconfigs/status - verbs: - - get - - list - - watch - - update - - apiGroups: - - tailscale.com - resources: - - recorders - - recorders/status - verbs: - - get - - list - - watch - - update ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: tailscale-operator -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: tailscale-operator -subjects: - - kind: ServiceAccount - name: operator - namespace: tailscale ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: operator - namespace: tailscale -rules: - - apiGroups: - - "" - resources: - - secrets - - serviceaccounts - - configmaps - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - "" - resources: - - pods - verbs: - - get - - list - - watch - - apiGroups: - - apps - resources: - - statefulsets - - deployments - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - discovery.k8s.io - resources: - - endpointslices - verbs: - - get - - list - - watch - - create - - update - - deletecollection - - apiGroups: - - rbac.authorization.k8s.io - resources: - - roles - - rolebindings - verbs: - - get - - create - - patch - - update - - list - - watch ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: proxies - namespace: tailscale -rules: - - apiGroups: - - "" - resources: - - secrets - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - "" - resources: - - events - verbs: - - create - - patch - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: operator - namespace: tailscale -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: operator -subjects: - - kind: ServiceAccount - name: operator - namespace: tailscale ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: proxies - namespace: tailscale -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: proxies -subjects: - - kind: ServiceAccount - name: proxies - namespace: tailscale ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: operator - namespace: tailscale -spec: - replicas: 1 - selector: - matchLabels: - app: operator - strategy: - type: Recreate - template: - metadata: - labels: - app: operator - spec: - containers: - - env: - - name: OPERATOR_INITIAL_TAGS - value: tag:k8s-operator - - name: OPERATOR_HOSTNAME - value: tailscale-operator - - name: OPERATOR_SECRET - value: operator - - name: OPERATOR_LOGGING - value: info - - name: OPERATOR_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: CLIENT_ID_FILE - value: /oauth/client_id - - name: CLIENT_SECRET_FILE - value: /oauth/client_secret - - name: PROXY_IMAGE - value: tailscale/tailscale:unstable - - name: PROXY_TAGS - value: tag:k8s - - name: APISERVER_PROXY - value: "false" - - name: PROXY_FIREWALL_MODE - value: auto - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: POD_UID - valueFrom: - fieldRef: - fieldPath: metadata.uid - image: tailscale/k8s-operator:unstable - imagePullPolicy: Always - name: operator - volumeMounts: - - mountPath: /oauth - name: oauth - readOnly: true - nodeSelector: - kubernetes.io/os: linux - serviceAccountName: operator - volumes: - - name: oauth - secret: - secretName: operator-oauth ---- -apiVersion: networking.k8s.io/v1 -kind: IngressClass -metadata: - annotations: {} - name: tailscale -spec: - controller: tailscale.com/ts-ingress diff --git a/cmd/k8s-operator/deploy/manifests/proxy.yaml b/cmd/k8s-operator/deploy/manifests/proxy.yaml deleted file mode 100644 index 1ad63c2653361..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/proxy.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# This file is not a complete manifest, it's a skeleton that the operator embeds -# at build time and then uses to construct Tailscale proxy pods. -apiVersion: apps/v1 -kind: StatefulSet -metadata: {} -spec: - replicas: 1 - template: - metadata: - deletionGracePeriodSeconds: 10 - spec: - serviceAccountName: proxies - initContainers: - - name: sysctler - securityContext: - privileged: true - command: ["/bin/sh", "-c"] - args: [sysctl -w net.ipv4.ip_forward=1 && if sysctl net.ipv6.conf.all.forwarding; then sysctl -w net.ipv6.conf.all.forwarding=1; fi] - resources: - requests: - cpu: 1m - memory: 1Mi - containers: - - name: tailscale - imagePullPolicy: Always - env: - - name: TS_USERSPACE - value: "false" - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: POD_UID - valueFrom: - fieldRef: - fieldPath: metadata.uid - securityContext: - capabilities: - add: - - NET_ADMIN diff --git a/cmd/k8s-operator/deploy/manifests/templates/01-header.yaml b/cmd/k8s-operator/deploy/manifests/templates/01-header.yaml deleted file mode 100644 index a96d4c37ee421..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/templates/01-header.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/cmd/k8s-operator/deploy/manifests/templates/02-namespace.yaml b/cmd/k8s-operator/deploy/manifests/templates/02-namespace.yaml deleted file mode 100644 index 04d4cbcb3f66b..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/templates/02-namespace.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: tailscale ---- diff --git a/cmd/k8s-operator/deploy/manifests/templates/03-secret.yaml b/cmd/k8s-operator/deploy/manifests/templates/03-secret.yaml deleted file mode 100644 index 0793a24584248..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/templates/03-secret.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: operator-oauth - namespace: tailscale -stringData: - client_id: # SET CLIENT ID HERE - client_secret: # SET CLIENT SECRET HERE ---- diff --git a/cmd/k8s-operator/deploy/manifests/userspace-proxy.yaml b/cmd/k8s-operator/deploy/manifests/userspace-proxy.yaml deleted file mode 100644 index 6617f6d4b52fe..0000000000000 --- a/cmd/k8s-operator/deploy/manifests/userspace-proxy.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# This file is not a complete manifest, it's a skeleton that the operator embeds -# at build time and then uses to construct Tailscale proxy pods. -apiVersion: apps/v1 -kind: StatefulSet -metadata: {} -spec: - replicas: 1 - template: - metadata: - deletionGracePeriodSeconds: 10 - spec: - serviceAccountName: proxies - resources: - requests: - cpu: 1m - memory: 1Mi - containers: - - name: tailscale - imagePullPolicy: Always - env: - - name: TS_USERSPACE - value: "true" - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: POD_UID - valueFrom: - fieldRef: - fieldPath: metadata.uid diff --git a/cmd/k8s-operator/dnsrecords.go b/cmd/k8s-operator/dnsrecords.go deleted file mode 100644 index bba87bf255910..0000000000000 --- a/cmd/k8s-operator/dnsrecords.go +++ /dev/null @@ -1,359 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "fmt" - "slices" - - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - networkingv1 "k8s.io/api/networking/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/net" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - operatorutils "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/util/mak" - "tailscale.com/util/set" -) - -const ( - dnsRecordsRecocilerFinalizer = "tailscale.com/dns-records-reconciler" - annotationTSMagicDNSName = "tailscale.com/magic-dnsname" -) - -// dnsRecordsReconciler knows how to update dnsrecords ConfigMap with DNS -// records. -// The records that it creates are: -// - For tailscale Ingress, a mapping of the Ingress's MagicDNSName to the IP address of -// the ingress proxy Pod. -// - For egress proxies configured via tailscale.com/tailnet-fqdn annotation, a -// mapping of the tailnet FQDN to the IP address of the egress proxy Pod. -// -// Records will only be created if there is exactly one ready -// tailscale.com/v1alpha1.DNSConfig instance in the cluster (so that we know -// that there is a ts.net nameserver deployed in the cluster). -type dnsRecordsReconciler struct { - client.Client - tsNamespace string // namespace in which we provision tailscale resources - logger *zap.SugaredLogger - isDefaultLoadBalancer bool // true if operator is the default ingress controller in this cluster -} - -// Reconcile takes a reconcile.Request for a headless Service fronting a -// tailscale proxy and updates DNS Records in dnsrecords ConfigMap for the -// in-cluster ts.net nameserver if required. -func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { - logger := dnsRR.logger.With("Service", req.NamespacedName) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - headlessSvc := new(corev1.Service) - err = dnsRR.Client.Get(ctx, req.NamespacedName, headlessSvc) - if apierrors.IsNotFound(err) { - logger.Debugf("Service not found") - return reconcile.Result{}, nil - } - if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get Service: %w", err) - } - if !(isManagedByType(headlessSvc, "svc") || isManagedByType(headlessSvc, "ingress")) { - logger.Debugf("Service is not a headless Service for a tailscale ingress or egress proxy; do nothing") - return reconcile.Result{}, nil - } - - if !headlessSvc.DeletionTimestamp.IsZero() { - logger.Debug("Service is being deleted, clean up resources") - return reconcile.Result{}, dnsRR.maybeCleanup(ctx, headlessSvc, logger) - } - - // Check that there is a ts.net nameserver deployed to the cluster by - // checking that there is tailscale.com/v1alpha1.DNSConfig resource in a - // Ready state. - dnsCfgLst := new(tsapi.DNSConfigList) - if err = dnsRR.List(ctx, dnsCfgLst); err != nil { - return reconcile.Result{}, fmt.Errorf("error listing DNSConfigs: %w", err) - } - if len(dnsCfgLst.Items) == 0 { - logger.Debugf("DNSConfig does not exist, not creating DNS records") - return reconcile.Result{}, nil - } - if len(dnsCfgLst.Items) > 1 { - logger.Errorf("Invalid cluster state - more than one DNSConfig found in cluster. Please ensure no more than one exists") - return reconcile.Result{}, nil - } - dnsCfg := dnsCfgLst.Items[0] - if !operatorutils.DNSCfgIsReady(&dnsCfg) { - logger.Info("DNSConfig is not ready yet, waiting...") - return reconcile.Result{}, nil - } - - return reconcile.Result{}, dnsRR.maybeProvision(ctx, headlessSvc, logger) -} - -// maybeProvision ensures that dnsrecords ConfigMap contains a record for the -// proxy associated with the headless Service. -// The record is only provisioned if the proxy is for a tailscale Ingress or -// egress configured via tailscale.com/tailnet-fqdn annotation. -// -// For Ingress, the record is a mapping between the MagicDNSName of the Ingress, retrieved from -// ingress.status.loadBalancer.ingress.hostname field and the proxy Pod IP addresses -// retrieved from the EndpoinSlice associated with this headless Service, i.e -// Records{IP4: : <[IPs of the ingress proxy Pods]>} -// -// For egress, the record is a mapping between tailscale.com/tailnet-fqdn -// annotation and the proxy Pod IP addresses, retrieved from the EndpointSlice -// associated with this headless Service, i.e -// Records{IP4: {: <[IPs of the egress proxy Pods]>} -// -// If records need to be created for this proxy, maybeProvision will also: -// - update the headless Service with a tailscale.com/magic-dnsname annotation -// - update the headless Service with a finalizer -func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) error { - if headlessSvc == nil { - logger.Info("[unexpected] maybeProvision called with a nil Service") - return nil - } - isEgressFQDNSvc, err := dnsRR.isSvcForFQDNEgressProxy(ctx, headlessSvc) - if err != nil { - return fmt.Errorf("error checking whether the Service is for an egress proxy: %w", err) - } - if !(isEgressFQDNSvc || isManagedByType(headlessSvc, "ingress")) { - logger.Debug("Service is not fronting a proxy that we create DNS records for; do nothing") - return nil - } - fqdn, err := dnsRR.fqdnForDNSRecord(ctx, headlessSvc, logger) - if err != nil { - return fmt.Errorf("error determining DNS name for record: %w", err) - } - if fqdn == "" { - logger.Debugf("MagicDNS name does not (yet) exist, not provisioning DNS record") - return nil // a new reconcile will be triggered once it's added - } - - oldHeadlessSvc := headlessSvc.DeepCopy() - // Ensure that headless Service is annotated with a finalizer to help - // with records cleanup when proxy resources are deleted. - if !slices.Contains(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer) { - headlessSvc.Finalizers = append(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer) - } - // Ensure that headless Service is annotated with the current MagicDNS - // name to help with records cleanup when proxy resources are deleted or - // MagicDNS name changes. - oldFqdn := headlessSvc.Annotations[annotationTSMagicDNSName] - if oldFqdn != "" && oldFqdn != fqdn { // i.e user has changed the value of tailscale.com/tailnet-fqdn annotation - logger.Debugf("MagicDNS name has changed, remvoving record for %s", oldFqdn) - updateFunc := func(rec *operatorutils.Records) { - delete(rec.IP4, oldFqdn) - } - if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil { - return fmt.Errorf("error removing record for %s: %w", oldFqdn, err) - } - } - mak.Set(&headlessSvc.Annotations, annotationTSMagicDNSName, fqdn) - if !apiequality.Semantic.DeepEqual(oldHeadlessSvc, headlessSvc) { - logger.Infof("provisioning DNS record for MagicDNS name: %s", fqdn) // this will be printed exactly once - if err := dnsRR.Update(ctx, headlessSvc); err != nil { - return fmt.Errorf("error updating proxy headless Service metadata: %w", err) - } - } - - // Get the Pod IP addresses for the proxy from the EndpointSlices for - // the headless Service. The Service can have multiple EndpointSlices - // associated with it, for example in dual-stack clusters. - labels := map[string]string{discoveryv1.LabelServiceName: headlessSvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership - var eps = new(discoveryv1.EndpointSliceList) - if err := dnsRR.List(ctx, eps, client.InNamespace(dnsRR.tsNamespace), client.MatchingLabels(labels)); err != nil { - return fmt.Errorf("error listing EndpointSlices for the proxy's headless Service: %w", err) - } - if len(eps.Items) == 0 { - logger.Debugf("proxy's headless Service EndpointSlice does not yet exist. We will reconcile again once it's created") - return nil - } - // Each EndpointSlice for a Service can have a list of endpoints that each - // can have multiple addresses - these are the IP addresses of any Pods - // selected by that Service. Pick all the IPv4 addresses. - // It is also possible that multiple EndpointSlices have overlapping addresses. - // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#duplicate-endpoints - ips := make(set.Set[string], 0) - for _, slice := range eps.Items { - if slice.AddressType != discoveryv1.AddressTypeIPv4 { - logger.Infof("EndpointSlice is for AddressType %s, currently only IPv4 address type is supported", slice.AddressType) - continue - } - for _, ep := range slice.Endpoints { - if !epIsReady(&ep) { - logger.Debugf("Endpoint with addresses %v appears not ready to receive traffic %v", ep.Addresses, ep.Conditions.String()) - continue - } - for _, ip := range ep.Addresses { - if !net.IsIPv4String(ip) { - logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip) - } else { - ips.Add(ip) - } - } - } - } - if ips.Len() == 0 { - logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses. We will reconcile again once they are created.") - return nil - } - updateFunc := func(rec *operatorutils.Records) { - mak.Set(&rec.IP4, fqdn, ips.Slice()) - } - if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil { - return fmt.Errorf("error updating DNS records: %w", err) - } - return nil -} - -// epIsReady reports whether the endpoint is currently in a state to receive new -// traffic. As per kube docs, only explicitly set 'false' for 'Ready' or -// 'Serving' conditions or explicitly set 'true' for 'Terminating' condition -// means that the Endpoint is NOT ready. -// https://github.com/kubernetes/kubernetes/blob/60c4c2b2521fb454ce69dee737e3eb91a25e0535/pkg/apis/discovery/types.go#L109-L131 -func epIsReady(ep *discoveryv1.Endpoint) bool { - return (ep.Conditions.Ready == nil || *ep.Conditions.Ready) && - (ep.Conditions.Serving == nil || *ep.Conditions.Serving) && - (ep.Conditions.Terminating == nil || !*ep.Conditions.Terminating) -} - -// maybeCleanup ensures that the DNS record for the proxy has been removed from -// dnsrecords ConfigMap and the tailscale.com/dns-records-reconciler finalizer -// has been removed from the Service. If the record is not found in the -// ConfigMap, the ConfigMap does not exist, or the Service does not have -// tailscale.com/magic-dnsname annotation, just remove the finalizer. -func (h *dnsRecordsReconciler) maybeCleanup(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) error { - ix := slices.Index(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer) - if ix == -1 { - logger.Debugf("no finalizer, nothing to do") - return nil - } - cm := &corev1.ConfigMap{} - err := h.Client.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: h.tsNamespace}, cm) - if apierrors.IsNotFound(err) { - logger.Debug("'dsnrecords' ConfigMap not found") - return h.removeHeadlessSvcFinalizer(ctx, headlessSvc) - } - if err != nil { - return fmt.Errorf("error retrieving 'dnsrecords' ConfigMap: %w", err) - } - if cm.Data == nil { - logger.Debug("'dnsrecords' ConfigMap contains no records") - return h.removeHeadlessSvcFinalizer(ctx, headlessSvc) - } - _, ok := cm.Data[operatorutils.DNSRecordsCMKey] - if !ok { - logger.Debug("'dnsrecords' ConfigMap contains no records") - return h.removeHeadlessSvcFinalizer(ctx, headlessSvc) - } - fqdn, _ := headlessSvc.GetAnnotations()[annotationTSMagicDNSName] - if fqdn == "" { - return h.removeHeadlessSvcFinalizer(ctx, headlessSvc) - } - logger.Infof("removing DNS record for MagicDNS name %s", fqdn) - updateFunc := func(rec *operatorutils.Records) { - delete(rec.IP4, fqdn) - } - if err = h.updateDNSConfig(ctx, updateFunc); err != nil { - return fmt.Errorf("error updating DNS config: %w", err) - } - return h.removeHeadlessSvcFinalizer(ctx, headlessSvc) -} - -func (dnsRR *dnsRecordsReconciler) removeHeadlessSvcFinalizer(ctx context.Context, headlessSvc *corev1.Service) error { - idx := slices.Index(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer) - if idx == -1 { - return nil - } - headlessSvc.Finalizers = append(headlessSvc.Finalizers[:idx], headlessSvc.Finalizers[idx+1:]...) - return dnsRR.Update(ctx, headlessSvc) -} - -// fqdnForDNSRecord returns MagicDNS name associated with a given headless Service. -// If the headless Service is for a tailscale Ingress proxy, returns ingress.status.loadBalancer.ingress.hostname. -// If the headless Service is for an tailscale egress proxy configured via tailscale.com/tailnet-fqdn annotation, returns the annotation value. -// This function is not expected to be called with headless Services for other -// proxy types, or any other Services, but it just returns an empty string if -// that happens. -func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) (string, error) { - parentName := parentFromObjectLabels(headlessSvc) - if isManagedByType(headlessSvc, "ingress") { - ing := new(networkingv1.Ingress) - if err := dnsRR.Get(ctx, parentName, ing); err != nil { - return "", err - } - if len(ing.Status.LoadBalancer.Ingress) == 0 { - return "", nil - } - return ing.Status.LoadBalancer.Ingress[0].Hostname, nil - } - if isManagedByType(headlessSvc, "svc") { - svc := new(corev1.Service) - if err := dnsRR.Get(ctx, parentName, svc); apierrors.IsNotFound(err) { - logger.Info("[unexpected] parent Service for egress proxy %s not found", headlessSvc.Name) - return "", nil - } else if err != nil { - return "", err - } - return svc.Annotations[AnnotationTailnetTargetFQDN], nil - } - return "", nil -} - -// updateDNSConfig runs the provided update function against dnsrecords -// ConfigMap. At this point the in-cluster ts.net nameserver is expected to be -// successfully created together with the ConfigMap. -func (dnsRR *dnsRecordsReconciler) updateDNSConfig(ctx context.Context, update func(*operatorutils.Records)) error { - cm := &corev1.ConfigMap{} - err := dnsRR.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, cm) - if apierrors.IsNotFound(err) { - dnsRR.logger.Info("[unexpected] dnsrecords ConfigMap not found in cluster. Not updating DNS records. Please open an isue and attach operator logs.") - return nil - } - if err != nil { - return fmt.Errorf("error retrieving dnsrecords ConfigMap: %w", err) - } - dnsRecords := operatorutils.Records{Version: operatorutils.Alpha1Version, IP4: map[string][]string{}} - if cm.Data != nil && cm.Data[operatorutils.DNSRecordsCMKey] != "" { - if err := json.Unmarshal([]byte(cm.Data[operatorutils.DNSRecordsCMKey]), &dnsRecords); err != nil { - return err - } - } - update(&dnsRecords) - dnsRecordsBs, err := json.Marshal(dnsRecords) - if err != nil { - return fmt.Errorf("error marshalling DNS records: %w", err) - } - mak.Set(&cm.Data, operatorutils.DNSRecordsCMKey, string(dnsRecordsBs)) - return dnsRR.Update(ctx, cm) -} - -// isSvcForFQDNEgressProxy returns true if the Service is a headless Service -// created for a proxy for a tailscale egress Service configured via -// tailscale.com/tailnet-fqdn annotation. -func (dnsRR *dnsRecordsReconciler) isSvcForFQDNEgressProxy(ctx context.Context, svc *corev1.Service) (bool, error) { - if !isManagedByType(svc, "svc") { - return false, nil - } - parentName := parentFromObjectLabels(svc) - parentSvc := new(corev1.Service) - if err := dnsRR.Get(ctx, parentName, parentSvc); apierrors.IsNotFound(err) { - return false, nil - } else if err != nil { - return false, err - } - annots := parentSvc.Annotations - return annots != nil && annots[AnnotationTailnetTargetFQDN] != "", nil -} diff --git a/cmd/k8s-operator/dnsrecords_test.go b/cmd/k8s-operator/dnsrecords_test.go deleted file mode 100644 index 389461b85f340..0000000000000 --- a/cmd/k8s-operator/dnsrecords_test.go +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "fmt" - "testing" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - operatorutils "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstest" - "tailscale.com/types/ptr" -) - -func TestDNSRecordsReconciler(t *testing.T) { - // Preconfigure a cluster with a DNSConfig - dnsConfig := &tsapi.DNSConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - }, - TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"}, - Spec: tsapi.DNSConfigSpec{ - Nameserver: &tsapi.Nameserver{}, - }} - ing := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ts-ingress", - Namespace: "test", - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("tailscale"), - }, - Status: networkingv1.IngressStatus{ - LoadBalancer: networkingv1.IngressLoadBalancerStatus{ - Ingress: []networkingv1.IngressLoadBalancerIngress{{ - Hostname: "cluster.ingress.ts.net"}}, - }, - }, - } - cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords", Namespace: "tailscale"}} - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(cm). - WithObjects(dnsConfig). - WithObjects(ing). - WithStatusSubresource(dnsConfig, ing). - Build() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - // Set the ready condition of the DNSConfig - mustUpdateStatus[tsapi.DNSConfig](t, fc, "", "test", func(c *tsapi.DNSConfig) { - operatorutils.SetDNSConfigCondition(c, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated, 0, cl, zl.Sugar()) - }) - dnsRR := &dnsRecordsReconciler{ - Client: fc, - logger: zl.Sugar(), - tsNamespace: "tailscale", - } - - // 1. DNS record is created for an egress proxy configured via - // tailscale.com/tailnet-fqdn annotation - egressSvcFQDN := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "egress-fqdn", - Namespace: "test", - Annotations: map[string]string{"tailscale.com/tailnet-fqdn": "foo.bar.ts.net"}, - }, - Spec: corev1.ServiceSpec{ - ExternalName: "unused", - Type: corev1.ServiceTypeExternalName, - }, - } - headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service - ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7", discoveryv1.AddressTypeIPv4) - epv6 := endpointSliceForService(headlessForEgressSvcFQDN, "2600:1900:4011:161:0:d:0:d", discoveryv1.AddressTypeIPv6) - - mustCreate(t, fc, egressSvcFQDN) - mustCreate(t, fc, headlessForEgressSvcFQDN) - mustCreate(t, fc, ep) - mustCreate(t, fc, epv6) - expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service - // ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7 - wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}} // IPv6 endpoint is currently ignored - expectHostsRecords(t, fc, wantHosts) - - // 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's - // value changes - mustUpdate(t, fc, "test", "egress-fqdn", func(svc *corev1.Service) { - svc.Annotations["tailscale.com/tailnet-fqdn"] = "baz.bar.ts.net" - }) - expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service - wantHosts = map[string][]string{"baz.bar.ts.net": {"10.9.8.7"}} - expectHostsRecords(t, fc, wantHosts) - - // 3. DNS record is updated if the IP address of the proxy Pod changes. - ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4", discoveryv1.AddressTypeIPv4) - mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { - ep.Endpoints[0].Addresses = []string{"10.6.5.4"} - }) - expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service - wantHosts = map[string][]string{"baz.bar.ts.net": {"10.6.5.4"}} - expectHostsRecords(t, fc, wantHosts) - - // 4. DNS record is created for an ingress proxy configured via Ingress - headlessForIngress := headlessSvcForParent(ing, "ingress") - ep = endpointSliceForService(headlessForIngress, "10.9.8.7", discoveryv1.AddressTypeIPv4) - mustCreate(t, fc, headlessForIngress) - mustCreate(t, fc, ep) - expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service - wantHosts["cluster.ingress.ts.net"] = []string{"10.9.8.7"} - expectHostsRecords(t, fc, wantHosts) - - // 5. DNS records are updated if Ingress's MagicDNS name changes (i.e users changed spec.tls.hosts[0]) - t.Log("test case 5") - mustUpdateStatus(t, fc, "test", "ts-ingress", func(ing *networkingv1.Ingress) { - ing.Status.LoadBalancer.Ingress[0].Hostname = "another.ingress.ts.net" - }) - expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service - delete(wantHosts, "cluster.ingress.ts.net") - wantHosts["another.ingress.ts.net"] = []string{"10.9.8.7"} - expectHostsRecords(t, fc, wantHosts) - - // 6. DNS records are updated if Ingress proxy's Pod IP changes - mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { - ep.Endpoints[0].Addresses = []string{"7.8.9.10"} - }) - expectReconciled(t, dnsRR, "tailscale", "ts-ingress") - wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"} - expectHostsRecords(t, fc, wantHosts) - - // 7. A not-ready Endpoint is removed from DNS config. - mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) { - ep.Endpoints[0].Conditions.Ready = ptr.To(false) - ep.Endpoints = append(ep.Endpoints, discoveryv1.Endpoint{ - Addresses: []string{"1.2.3.4"}, - }) - }) - expectReconciled(t, dnsRR, "tailscale", "ts-ingress") - wantHosts["another.ingress.ts.net"] = []string{"1.2.3.4"} - expectHostsRecords(t, fc, wantHosts) -} - -func headlessSvcForParent(o client.Object, typ string) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: o.GetName(), - Namespace: "tailscale", - Labels: map[string]string{ - LabelManaged: "true", - LabelParentName: o.GetName(), - LabelParentNamespace: o.GetNamespace(), - LabelParentType: typ, - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "None", - Type: corev1.ServiceTypeClusterIP, - Selector: map[string]string{"foo": "bar"}, - }, - } -} - -func endpointSliceForService(svc *corev1.Service, ip string, fam discoveryv1.AddressType) *discoveryv1.EndpointSlice { - return &discoveryv1.EndpointSlice{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%s", svc.Name, string(fam)), - Namespace: svc.Namespace, - Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name}, - }, - AddressType: fam, - Endpoints: []discoveryv1.Endpoint{{ - Addresses: []string{ip}, - Conditions: discoveryv1.EndpointConditions{ - Ready: ptr.To(true), - Serving: ptr.To(true), - Terminating: ptr.To(false), - }, - }}, - } -} - -func expectHostsRecords(t *testing.T, cl client.Client, wantsHosts map[string][]string) { - t.Helper() - cm := new(corev1.ConfigMap) - if err := cl.Get(context.Background(), types.NamespacedName{Name: "dnsrecords", Namespace: "tailscale"}, cm); err != nil { - t.Fatalf("getting dnsconfig ConfigMap: %v", err) - } - if cm.Data == nil { - t.Fatal("dnsconfig ConfigMap has no data") - } - dnsConfigString, ok := cm.Data[operatorutils.DNSRecordsCMKey] - if !ok { - t.Fatal("dnsconfig ConfigMap does not contain dnsconfig") - } - dnsConfig := &operatorutils.Records{} - if err := json.Unmarshal([]byte(dnsConfigString), dnsConfig); err != nil { - t.Fatalf("unmarshaling dnsconfig: %v", err) - } - if diff := cmp.Diff(dnsConfig.IP4, wantsHosts); diff != "" { - t.Fatalf("unexpected dns config (-got +want):\n%s", diff) - } -} diff --git a/cmd/k8s-operator/egress-eps.go b/cmd/k8s-operator/egress-eps.go deleted file mode 100644 index 85992abed9e37..0000000000000 --- a/cmd/k8s-operator/egress-eps.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/netip" - "reflect" - "strings" - - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - tsoperator "tailscale.com/k8s-operator" - "tailscale.com/kube/egressservices" - "tailscale.com/types/ptr" -) - -// egressEpsReconciler reconciles EndpointSlices for tailnet services exposed to cluster via egress ProxyGroup proxies. -type egressEpsReconciler struct { - client.Client - logger *zap.SugaredLogger - tsNamespace string -} - -// Reconcile reconciles an EndpointSlice for a tailnet service. It updates the EndpointSlice with the endpoints of -// those ProxyGroup Pods that are ready to route traffic to the tailnet service. -// It compares tailnet service state stored in egress proxy state Secrets by containerboot with the desired -// configuration stored in proxy-cfg ConfigMap to determine if the endpoint is ready. -func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { - l := er.logger.With("Service", req.NamespacedName) - l.Debugf("starting reconcile") - defer l.Debugf("reconcile finished") - - eps := new(discoveryv1.EndpointSlice) - err = er.Get(ctx, req.NamespacedName, eps) - if apierrors.IsNotFound(err) { - l.Debugf("EndpointSlice not found") - return reconcile.Result{}, nil - } - if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get EndpointSlice: %w", err) - } - if !eps.DeletionTimestamp.IsZero() { - l.Debugf("EnpointSlice is being deleted") - return res, nil - } - - // Get the user-created ExternalName Service and use its status conditions to determine whether cluster - // resources are set up for this tailnet service. - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: eps.Labels[LabelParentName], - Namespace: eps.Labels[LabelParentNamespace], - }, - } - err = er.Get(ctx, client.ObjectKeyFromObject(svc), svc) - if apierrors.IsNotFound(err) { - l.Infof("ExternalName Service %s/%s not found, perhaps it was deleted", svc.Namespace, svc.Name) - return res, nil - } - if err != nil { - return res, fmt.Errorf("error retrieving ExternalName Service: %w", err) - } - if !tsoperator.EgressServiceIsValidAndConfigured(svc) { - l.Infof("Cluster resources for ExternalName Service %s/%s are not yet configured", svc.Namespace, svc.Name) - return res, nil - } - - // TODO(irbekrm): currently this reconcile loop runs all the checks every time it's triggered, which is - // wasteful. Once we have a Ready condition for ExternalName Services for ProxyGroup, use the condition to - // determine if a reconcile is needed. - - oldEps := eps.DeepCopy() - proxyGroupName := eps.Labels[labelProxyGroup] - tailnetSvc := tailnetSvcName(svc) - l = l.With("tailnet-service-name", tailnetSvc) - - // Retrieve the desired tailnet service configuration from the ConfigMap. - _, cfgs, err := egressSvcsConfigs(ctx, er.Client, proxyGroupName, er.tsNamespace) - if err != nil { - return res, fmt.Errorf("error retrieving tailnet services configuration: %w", err) - } - cfg, ok := (*cfgs)[tailnetSvc] - if !ok { - l.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc) - return res, nil - } - - // Check which Pods in ProxyGroup are ready to route traffic to this - // egress service. - podList := &corev1.PodList{} - if err := er.List(ctx, podList, client.MatchingLabels(pgLabels(proxyGroupName, nil))); err != nil { - return res, fmt.Errorf("error listing Pods for ProxyGroup %s: %w", proxyGroupName, err) - } - newEndpoints := make([]discoveryv1.Endpoint, 0) - for _, pod := range podList.Items { - ready, err := er.podIsReadyToRouteTraffic(ctx, pod, &cfg, tailnetSvc, l) - if err != nil { - return res, fmt.Errorf("error verifying if Pod is ready to route traffic: %w", err) - } - if !ready { - continue // maybe next time - } - podIP, err := podIPv4(&pod) // we currently only support IPv4 - if err != nil { - return res, fmt.Errorf("error determining IPv4 address for Pod: %w", err) - } - newEndpoints = append(newEndpoints, discoveryv1.Endpoint{ - Hostname: (*string)(&pod.UID), - Addresses: []string{podIP}, - Conditions: discoveryv1.EndpointConditions{ - Ready: ptr.To(true), - Serving: ptr.To(true), - Terminating: ptr.To(false), - }, - }) - } - // Note that Endpoints are being overwritten with the currently valid endpoints so we don't need to explicitly - // run a cleanup for deleted Pods etc. - eps.Endpoints = newEndpoints - if !reflect.DeepEqual(eps, oldEps) { - l.Infof("Updating EndpointSlice to ensure traffic is routed to ready proxy Pods") - if err := er.Update(ctx, eps); err != nil { - return res, fmt.Errorf("error updating EndpointSlice: %w", err) - } - } - return res, nil -} - -func podIPv4(pod *corev1.Pod) (string, error) { - for _, ip := range pod.Status.PodIPs { - parsed, err := netip.ParseAddr(ip.IP) - if err != nil { - return "", fmt.Errorf("error parsing IP address %s: %w", ip, err) - } - if parsed.Is4() { - return parsed.String(), nil - } - } - return "", nil -} - -// podIsReadyToRouteTraffic returns true if it appears that the proxy Pod has configured firewall rules to be able to -// route traffic to the given tailnet service. It retrieves the proxy's state Secret and compares the tailnet service -// status written there to the desired service configuration. -func (er *egressEpsReconciler) podIsReadyToRouteTraffic(ctx context.Context, pod corev1.Pod, cfg *egressservices.Config, tailnetSvcName string, l *zap.SugaredLogger) (bool, error) { - l = l.With("proxy_pod", pod.Name) - l.Debugf("checking whether proxy is ready to route to egress service") - if !pod.DeletionTimestamp.IsZero() { - l.Debugf("proxy Pod is being deleted, ignore") - return false, nil - } - podIP, err := podIPv4(&pod) - if err != nil { - return false, fmt.Errorf("error determining Pod IP address: %v", err) - } - if podIP == "" { - l.Infof("[unexpected] Pod does not have an IPv4 address, and IPv6 is not currently supported") - return false, nil - } - stateS := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: pod.Name, - Namespace: pod.Namespace, - }, - } - err = er.Get(ctx, client.ObjectKeyFromObject(stateS), stateS) - if apierrors.IsNotFound(err) { - l.Debugf("proxy does not have a state Secret, waiting...") - return false, nil - } - if err != nil { - return false, fmt.Errorf("error getting state Secret: %w", err) - } - svcStatusBS := stateS.Data[egressservices.KeyEgressServices] - if len(svcStatusBS) == 0 { - l.Debugf("proxy's state Secret does not contain egress services status, waiting...") - return false, nil - } - svcStatus := &egressservices.Status{} - if err := json.Unmarshal(svcStatusBS, svcStatus); err != nil { - return false, fmt.Errorf("error unmarshalling egress service status: %w", err) - } - if !strings.EqualFold(podIP, svcStatus.PodIPv4) { - l.Infof("proxy's egress service status is for Pod IP %s, current proxy's Pod IP %s, waiting for the proxy to reconfigure...", svcStatus.PodIPv4, podIP) - return false, nil - } - st, ok := (*svcStatus).Services[tailnetSvcName] - if !ok { - l.Infof("proxy's state Secret does not have egress service status, waiting...") - return false, nil - } - if !reflect.DeepEqual(cfg.TailnetTarget, st.TailnetTarget) { - l.Infof("proxy has configured egress service for tailnet target %v, current target is %v, waiting for proxy to reconfigure...", st.TailnetTarget, cfg.TailnetTarget) - return false, nil - } - if !reflect.DeepEqual(cfg.Ports, st.Ports) { - l.Debugf("proxy has configured egress service for ports %#+v, wants ports %#+v, waiting for proxy to reconfigure", st.Ports, cfg.Ports) - return false, nil - } - l.Debugf("proxy is ready to route traffic to egress service") - return true, nil -} diff --git a/cmd/k8s-operator/egress-eps_test.go b/cmd/k8s-operator/egress-eps_test.go deleted file mode 100644 index a64f3e4e1bb50..0000000000000 --- a/cmd/k8s-operator/egress-eps_test.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "encoding/json" - "fmt" - "math/rand/v2" - "testing" - - "github.com/AlekSi/pointer" - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/egressservices" - "tailscale.com/tstest" - "tailscale.com/util/mak" -) - -func TestTailscaleEgressEndpointSlices(t *testing.T) { - clock := tstest.NewClock(tstest.ClockOpts{}) - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetFQDN: "foo.bar.ts.net", - AnnotationProxyGroup: "foo", - }, - }, - Spec: corev1.ServiceSpec{ - ExternalName: "placeholder", - Type: corev1.ServiceTypeExternalName, - Selector: nil, - Ports: []corev1.ServicePort{ - { - Name: "http", - Protocol: "TCP", - Port: 80, - }, - }, - }, - Status: corev1.ServiceStatus{ - Conditions: []metav1.Condition{ - condition(tsapi.EgressSvcConfigured, metav1.ConditionTrue, "", "", clock), - condition(tsapi.EgressSvcValid, metav1.ConditionTrue, "", "", clock), - }, - }, - } - port := randomPort() - cm := configMapForSvc(t, svc, port) - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(svc, cm). - WithStatusSubresource(svc). - Build() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - er := &egressEpsReconciler{ - Client: fc, - logger: zl.Sugar(), - tsNamespace: "operator-ns", - } - eps := &discoveryv1.EndpointSlice{ - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - Namespace: "operator-ns", - Labels: map[string]string{ - LabelParentName: "test", - LabelParentNamespace: "default", - labelSvcType: typeEgress, - labelProxyGroup: "foo"}, - }, - AddressType: discoveryv1.AddressTypeIPv4, - } - mustCreate(t, fc, eps) - - t.Run("no_proxy_group_resources", func(t *testing.T) { - expectReconciled(t, er, "operator-ns", "foo") // should not error - }) - - t.Run("no_pods_ready_to_route_traffic", func(t *testing.T) { - pod, stateS := podAndSecretForProxyGroup("foo") - mustCreate(t, fc, pod) - mustCreate(t, fc, stateS) - expectReconciled(t, er, "operator-ns", "foo") // should not error - }) - - t.Run("pods_are_ready_to_route_traffic", func(t *testing.T) { - pod, stateS := podAndSecretForProxyGroup("foo") - stBs := serviceStatusForPodIP(t, svc, pod.Status.PodIPs[0].IP, port) - mustUpdate(t, fc, "operator-ns", stateS.Name, func(s *corev1.Secret) { - mak.Set(&s.Data, egressservices.KeyEgressServices, stBs) - }) - expectReconciled(t, er, "operator-ns", "foo") - eps.Endpoints = append(eps.Endpoints, discoveryv1.Endpoint{ - Addresses: []string{"10.0.0.1"}, - Hostname: pointer.To("foo"), - Conditions: discoveryv1.EndpointConditions{ - Serving: pointer.ToBool(true), - Ready: pointer.ToBool(true), - Terminating: pointer.ToBool(false), - }, - }) - expectEqual(t, fc, eps, nil) - }) - t.Run("status_does_not_match_pod_ip", func(t *testing.T) { - _, stateS := podAndSecretForProxyGroup("foo") // replica Pod has IP 10.0.0.1 - stBs := serviceStatusForPodIP(t, svc, "10.0.0.2", port) // status is for a Pod with IP 10.0.0.2 - mustUpdate(t, fc, "operator-ns", stateS.Name, func(s *corev1.Secret) { - mak.Set(&s.Data, egressservices.KeyEgressServices, stBs) - }) - expectReconciled(t, er, "operator-ns", "foo") - eps.Endpoints = []discoveryv1.Endpoint{} - expectEqual(t, fc, eps, nil) - }) -} - -func configMapForSvc(t *testing.T, svc *corev1.Service, p uint16) *corev1.ConfigMap { - t.Helper() - ports := make(map[egressservices.PortMap]struct{}) - for _, port := range svc.Spec.Ports { - ports[egressservices.PortMap{Protocol: string(port.Protocol), MatchPort: p, TargetPort: uint16(port.Port)}] = struct{}{} - } - cfg := egressservices.Config{ - Ports: ports, - } - if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" { - cfg.TailnetTarget = egressservices.TailnetTarget{FQDN: fqdn} - } - if ip := svc.Annotations[AnnotationTailnetTargetIP]; ip != "" { - cfg.TailnetTarget = egressservices.TailnetTarget{IP: ip} - } - name := tailnetSvcName(svc) - cfgs := egressservices.Configs{name: cfg} - bs, err := json.Marshal(&cfgs) - if err != nil { - t.Fatalf("error marshalling config: %v", err) - } - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: pgEgressCMName(svc.Annotations[AnnotationProxyGroup]), - Namespace: "operator-ns", - }, - BinaryData: map[string][]byte{egressservices.KeyEgressServices: bs}, - } - return cm -} - -func serviceStatusForPodIP(t *testing.T, svc *corev1.Service, ip string, p uint16) []byte { - t.Helper() - ports := make(map[egressservices.PortMap]struct{}) - for _, port := range svc.Spec.Ports { - ports[egressservices.PortMap{Protocol: string(port.Protocol), MatchPort: p, TargetPort: uint16(port.Port)}] = struct{}{} - } - svcSt := egressservices.ServiceStatus{Ports: ports} - if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" { - svcSt.TailnetTarget = egressservices.TailnetTarget{FQDN: fqdn} - } - if ip := svc.Annotations[AnnotationTailnetTargetIP]; ip != "" { - svcSt.TailnetTarget = egressservices.TailnetTarget{IP: ip} - } - svcName := tailnetSvcName(svc) - st := egressservices.Status{ - PodIPv4: ip, - Services: map[string]*egressservices.ServiceStatus{svcName: &svcSt}, - } - bs, err := json.Marshal(st) - if err != nil { - t.Fatalf("error marshalling service status: %v", err) - } - return bs -} - -func podAndSecretForProxyGroup(pg string) (*corev1.Pod, *corev1.Secret) { - p := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-0", pg), - Namespace: "operator-ns", - Labels: pgLabels(pg, nil), - UID: "foo", - }, - Status: corev1.PodStatus{ - PodIPs: []corev1.PodIP{ - {IP: "10.0.0.1"}, - }, - }, - } - s := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-0", pg), - Namespace: "operator-ns", - Labels: pgSecretLabels(pg, "state"), - }, - } - return p, s -} - -func randomPort() uint16 { - return uint16(rand.Int32N(1000) + 1000) -} diff --git a/cmd/k8s-operator/egress-services-readiness.go b/cmd/k8s-operator/egress-services-readiness.go deleted file mode 100644 index f6991145f88fc..0000000000000 --- a/cmd/k8s-operator/egress-services-readiness.go +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "errors" - "fmt" - "strings" - - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstime" -) - -const ( - reasonReadinessCheckFailed = "ReadinessCheckFailed" - reasonClusterResourcesNotReady = "ClusterResourcesNotReady" - reasonNoProxies = "NoProxiesConfigured" - reasonNotReady = "NotReadyToRouteTraffic" - reasonReady = "ReadyToRouteTraffic" - reasonPartiallyReady = "PartiallyReadyToRouteTraffic" - msgReadyToRouteTemplate = "%d out of %d replicas are ready to route traffic" -) - -type egressSvcsReadinessReconciler struct { - client.Client - logger *zap.SugaredLogger - clock tstime.Clock - tsNamespace string -} - -// Reconcile reconciles an ExternalName Service that defines a tailnet target to be exposed on a ProxyGroup and sets the -// EgressSvcReady condition on it. The condition gets set to true if at least one of the proxies is currently ready to -// route traffic to the target. It compares proxy Pod IPs with the endpoints set on the EndpointSlice for the egress -// service to determine how many replicas are currently able to route traffic. -func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { - l := esrr.logger.With("Service", req.NamespacedName) - defer l.Info("reconcile finished") - - svc := new(corev1.Service) - if err = esrr.Get(ctx, req.NamespacedName, svc); apierrors.IsNotFound(err) { - l.Info("Service not found") - return res, nil - } else if err != nil { - return res, fmt.Errorf("failed to get Service: %w", err) - } - var ( - reason, msg string - st metav1.ConditionStatus = metav1.ConditionUnknown - ) - oldStatus := svc.Status.DeepCopy() - defer func() { - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, st, reason, msg, esrr.clock, l) - if !apiequality.Semantic.DeepEqual(oldStatus, svc.Status) { - err = errors.Join(err, esrr.Status().Update(ctx, svc)) - } - }() - - crl := egressSvcChildResourceLabels(svc) - eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, esrr.Client, esrr.tsNamespace, crl) - if err != nil { - err = fmt.Errorf("error getting EndpointSlice: %w", err) - reason = reasonReadinessCheckFailed - msg = err.Error() - return res, err - } - if eps == nil { - l.Infof("EndpointSlice for Service does not yet exist, waiting...") - reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady - st = metav1.ConditionFalse - return res, nil - } - pg := &tsapi.ProxyGroup{ - ObjectMeta: metav1.ObjectMeta{ - Name: svc.Annotations[AnnotationProxyGroup], - }, - } - err = esrr.Get(ctx, client.ObjectKeyFromObject(pg), pg) - if apierrors.IsNotFound(err) { - l.Infof("ProxyGroup for Service does not exist, waiting...") - reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady - st = metav1.ConditionFalse - return res, nil - } - if err != nil { - err = fmt.Errorf("error retrieving ProxyGroup: %w", err) - reason = reasonReadinessCheckFailed - msg = err.Error() - return res, err - } - if !tsoperator.ProxyGroupIsReady(pg) { - l.Infof("ProxyGroup for Service is not ready, waiting...") - reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady - st = metav1.ConditionFalse - return res, nil - } - - replicas := pgReplicas(pg) - if replicas == 0 { - l.Infof("ProxyGroup replicas set to 0") - reason, msg = reasonNoProxies, reasonNoProxies - st = metav1.ConditionFalse - return res, nil - } - podLabels := pgLabels(pg.Name, nil) - var readyReplicas int32 - for i := range replicas { - podLabels[appsv1.PodIndexLabel] = fmt.Sprintf("%d", i) - pod, err := getSingleObject[corev1.Pod](ctx, esrr.Client, esrr.tsNamespace, podLabels) - if err != nil { - err = fmt.Errorf("error retrieving ProxyGroup Pod: %w", err) - reason = reasonReadinessCheckFailed - msg = err.Error() - return res, err - } - if pod == nil { - l.Infof("[unexpected] ProxyGroup is ready, but replica %d was not found", i) - reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady - return res, nil - } - l.Infof("looking at Pod with IPs %v", pod.Status.PodIPs) - ready := false - for _, ep := range eps.Endpoints { - l.Infof("looking at endpoint with addresses %v", ep.Addresses) - if endpointReadyForPod(&ep, pod, l) { - l.Infof("endpoint is ready for Pod") - ready = true - break - } - } - if ready { - readyReplicas++ - } - } - msg = fmt.Sprintf(msgReadyToRouteTemplate, readyReplicas, replicas) - if readyReplicas == 0 { - reason = reasonNotReady - st = metav1.ConditionFalse - return res, nil - } - st = metav1.ConditionTrue - if readyReplicas < replicas { - reason = reasonPartiallyReady - } else { - reason = reasonReady - } - return res, nil -} - -// endpointReadyForPod returns true if the endpoint is for the Pod's IPv4 address and is ready to serve traffic. -// Endpoint must not be nil. -func endpointReadyForPod(ep *discoveryv1.Endpoint, pod *corev1.Pod, l *zap.SugaredLogger) bool { - podIP, err := podIPv4(pod) - if err != nil { - l.Infof("[unexpected] error retrieving Pod's IPv4 address: %v", err) - return false - } - // Currently we only ever set a single address on and Endpoint and nothing else is meant to modify this. - if len(ep.Addresses) != 1 { - return false - } - return strings.EqualFold(ep.Addresses[0], podIP) && - *ep.Conditions.Ready && - *ep.Conditions.Serving && - !*ep.Conditions.Terminating -} diff --git a/cmd/k8s-operator/egress-services-readiness_test.go b/cmd/k8s-operator/egress-services-readiness_test.go deleted file mode 100644 index 052eb1a493801..0000000000000 --- a/cmd/k8s-operator/egress-services-readiness_test.go +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "fmt" - "testing" - - "github.com/AlekSi/pointer" - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstest" - "tailscale.com/tstime" -) - -func TestEgressServiceReadiness(t *testing.T) { - // We need to pass a ProxyGroup object to WithStatusSubresource because of some quirks in how the fake client - // works. Without this code further down would not be able to update ProxyGroup status. - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithStatusSubresource(&tsapi.ProxyGroup{}). - Build() - zl, _ := zap.NewDevelopment() - cl := tstest.NewClock(tstest.ClockOpts{}) - rec := &egressSvcsReadinessReconciler{ - tsNamespace: "operator-ns", - Client: fc, - logger: zl.Sugar(), - clock: cl, - } - tailnetFQDN := "my-app.tailnetxyz.ts.net" - egressSvc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-app", - Namespace: "dev", - Annotations: map[string]string{ - AnnotationProxyGroup: "dev", - AnnotationTailnetTargetFQDN: tailnetFQDN, - }, - }, - } - fakeClusterIPSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "my-app", Namespace: "operator-ns"}} - l := egressSvcEpsLabels(egressSvc, fakeClusterIPSvc) - eps := &discoveryv1.EndpointSlice{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-app", - Namespace: "operator-ns", - Labels: l, - }, - AddressType: discoveryv1.AddressTypeIPv4, - } - pg := &tsapi.ProxyGroup{ - ObjectMeta: metav1.ObjectMeta{ - Name: "dev", - }, - } - mustCreate(t, fc, egressSvc) - setClusterNotReady(egressSvc, cl, zl.Sugar()) - t.Run("endpointslice_does_not_exist", func(t *testing.T) { - expectReconciled(t, rec, "dev", "my-app") - expectEqual(t, fc, egressSvc, nil) // not ready - }) - t.Run("proxy_group_does_not_exist", func(t *testing.T) { - mustCreate(t, fc, eps) - expectReconciled(t, rec, "dev", "my-app") - expectEqual(t, fc, egressSvc, nil) // still not ready - }) - t.Run("proxy_group_not_ready", func(t *testing.T) { - mustCreate(t, fc, pg) - expectReconciled(t, rec, "dev", "my-app") - expectEqual(t, fc, egressSvc, nil) // still not ready - }) - t.Run("no_ready_replicas", func(t *testing.T) { - setPGReady(pg, cl, zl.Sugar()) - mustUpdateStatus(t, fc, pg.Namespace, pg.Name, func(p *tsapi.ProxyGroup) { - p.Status = pg.Status - }) - expectEqual(t, fc, pg, nil) - for i := range pgReplicas(pg) { - p := pod(pg, i) - mustCreate(t, fc, p) - mustUpdateStatus(t, fc, p.Namespace, p.Name, func(existing *corev1.Pod) { - existing.Status.PodIPs = p.Status.PodIPs - }) - } - expectReconciled(t, rec, "dev", "my-app") - setNotReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg)) - expectEqual(t, fc, egressSvc, nil) // still not ready - }) - t.Run("one_ready_replica", func(t *testing.T) { - setEndpointForReplica(pg, 0, eps) - mustUpdate(t, fc, eps.Namespace, eps.Name, func(e *discoveryv1.EndpointSlice) { - e.Endpoints = eps.Endpoints - }) - setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), 1) - expectReconciled(t, rec, "dev", "my-app") - expectEqual(t, fc, egressSvc, nil) // partially ready - }) - t.Run("all_replicas_ready", func(t *testing.T) { - for i := range pgReplicas(pg) { - setEndpointForReplica(pg, i, eps) - } - mustUpdate(t, fc, eps.Namespace, eps.Name, func(e *discoveryv1.EndpointSlice) { - e.Endpoints = eps.Endpoints - }) - setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), pgReplicas(pg)) - expectReconciled(t, rec, "dev", "my-app") - expectEqual(t, fc, egressSvc, nil) // ready - }) -} - -func setClusterNotReady(svc *corev1.Service, cl tstime.Clock, l *zap.SugaredLogger) { - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, metav1.ConditionFalse, reasonClusterResourcesNotReady, reasonClusterResourcesNotReady, cl, l) -} - -func setNotReady(svc *corev1.Service, cl tstime.Clock, l *zap.SugaredLogger, replicas int32) { - msg := fmt.Sprintf(msgReadyToRouteTemplate, 0, replicas) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, metav1.ConditionFalse, reasonNotReady, msg, cl, l) -} - -func setReady(svc *corev1.Service, cl tstime.Clock, l *zap.SugaredLogger, replicas, readyReplicas int32) { - reason := reasonPartiallyReady - if readyReplicas == replicas { - reason = reasonReady - } - msg := fmt.Sprintf(msgReadyToRouteTemplate, readyReplicas, replicas) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, metav1.ConditionTrue, reason, msg, cl, l) -} - -func setPGReady(pg *tsapi.ProxyGroup, cl tstime.Clock, l *zap.SugaredLogger) { - tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, metav1.ConditionTrue, "foo", "foo", pg.Generation, cl, l) -} - -func setEndpointForReplica(pg *tsapi.ProxyGroup, ordinal int32, eps *discoveryv1.EndpointSlice) { - p := pod(pg, ordinal) - eps.Endpoints = append(eps.Endpoints, discoveryv1.Endpoint{ - Addresses: []string{p.Status.PodIPs[0].IP}, - Conditions: discoveryv1.EndpointConditions{ - Ready: pointer.ToBool(true), - Serving: pointer.ToBool(true), - Terminating: pointer.ToBool(false), - }, - }) -} - -func pod(pg *tsapi.ProxyGroup, ordinal int32) *corev1.Pod { - l := pgLabels(pg.Name, nil) - l[appsv1.PodIndexLabel] = fmt.Sprintf("%d", ordinal) - ip := fmt.Sprintf("10.0.0.%d", ordinal) - return &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%d", pg.Name, ordinal), - Namespace: "operator-ns", - Labels: l, - }, - Status: corev1.PodStatus{ - PodIPs: []corev1.PodIP{{IP: ip}}, - }, - } -} diff --git a/cmd/k8s-operator/egress-services.go b/cmd/k8s-operator/egress-services.go deleted file mode 100644 index 98ed943669cd0..0000000000000 --- a/cmd/k8s-operator/egress-services.go +++ /dev/null @@ -1,716 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "crypto/sha256" - "encoding/json" - "errors" - "fmt" - "math/rand/v2" - "reflect" - "slices" - "strings" - "sync" - - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/intstr" - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/apiserver/pkg/storage/names" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/egressservices" - "tailscale.com/kube/kubetypes" - "tailscale.com/tstime" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/set" -) - -const ( - reasonEgressSvcInvalid = "EgressSvcInvalid" - reasonEgressSvcValid = "EgressSvcValid" - reasonEgressSvcCreationFailed = "EgressSvcCreationFailed" - reasonProxyGroupNotReady = "ProxyGroupNotReady" - - labelProxyGroup = "tailscale.com/proxy-group" - - labelSvcType = "tailscale.com/svc-type" // ingress or egress - typeEgress = "egress" - // maxPorts is the maximum number of ports that can be exposed on a - // container. In practice this will be ports in range [3000 - 4000). The - // high range should make it easier to distinguish container ports from - // the tailnet target ports for debugging purposes (i.e when reading - // netfilter rules). The limit of 10000 is somewhat arbitrary, the - // assumption is that this would not be hit in practice. - maxPorts = 10000 - - indexEgressProxyGroup = ".metadata.annotations.egress-proxy-group" -) - -var gaugeEgressServices = clientmetric.NewGauge(kubetypes.MetricEgressServiceCount) - -// egressSvcsReconciler reconciles user created ExternalName Services that specify a tailnet -// endpoint that should be exposed to cluster workloads and an egress ProxyGroup -// on whose proxies it should be exposed. -type egressSvcsReconciler struct { - client.Client - logger *zap.SugaredLogger - recorder record.EventRecorder - clock tstime.Clock - tsNamespace string - - mu sync.Mutex // protects following - svcs set.Slice[types.UID] // UIDs of all currently managed egress Services for ProxyGroup -} - -// Reconcile reconciles an ExternalName Service that specifies a tailnet target and a ProxyGroup on whose proxies should -// forward cluster traffic to the target. -// For an ExternalName Service the reconciler: -// -// - for each port N defined on the ExternalName Service, allocates a port X in range [3000- 4000), unique for the -// ProxyGroup proxies. Proxies will forward cluster traffic received on port N to port M on the tailnet target -// -// - creates a ClusterIP Service in the operator's namespace with portmappings for all M->N port pairs. This will allow -// cluster workloads to send traffic on the user-defined tailnet target port and get it transparently mapped to the -// randomly selected port on proxy Pods. -// -// - creates an EndpointSlice in the operator's namespace with kubernetes.io/service-name label pointing to the -// ClusterIP Service. The endpoints will get dynamically updates to proxy Pod IPs as the Pods become ready to route -// traffic to the tailnet target. kubernetes.io/service-name label ensures that kube-proxy sets up routing rules to -// forward cluster traffic received on ClusterIP Service's IP address to the endpoints (Pod IPs). -// -// - updates the egress service config in a ConfigMap mounted to the ProxyGroup proxies with the tailnet target and the -// portmappings. -func (esr *egressSvcsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { - l := esr.logger.With("Service", req.NamespacedName) - defer l.Info("reconcile finished") - - svc := new(corev1.Service) - if err = esr.Get(ctx, req.NamespacedName, svc); apierrors.IsNotFound(err) { - l.Info("Service not found") - return res, nil - } else if err != nil { - return res, fmt.Errorf("failed to get Service: %w", err) - } - - // Name of the 'egress service', meaning the tailnet target. - tailnetSvc := tailnetSvcName(svc) - l = l.With("tailnet-service", tailnetSvc) - - // Note that resources for egress Services are only cleaned up when the - // Service is actually deleted (and not if, for example, user decides to - // remove the Tailscale annotation from it). This should be fine- we - // assume that the egress ExternalName Services are always created for - // Tailscale operator specifically. - if !svc.DeletionTimestamp.IsZero() { - l.Info("Service is being deleted, ensuring resource cleanup") - return res, esr.maybeCleanup(ctx, svc, l) - } - - oldStatus := svc.Status.DeepCopy() - defer func() { - if !apiequality.Semantic.DeepEqual(oldStatus, svc.Status) { - err = errors.Join(err, esr.Status().Update(ctx, svc)) - } - }() - - // Validate the user-created ExternalName Service and the associated ProxyGroup. - if ok, err := esr.validateClusterResources(ctx, svc, l); err != nil { - return res, fmt.Errorf("error validating cluster resources: %w", err) - } else if !ok { - return res, nil - } - - if !slices.Contains(svc.Finalizers, FinalizerName) { - l.Infof("configuring tailnet service") // logged exactly once - svc.Finalizers = append(svc.Finalizers, FinalizerName) - if err := esr.Update(ctx, svc); err != nil { - err := fmt.Errorf("failed to add finalizer: %w", err) - r := svcConfiguredReason(svc, false, l) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcConfigured, metav1.ConditionFalse, r, err.Error(), esr.clock, l) - return res, err - } - esr.mu.Lock() - esr.svcs.Add(svc.UID) - gaugeEgressServices.Set(int64(esr.svcs.Len())) - esr.mu.Unlock() - } - - if err := esr.maybeCleanupProxyGroupConfig(ctx, svc, l); err != nil { - err = fmt.Errorf("cleaning up resources for previous ProxyGroup failed: %w", err) - r := svcConfiguredReason(svc, false, l) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcConfigured, metav1.ConditionFalse, r, err.Error(), esr.clock, l) - return res, err - } - - return res, esr.maybeProvision(ctx, svc, l) -} - -func (esr *egressSvcsReconciler) maybeProvision(ctx context.Context, svc *corev1.Service, l *zap.SugaredLogger) (err error) { - r := svcConfiguredReason(svc, false, l) - st := metav1.ConditionFalse - defer func() { - msg := r - if st != metav1.ConditionTrue && err != nil { - msg = err.Error() - } - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcConfigured, st, r, msg, esr.clock, l) - }() - - crl := egressSvcChildResourceLabels(svc) - clusterIPSvc, err := getSingleObject[corev1.Service](ctx, esr.Client, esr.tsNamespace, crl) - if err != nil { - err = fmt.Errorf("error retrieving ClusterIP Service: %w", err) - return err - } - if clusterIPSvc == nil { - clusterIPSvc = esr.clusterIPSvcForEgress(crl) - } - upToDate := svcConfigurationUpToDate(svc, l) - provisioned := true - if !upToDate { - if clusterIPSvc, provisioned, err = esr.provision(ctx, svc.Annotations[AnnotationProxyGroup], svc, clusterIPSvc, l); err != nil { - return err - } - } - if !provisioned { - l.Infof("unable to provision cluster resources") - return nil - } - - // Update ExternalName Service to point at the ClusterIP Service. - clusterDomain := retrieveClusterDomain(esr.tsNamespace, l) - clusterIPSvcFQDN := fmt.Sprintf("%s.%s.svc.%s", clusterIPSvc.Name, clusterIPSvc.Namespace, clusterDomain) - if svc.Spec.ExternalName != clusterIPSvcFQDN { - l.Infof("Configuring ExternalName Service to point to ClusterIP Service %s", clusterIPSvcFQDN) - svc.Spec.ExternalName = clusterIPSvcFQDN - if err = esr.Update(ctx, svc); err != nil { - err = fmt.Errorf("error updating ExternalName Service: %w", err) - return err - } - } - r = svcConfiguredReason(svc, true, l) - st = metav1.ConditionTrue - return nil -} - -func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName string, svc, clusterIPSvc *corev1.Service, l *zap.SugaredLogger) (*corev1.Service, bool, error) { - l.Infof("updating configuration...") - usedPorts, err := esr.usedPortsForPG(ctx, proxyGroupName) - if err != nil { - return nil, false, fmt.Errorf("error calculating used ports for ProxyGroup %s: %w", proxyGroupName, err) - } - - oldClusterIPSvc := clusterIPSvc.DeepCopy() - // loop over ClusterIP Service ports, remove any that are not needed. - for i := len(clusterIPSvc.Spec.Ports) - 1; i >= 0; i-- { - pm := clusterIPSvc.Spec.Ports[i] - found := false - for _, wantsPM := range svc.Spec.Ports { - if wantsPM.Port == pm.Port && strings.EqualFold(string(wantsPM.Protocol), string(pm.Protocol)) { - found = true - break - } - } - if !found { - l.Debugf("portmapping %s:%d -> %s:%d is no longer required, removing", pm.Protocol, pm.TargetPort.IntVal, pm.Protocol, pm.Port) - clusterIPSvc.Spec.Ports = slices.Delete(clusterIPSvc.Spec.Ports, i, i+1) - } - } - - // loop over ExternalName Service ports, for each one not found on - // ClusterIP Service produce new target port and add a portmapping to - // the ClusterIP Service. - for _, wantsPM := range svc.Spec.Ports { - found := false - for _, gotPM := range clusterIPSvc.Spec.Ports { - if wantsPM.Port == gotPM.Port && strings.EqualFold(string(wantsPM.Protocol), string(gotPM.Protocol)) { - found = true - break - } - } - if !found { - // Calculate a free port to expose on container and add - // a new PortMap to the ClusterIP Service. - if usedPorts.Len() == maxPorts { - // TODO(irbekrm): refactor to avoid extra reconciles here. Low priority as in practice, - // the limit should not be hit. - return nil, false, fmt.Errorf("unable to allocate additional ports on ProxyGroup %s, %d ports already used. Create another ProxyGroup or open an issue if you believe this is unexpected.", proxyGroupName, maxPorts) - } - p := unusedPort(usedPorts) - l.Debugf("mapping tailnet target port %d to container port %d", wantsPM.Port, p) - usedPorts.Insert(p) - clusterIPSvc.Spec.Ports = append(clusterIPSvc.Spec.Ports, corev1.ServicePort{ - Name: wantsPM.Name, - Protocol: wantsPM.Protocol, - Port: wantsPM.Port, - TargetPort: intstr.FromInt32(p), - }) - } - } - if !reflect.DeepEqual(clusterIPSvc, oldClusterIPSvc) { - if clusterIPSvc, err = createOrUpdate(ctx, esr.Client, esr.tsNamespace, clusterIPSvc, func(svc *corev1.Service) { - svc.Labels = clusterIPSvc.Labels - svc.Spec = clusterIPSvc.Spec - }); err != nil { - return nil, false, fmt.Errorf("error ensuring ClusterIP Service: %v", err) - } - } - - crl := egressSvcEpsLabels(svc, clusterIPSvc) - // TODO(irbekrm): support IPv6, but need to investigate how kube proxy - // sets up Service -> Pod routing when IPv6 is involved. - eps := &discoveryv1.EndpointSlice{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-ipv4", clusterIPSvc.Name), - Namespace: esr.tsNamespace, - Labels: crl, - }, - AddressType: discoveryv1.AddressTypeIPv4, - Ports: epsPortsFromSvc(clusterIPSvc), - } - if eps, err = createOrUpdate(ctx, esr.Client, esr.tsNamespace, eps, func(e *discoveryv1.EndpointSlice) { - e.Labels = eps.Labels - e.AddressType = eps.AddressType - e.Ports = eps.Ports - for _, p := range e.Endpoints { - p.Conditions.Ready = nil - } - }); err != nil { - return nil, false, fmt.Errorf("error ensuring EndpointSlice: %w", err) - } - - cm, cfgs, err := egressSvcsConfigs(ctx, esr.Client, proxyGroupName, esr.tsNamespace) - if err != nil { - return nil, false, fmt.Errorf("error retrieving egress services configuration: %w", err) - } - if cm == nil { - l.Info("ConfigMap not yet created, waiting..") - return nil, false, nil - } - tailnetSvc := tailnetSvcName(svc) - gotCfg := (*cfgs)[tailnetSvc] - wantsCfg := egressSvcCfg(svc, clusterIPSvc) - if !reflect.DeepEqual(gotCfg, wantsCfg) { - l.Debugf("updating egress services ConfigMap %s", cm.Name) - mak.Set(cfgs, tailnetSvc, wantsCfg) - bs, err := json.Marshal(cfgs) - if err != nil { - return nil, false, fmt.Errorf("error marshalling egress services configs: %w", err) - } - mak.Set(&cm.BinaryData, egressservices.KeyEgressServices, bs) - if err := esr.Update(ctx, cm); err != nil { - return nil, false, fmt.Errorf("error updating egress services ConfigMap: %w", err) - } - } - l.Infof("egress service configuration has been updated") - return clusterIPSvc, true, nil -} - -func (esr *egressSvcsReconciler) maybeCleanup(ctx context.Context, svc *corev1.Service, logger *zap.SugaredLogger) error { - logger.Info("ensuring that resources created for egress service are deleted") - - // Delete egress service config from the ConfigMap mounted by the proxies. - if err := esr.ensureEgressSvcCfgDeleted(ctx, svc, logger); err != nil { - return fmt.Errorf("error deleting egress service config: %w", err) - } - - // Delete the ClusterIP Service and EndpointSlice for the egress - // service. - types := []client.Object{ - &corev1.Service{}, - &discoveryv1.EndpointSlice{}, - } - crl := egressSvcChildResourceLabels(svc) - for _, typ := range types { - if err := esr.DeleteAllOf(ctx, typ, client.InNamespace(esr.tsNamespace), client.MatchingLabels(crl)); err != nil { - return fmt.Errorf("error deleting %s: %w", typ, err) - } - } - - ix := slices.Index(svc.Finalizers, FinalizerName) - if ix != -1 { - logger.Debug("Removing Tailscale finalizer from Service") - svc.Finalizers = append(svc.Finalizers[:ix], svc.Finalizers[ix+1:]...) - if err := esr.Update(ctx, svc); err != nil { - return fmt.Errorf("failed to remove finalizer: %w", err) - } - } - esr.mu.Lock() - esr.svcs.Remove(svc.UID) - gaugeEgressServices.Set(int64(esr.svcs.Len())) - esr.mu.Unlock() - logger.Info("successfully cleaned up resources for egress Service") - return nil -} - -func (esr *egressSvcsReconciler) maybeCleanupProxyGroupConfig(ctx context.Context, svc *corev1.Service, l *zap.SugaredLogger) error { - wantsProxyGroup := svc.Annotations[AnnotationProxyGroup] - cond := tsoperator.GetServiceCondition(svc, tsapi.EgressSvcConfigured) - if cond == nil { - return nil - } - ss := strings.Split(cond.Reason, ":") - if len(ss) < 3 { - return nil - } - if strings.EqualFold(wantsProxyGroup, ss[2]) { - return nil - } - esr.logger.Infof("egress Service configured on ProxyGroup %s, wants ProxyGroup %s, cleaning up...", ss[2], wantsProxyGroup) - if err := esr.ensureEgressSvcCfgDeleted(ctx, svc, l); err != nil { - return fmt.Errorf("error deleting egress service config: %w", err) - } - return nil -} - -// usedPortsForPG calculates the currently used match ports for ProxyGroup -// containers. It does that by looking by retrieving all target ports of all -// ClusterIP Services created for egress services exposed on this ProxyGroup's -// proxies. -// TODO(irbekrm): this is currently good enough because we only have a single worker and -// because these Services are created by us, so we can always expect to get the -// latest ClusterIP Services via the controller cache. It will not work as well -// once we split into multiple workers- at that point we probably want to set -// used ports on ProxyGroup's status. -func (esr *egressSvcsReconciler) usedPortsForPG(ctx context.Context, pg string) (sets.Set[int32], error) { - svcList := &corev1.ServiceList{} - if err := esr.List(ctx, svcList, client.InNamespace(esr.tsNamespace), client.MatchingLabels(map[string]string{labelProxyGroup: pg})); err != nil { - return nil, fmt.Errorf("error listing Services: %w", err) - } - usedPorts := sets.New[int32]() - for _, s := range svcList.Items { - for _, p := range s.Spec.Ports { - usedPorts.Insert(p.TargetPort.IntVal) - } - } - return usedPorts, nil -} - -// clusterIPSvcForEgress returns a template for the ClusterIP Service created -// for an egress service exposed on ProxyGroup proxies. The ClusterIP Service -// has no selector. Traffic sent to it will be routed to the endpoints defined -// by an EndpointSlice created for this egress service. -func (esr *egressSvcsReconciler) clusterIPSvcForEgress(crl map[string]string) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: svcNameBase(crl[LabelParentName]), - Namespace: esr.tsNamespace, - Labels: crl, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - }, - } -} - -func (esr *egressSvcsReconciler) ensureEgressSvcCfgDeleted(ctx context.Context, svc *corev1.Service, logger *zap.SugaredLogger) error { - crl := egressSvcChildResourceLabels(svc) - cmName := pgEgressCMName(crl[labelProxyGroup]) - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: cmName, - Namespace: esr.tsNamespace, - }, - } - l := logger.With("ConfigMap", client.ObjectKeyFromObject(cm)) - l.Debug("ensuring that egress service configuration is removed from proxy config") - if err := esr.Get(ctx, client.ObjectKeyFromObject(cm), cm); apierrors.IsNotFound(err) { - l.Debugf("ConfigMap not found") - return nil - } else if err != nil { - return fmt.Errorf("error retrieving ConfigMap: %w", err) - } - bs := cm.BinaryData[egressservices.KeyEgressServices] - if len(bs) == 0 { - l.Debugf("ConfigMap does not contain egress service configs") - return nil - } - cfgs := &egressservices.Configs{} - if err := json.Unmarshal(bs, cfgs); err != nil { - return fmt.Errorf("error unmarshalling egress services configs") - } - tailnetSvc := tailnetSvcName(svc) - _, ok := (*cfgs)[tailnetSvc] - if !ok { - l.Debugf("ConfigMap does not contain egress service config, likely because it was already deleted") - return nil - } - l.Infof("before deleting config %+#v", *cfgs) - delete(*cfgs, tailnetSvc) - l.Infof("after deleting config %+#v", *cfgs) - bs, err := json.Marshal(cfgs) - if err != nil { - return fmt.Errorf("error marshalling egress services configs: %w", err) - } - mak.Set(&cm.BinaryData, egressservices.KeyEgressServices, bs) - return esr.Update(ctx, cm) -} - -func (esr *egressSvcsReconciler) validateClusterResources(ctx context.Context, svc *corev1.Service, l *zap.SugaredLogger) (bool, error) { - proxyGroupName := svc.Annotations[AnnotationProxyGroup] - pg := &tsapi.ProxyGroup{ - ObjectMeta: metav1.ObjectMeta{ - Name: proxyGroupName, - }, - } - if err := esr.Get(ctx, client.ObjectKeyFromObject(pg), pg); apierrors.IsNotFound(err) { - l.Infof("ProxyGroup %q not found, waiting...", proxyGroupName) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, reasonProxyGroupNotReady, esr.clock, l) - tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured) - return false, nil - } else if err != nil { - err := fmt.Errorf("unable to retrieve ProxyGroup %s: %w", proxyGroupName, err) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, err.Error(), esr.clock, l) - tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured) - return false, err - } - if !tsoperator.ProxyGroupIsReady(pg) { - l.Infof("ProxyGroup %s is not ready, waiting...", proxyGroupName) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, reasonProxyGroupNotReady, esr.clock, l) - tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured) - return false, nil - } - - if violations := validateEgressService(svc, pg); len(violations) > 0 { - msg := fmt.Sprintf("invalid egress Service: %s", strings.Join(violations, ", ")) - esr.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVICE", msg) - l.Info(msg) - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionFalse, reasonEgressSvcInvalid, msg, esr.clock, l) - tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured) - return false, nil - } - l.Debugf("egress service is valid") - tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionTrue, reasonEgressSvcValid, reasonEgressSvcValid, esr.clock, l) - return true, nil -} - -func validateEgressService(svc *corev1.Service, pg *tsapi.ProxyGroup) []string { - violations := validateService(svc) - - // We check that only one of these two is set in the earlier validateService function. - if svc.Annotations[AnnotationTailnetTargetFQDN] == "" && svc.Annotations[AnnotationTailnetTargetIP] == "" { - violations = append(violations, fmt.Sprintf("egress Service for ProxyGroup must have one of %s, %s annotations set", AnnotationTailnetTargetFQDN, AnnotationTailnetTargetIP)) - } - if len(svc.Spec.Ports) == 0 { - violations = append(violations, "egress Service for ProxyGroup must have at least one target Port specified") - } - if svc.Spec.Type != corev1.ServiceTypeExternalName { - violations = append(violations, fmt.Sprintf("unexpected egress Service type %s. The only supported type is ExternalName.", svc.Spec.Type)) - } - if pg.Spec.Type != tsapi.ProxyGroupTypeEgress { - violations = append(violations, fmt.Sprintf("egress Service references ProxyGroup of type %s, must be type %s", pg.Spec.Type, tsapi.ProxyGroupTypeEgress)) - } - return violations -} - -// egressSvcNameBase returns a name base that can be passed to -// ObjectMeta.GenerateName to generate a name for the ClusterIP Service. -// The generated name needs to be short enough so that it can later be used to -// generate a valid Kubernetes resource name for the EndpointSlice in form -// 'ipv4-|ipv6-. -// A valid Kubernetes resource name must not be longer than 253 chars. -func svcNameBase(s string) string { - // -ipv4 - ipv6 - const maxClusterIPSvcNameLength = 253 - 5 - base := fmt.Sprintf("ts-%s-", s) - generator := names.SimpleNameGenerator - for { - generatedName := generator.GenerateName(base) - excess := len(generatedName) - maxClusterIPSvcNameLength - if excess <= 0 { - return base - } - base = base[:len(base)-1-excess] // cut off the excess chars - base = base + "-" // re-instate the dash - } -} - -// unusedPort returns a port in range [3000 - 4000). The caller must ensure that -// usedPorts does not contain all ports in range [3000 - 4000). -func unusedPort(usedPorts sets.Set[int32]) int32 { - foundFreePort := false - var suggestPort int32 - for !foundFreePort { - suggestPort = rand.Int32N(maxPorts) + 3000 - if !usedPorts.Has(suggestPort) { - foundFreePort = true - } - } - return suggestPort -} - -// tailnetTargetFromSvc returns a tailnet target for the given egress Service. -// Service must contain exactly one of tailscale.com/tailnet-ip, -// tailscale.com/tailnet-fqdn annotations. -func tailnetTargetFromSvc(svc *corev1.Service) egressservices.TailnetTarget { - if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" { - return egressservices.TailnetTarget{ - FQDN: fqdn, - } - } - return egressservices.TailnetTarget{ - IP: svc.Annotations[AnnotationTailnetTargetIP], - } -} - -func egressSvcCfg(externalNameSvc, clusterIPSvc *corev1.Service) egressservices.Config { - tt := tailnetTargetFromSvc(externalNameSvc) - cfg := egressservices.Config{TailnetTarget: tt} - for _, svcPort := range clusterIPSvc.Spec.Ports { - pm := portMap(svcPort) - mak.Set(&cfg.Ports, pm, struct{}{}) - } - return cfg -} - -func portMap(p corev1.ServicePort) egressservices.PortMap { - // TODO (irbekrm): out of bounds check? - return egressservices.PortMap{Protocol: string(p.Protocol), MatchPort: uint16(p.TargetPort.IntVal), TargetPort: uint16(p.Port)} -} - -func isEgressSvcForProxyGroup(obj client.Object) bool { - s, ok := obj.(*corev1.Service) - if !ok { - return false - } - annots := s.ObjectMeta.Annotations - return annots[AnnotationProxyGroup] != "" && (annots[AnnotationTailnetTargetFQDN] != "" || annots[AnnotationTailnetTargetIP] != "") -} - -// egressSvcConfig returns a ConfigMap that contains egress services configuration for the provided ProxyGroup as well -// as unmarshalled configuration from the ConfigMap. -func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, tsNamespace string) (cm *corev1.ConfigMap, cfgs *egressservices.Configs, err error) { - name := pgEgressCMName(proxyGroupName) - cm = &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: tsNamespace, - }, - } - if err := cl.Get(ctx, client.ObjectKeyFromObject(cm), cm); err != nil { - return nil, nil, fmt.Errorf("error retrieving egress services ConfigMap %s: %v", name, err) - } - cfgs = &egressservices.Configs{} - if len(cm.BinaryData[egressservices.KeyEgressServices]) != 0 { - if err := json.Unmarshal(cm.BinaryData[egressservices.KeyEgressServices], cfgs); err != nil { - return nil, nil, fmt.Errorf("error unmarshaling egress services config %v: %w", cm.BinaryData[egressservices.KeyEgressServices], err) - } - } - return cm, cfgs, nil -} - -// egressSvcChildResourceLabels returns labels that should be applied to the -// ClusterIP Service and the EndpointSlice created for the egress service. -// TODO(irbekrm): we currently set a bunch of labels based on Kubernetes -// resource names (ProxyGroup, Service). Maximum allowed label length is 63 -// chars whilst the maximum allowed resource name length is 253 chars, so we -// should probably validate and truncate (?) the names is they are too long. -func egressSvcChildResourceLabels(svc *corev1.Service) map[string]string { - return map[string]string{ - LabelManaged: "true", - LabelParentType: "svc", - LabelParentName: svc.Name, - LabelParentNamespace: svc.Namespace, - labelProxyGroup: svc.Annotations[AnnotationProxyGroup], - labelSvcType: typeEgress, - } -} - -// egressEpsLabels returns labels to be added to an EndpointSlice created for an egress service. -func egressSvcEpsLabels(extNSvc, clusterIPSvc *corev1.Service) map[string]string { - l := egressSvcChildResourceLabels(extNSvc) - // Adding this label is what makes kube proxy set up rules to route traffic sent to the clusterIP Service to the - // endpoints defined on this EndpointSlice. - // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership - l[discoveryv1.LabelServiceName] = clusterIPSvc.Name - // Kubernetes recommends setting this label. - // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#management - l[discoveryv1.LabelManagedBy] = "tailscale.com" - return l -} - -func svcConfigurationUpToDate(svc *corev1.Service, l *zap.SugaredLogger) bool { - cond := tsoperator.GetServiceCondition(svc, tsapi.EgressSvcConfigured) - if cond == nil { - return false - } - if cond.Status != metav1.ConditionTrue { - return false - } - wantsReadyReason := svcConfiguredReason(svc, true, l) - return strings.EqualFold(wantsReadyReason, cond.Reason) -} - -func cfgHash(c cfg, l *zap.SugaredLogger) string { - bs, err := json.Marshal(c) - if err != nil { - // Don't use l.Error as that messes up component logs with, in this case, unnecessary stack trace. - l.Infof("error marhsalling Config: %v", err) - return "" - } - h := sha256.New() - if _, err := h.Write(bs); err != nil { - // Don't use l.Error as that messes up component logs with, in this case, unnecessary stack trace. - l.Infof("error producing Config hash: %v", err) - return "" - } - return fmt.Sprintf("%x", h.Sum(nil)) -} - -type cfg struct { - Ports []corev1.ServicePort `json:"ports"` - TailnetTarget egressservices.TailnetTarget `json:"tailnetTarget"` - ProxyGroup string `json:"proxyGroup"` -} - -func svcConfiguredReason(svc *corev1.Service, configured bool, l *zap.SugaredLogger) string { - var r string - if configured { - r = "ConfiguredFor:" - } else { - r = fmt.Sprintf("ConfigurationFailed:%s", r) - } - r += fmt.Sprintf("ProxyGroup:%s", svc.Annotations[AnnotationProxyGroup]) - tt := tailnetTargetFromSvc(svc) - s := cfg{ - Ports: svc.Spec.Ports, - TailnetTarget: tt, - ProxyGroup: svc.Annotations[AnnotationProxyGroup], - } - r += fmt.Sprintf(":Config:%s", cfgHash(s, l)) - return r -} - -// tailnetSvc accepts and ExternalName Service name and returns a name that will be used to distinguish this tailnet -// service from other tailnet services exposed to cluster workloads. -func tailnetSvcName(extNSvc *corev1.Service) string { - return fmt.Sprintf("%s-%s", extNSvc.Namespace, extNSvc.Name) -} - -// epsPortsFromSvc takes the ClusterIP Service created for an egress service and -// returns its Port array in a form that can be used for an EndpointSlice. -func epsPortsFromSvc(svc *corev1.Service) (ep []discoveryv1.EndpointPort) { - for _, p := range svc.Spec.Ports { - ep = append(ep, discoveryv1.EndpointPort{ - Protocol: &p.Protocol, - Port: &p.TargetPort.IntVal, - Name: &p.Name, - }) - } - return ep -} diff --git a/cmd/k8s-operator/egress-services_test.go b/cmd/k8s-operator/egress-services_test.go deleted file mode 100644 index ac77339853ebe..0000000000000 --- a/cmd/k8s-operator/egress-services_test.go +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "fmt" - "testing" - - "github.com/AlekSi/pointer" - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/egressservices" - "tailscale.com/tstest" - "tailscale.com/tstime" -) - -func TestTailscaleEgressServices(t *testing.T) { - pg := &tsapi.ProxyGroup{ - TypeMeta: metav1.TypeMeta{Kind: "ProxyGroup", APIVersion: "tailscale.com/v1alpha1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "foo", - UID: types.UID("1234-UID"), - }, - Spec: tsapi.ProxyGroupSpec{ - Replicas: pointer.To[int32](3), - Type: tsapi.ProxyGroupTypeEgress, - }, - } - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: pgEgressCMName("foo"), - Namespace: "operator-ns", - }, - } - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(pg, cm). - WithStatusSubresource(pg). - Build() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - - esr := &egressSvcsReconciler{ - Client: fc, - logger: zl.Sugar(), - clock: clock, - tsNamespace: "operator-ns", - } - tailnetTargetFQDN := "foo.bar.ts.net." - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetFQDN: tailnetTargetFQDN, - AnnotationProxyGroup: "foo", - }, - }, - Spec: corev1.ServiceSpec{ - ExternalName: "placeholder", - Type: corev1.ServiceTypeExternalName, - Selector: nil, - Ports: []corev1.ServicePort{ - { - Name: "http", - Protocol: "TCP", - Port: 80, - }, - { - Name: "https", - Protocol: "TCP", - Port: 443, - }, - }, - }, - } - - t.Run("proxy_group_not_ready", func(t *testing.T) { - mustCreate(t, fc, svc) - expectReconciled(t, esr, "default", "test") - // Service should have EgressSvcValid condition set to Unknown. - svc.Status.Conditions = []metav1.Condition{condition(tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, reasonProxyGroupNotReady, clock)} - expectEqual(t, fc, svc, nil) - }) - - t.Run("proxy_group_ready", func(t *testing.T) { - mustUpdateStatus(t, fc, "", "foo", func(pg *tsapi.ProxyGroup) { - pg.Status.Conditions = []metav1.Condition{ - condition(tsapi.ProxyGroupReady, metav1.ConditionTrue, "", "", clock), - } - }) - // Quirks of the fake client. - mustUpdateStatus(t, fc, "default", "test", func(svc *corev1.Service) { - svc.Status.Conditions = []metav1.Condition{} - }) - expectReconciled(t, esr, "default", "test") - // Verify that a ClusterIP Service has been created. - name := findGenNameForEgressSvcResources(t, fc, svc) - expectEqual(t, fc, clusterIPSvc(name, svc), removeTargetPortsFromSvc) - clusterSvc := mustGetClusterIPSvc(t, fc, name) - // Verify that an EndpointSlice has been created. - expectEqual(t, fc, endpointSlice(name, svc, clusterSvc), nil) - // Verify that ConfigMap contains configuration for the new egress service. - mustHaveConfigForSvc(t, fc, svc, clusterSvc, cm) - r := svcConfiguredReason(svc, true, zl.Sugar()) - // Verify that the user-created ExternalName Service has Configured set to true and ExternalName pointing to the - // CluterIP Service. - svc.Status.Conditions = []metav1.Condition{ - condition(tsapi.EgressSvcConfigured, metav1.ConditionTrue, r, r, clock), - } - svc.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"} - svc.Spec.ExternalName = fmt.Sprintf("%s.operator-ns.svc.cluster.local", name) - expectEqual(t, fc, svc, nil) - }) - - t.Run("delete_external_name_service", func(t *testing.T) { - name := findGenNameForEgressSvcResources(t, fc, svc) - if err := fc.Delete(context.Background(), svc); err != nil { - t.Fatalf("error deleting ExternalName Service: %v", err) - } - expectReconciled(t, esr, "default", "test") - // Verify that ClusterIP Service and EndpointSlice have been deleted. - expectMissing[corev1.Service](t, fc, "operator-ns", name) - expectMissing[discoveryv1.EndpointSlice](t, fc, "operator-ns", fmt.Sprintf("%s-ipv4", name)) - // Verify that service config has been deleted from the ConfigMap. - mustNotHaveConfigForSvc(t, fc, svc, cm) - }) -} - -func condition(typ tsapi.ConditionType, st metav1.ConditionStatus, r, msg string, clock tstime.Clock) metav1.Condition { - return metav1.Condition{ - Type: string(typ), - Status: st, - LastTransitionTime: conditionTime(clock), - Reason: r, - Message: msg, - } -} - -func findGenNameForEgressSvcResources(t *testing.T, client client.Client, svc *corev1.Service) string { - t.Helper() - labels := egressSvcChildResourceLabels(svc) - s, err := getSingleObject[corev1.Service](context.Background(), client, "operator-ns", labels) - if err != nil { - t.Fatalf("finding ClusterIP Service for ExternalName Service %s: %v", svc.Name, err) - } - if s == nil { - t.Fatalf("no ClusterIP Service found for ExternalName Service %q", svc.Name) - } - return s.GetName() -} - -func clusterIPSvc(name string, extNSvc *corev1.Service) *corev1.Service { - labels := egressSvcChildResourceLabels(extNSvc) - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: "operator-ns", - GenerateName: fmt.Sprintf("ts-%s-", extNSvc.Name), - Labels: labels, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Ports: extNSvc.Spec.Ports, - }, - } -} - -func mustGetClusterIPSvc(t *testing.T, cl client.Client, name string) *corev1.Service { - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: "operator-ns", - }, - } - if err := cl.Get(context.Background(), client.ObjectKeyFromObject(svc), svc); err != nil { - t.Fatalf("error retrieving Service") - } - return svc -} - -func endpointSlice(name string, extNSvc, clusterIPSvc *corev1.Service) *discoveryv1.EndpointSlice { - labels := egressSvcChildResourceLabels(extNSvc) - labels[discoveryv1.LabelManagedBy] = "tailscale.com" - labels[discoveryv1.LabelServiceName] = name - return &discoveryv1.EndpointSlice{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-ipv4", name), - Namespace: "operator-ns", - Labels: labels, - }, - Ports: portsForEndpointSlice(clusterIPSvc), - AddressType: discoveryv1.AddressTypeIPv4, - } -} - -func portsForEndpointSlice(svc *corev1.Service) []discoveryv1.EndpointPort { - ports := make([]discoveryv1.EndpointPort, 0) - for _, p := range svc.Spec.Ports { - ports = append(ports, discoveryv1.EndpointPort{ - Name: &p.Name, - Protocol: &p.Protocol, - Port: pointer.ToInt32(p.TargetPort.IntVal), - }) - } - return ports -} - -func mustHaveConfigForSvc(t *testing.T, cl client.Client, extNSvc, clusterIPSvc *corev1.Service, cm *corev1.ConfigMap) { - t.Helper() - wantsCfg := egressSvcCfg(extNSvc, clusterIPSvc) - if err := cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm); err != nil { - t.Fatalf("Error retrieving ConfigMap: %v", err) - } - name := tailnetSvcName(extNSvc) - gotCfg := configFromCM(t, cm, name) - if gotCfg == nil { - t.Fatalf("No config found for service %q", name) - } - if diff := cmp.Diff(*gotCfg, wantsCfg); diff != "" { - t.Fatalf("unexpected config for service %q (-got +want):\n%s", name, diff) - } -} - -func mustNotHaveConfigForSvc(t *testing.T, cl client.Client, extNSvc *corev1.Service, cm *corev1.ConfigMap) { - t.Helper() - if err := cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm); err != nil { - t.Fatalf("Error retrieving ConfigMap: %v", err) - } - name := tailnetSvcName(extNSvc) - gotCfg := configFromCM(t, cm, name) - if gotCfg != nil { - t.Fatalf("Config %#+v for service %q found when it should not be present", gotCfg, name) - } -} - -func configFromCM(t *testing.T, cm *corev1.ConfigMap, svcName string) *egressservices.Config { - t.Helper() - cfgBs, ok := cm.BinaryData[egressservices.KeyEgressServices] - if !ok { - return nil - } - cfgs := &egressservices.Configs{} - if err := json.Unmarshal(cfgBs, cfgs); err != nil { - t.Fatalf("error unmarshalling config: %v", err) - } - cfg, ok := (*cfgs)[svcName] - if ok { - return &cfg - } - return nil -} diff --git a/cmd/k8s-operator/generate/main.go b/cmd/k8s-operator/generate/main.go deleted file mode 100644 index 25435a47cf14a..0000000000000 --- a/cmd/k8s-operator/generate/main.go +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// The generate command creates tailscale.com CRDs. -package main - -import ( - "bytes" - "fmt" - "io" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - - "gopkg.in/yaml.v3" -) - -const ( - operatorDeploymentFilesPath = "cmd/k8s-operator/deploy" - connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml" - proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml" - dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml" - recorderCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_recorders.yaml" - proxyGroupCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxygroups.yaml" - helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates" - connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml" - proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml" - dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml" - recorderCRDHelmTemplatePath = helmTemplatesPath + "/recorder.yaml" - proxyGroupCRDHelmTemplatePath = helmTemplatesPath + "/proxygroup.yaml" - - helmConditionalStart = "{{ if .Values.installCRDs -}}\n" - helmConditionalEnd = "{{- end -}}" -) - -func main() { - if len(os.Args) < 2 { - log.Fatalf("usage ./generate [staticmanifests|helmcrd]") - } - repoRoot := "../../" - switch os.Args[1] { - case "helmcrd": // insert CRDs to Helm templates behind a installCRDs=true conditional check - log.Print("Adding CRDs to Helm templates") - if err := generate("./"); err != nil { - log.Fatalf("error adding CRDs to Helm templates: %v", err) - } - return - case "staticmanifests": // generate static manifests from Helm templates (including the CRD) - default: - log.Fatalf("unknown option %s, known options are 'staticmanifests', 'helmcrd'", os.Args[1]) - } - log.Printf("Inserting CRDs Helm templates") - if err := generate(repoRoot); err != nil { - log.Fatalf("error adding CRDs to Helm templates: %v", err) - } - defer func() { - if err := cleanup(repoRoot); err != nil { - log.Fatalf("error cleaning up generated resources") - } - }() - log.Print("Templating Helm chart contents") - helmTmplCmd := exec.Command("./tool/helm", "template", "operator", "./cmd/k8s-operator/deploy/chart", - "--namespace=tailscale") - helmTmplCmd.Dir = repoRoot - var out bytes.Buffer - helmTmplCmd.Stdout = &out - helmTmplCmd.Stderr = os.Stderr - if err := helmTmplCmd.Run(); err != nil { - log.Fatalf("error templating helm manifests: %v", err) - } - - var final bytes.Buffer - - templatePath := filepath.Join(repoRoot, "cmd/k8s-operator/deploy/manifests/templates") - fileInfos, err := os.ReadDir(templatePath) - if err != nil { - log.Fatalf("error reading templates: %v", err) - } - for _, fi := range fileInfos { - templateBytes, err := os.ReadFile(filepath.Join(templatePath, fi.Name())) - if err != nil { - log.Fatalf("error reading template: %v", err) - } - final.Write(templateBytes) - } - decoder := yaml.NewDecoder(&out) - for { - var document any - err := decoder.Decode(&document) - if err == io.EOF { - break - } - if err != nil { - log.Fatalf("failed read from input data: %v", err) - } - bytes, err := yaml.Marshal(document) - if err != nil { - log.Fatalf("failed to marshal YAML document: %v", err) - } - if strings.TrimSpace(string(bytes)) == "null" { - continue - } - if _, err = final.Write(bytes); err != nil { - log.Fatalf("error marshaling yaml: %v", err) - } - fmt.Fprint(&final, "---\n") - } - finalString, _ := strings.CutSuffix(final.String(), "---\n") - if err := os.WriteFile(filepath.Join(repoRoot, "cmd/k8s-operator/deploy/manifests/operator.yaml"), []byte(finalString), 0664); err != nil { - log.Fatalf("error writing new file: %v", err) - } -} - -// generate places tailscale.com CRDs (currently Connector, ProxyClass, DNSConfig, Recorder) into -// the Helm chart templates behind .Values.installCRDs=true condition (true by -// default). -func generate(baseDir string) error { - addCRDToHelm := func(crdPath, crdTemplatePath string) error { - chartBytes, err := os.ReadFile(filepath.Join(baseDir, crdPath)) - if err != nil { - return fmt.Errorf("error reading CRD contents: %w", err) - } - // Place a new temporary Helm template file with the templated CRD - // contents into Helm templates. - file, err := os.Create(filepath.Join(baseDir, crdTemplatePath)) - if err != nil { - return fmt.Errorf("error creating CRD template file: %w", err) - } - if _, err := file.Write([]byte(helmConditionalStart)); err != nil { - return fmt.Errorf("error writing helm if statement start: %w", err) - } - if _, err := file.Write(chartBytes); err != nil { - return fmt.Errorf("error writing chart bytes: %w", err) - } - if _, err := file.Write([]byte(helmConditionalEnd)); err != nil { - return fmt.Errorf("error writing helm if-statement end: %w", err) - } - return nil - } - for _, crd := range []struct { - crdPath, templatePath string - }{ - {connectorCRDPath, connectorCRDHelmTemplatePath}, - {proxyClassCRDPath, proxyClassCRDHelmTemplatePath}, - {dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath}, - {recorderCRDPath, recorderCRDHelmTemplatePath}, - {proxyGroupCRDPath, proxyGroupCRDHelmTemplatePath}, - } { - if err := addCRDToHelm(crd.crdPath, crd.templatePath); err != nil { - return fmt.Errorf("error adding %s CRD to Helm templates: %w", crd.crdPath, err) - } - } - return nil -} - -func cleanup(baseDir string) error { - log.Print("Cleaning up CRD from Helm templates") - for _, path := range []string{ - connectorCRDHelmTemplatePath, - proxyClassCRDHelmTemplatePath, - dnsConfigCRDHelmTemplatePath, - recorderCRDHelmTemplatePath, - proxyGroupCRDHelmTemplatePath, - } { - if err := os.Remove(filepath.Join(baseDir, path)); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("error cleaning up %s: %w", path, err) - } - } - return nil -} diff --git a/cmd/k8s-operator/generate/main_test.go b/cmd/k8s-operator/generate/main_test.go deleted file mode 100644 index c7956dcdbef8f..0000000000000 --- a/cmd/k8s-operator/generate/main_test.go +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 && !windows - -package main - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func Test_generate(t *testing.T) { - base, err := os.Getwd() - base = filepath.Join(base, "../../../") - if err != nil { - t.Fatalf("error getting current working directory: %v", err) - } - defer cleanup(base) - if err := generate(base); err != nil { - t.Fatalf("CRD template generation: %v", err) - } - - tempDir := t.TempDir() - helmCLIPath := filepath.Join(base, "tool/helm") - helmChartTemplatesPath := filepath.Join(base, "cmd/k8s-operator/deploy/chart") - helmPackageCmd := exec.Command(helmCLIPath, "package", helmChartTemplatesPath, "--destination", tempDir, "--version", "0.0.1") - helmPackageCmd.Stderr = os.Stderr - helmPackageCmd.Stdout = os.Stdout - if err := helmPackageCmd.Run(); err != nil { - t.Fatalf("error packaging Helm chart: %v", err) - } - helmPackagePath := filepath.Join(tempDir, "tailscale-operator-0.0.1.tgz") - helmLintCmd := exec.Command(helmCLIPath, "lint", helmPackagePath) - helmLintCmd.Stderr = os.Stderr - helmLintCmd.Stdout = os.Stdout - if err := helmLintCmd.Run(); err != nil { - t.Fatalf("Helm chart linter failed: %v", err) - } - - // Test that default Helm install contains the Connector and ProxyClass CRDs. - installContentsWithCRD := bytes.NewBuffer([]byte{}) - helmTemplateWithCRDCmd := exec.Command(helmCLIPath, "template", helmPackagePath) - helmTemplateWithCRDCmd.Stderr = os.Stderr - helmTemplateWithCRDCmd.Stdout = installContentsWithCRD - if err := helmTemplateWithCRDCmd.Run(); err != nil { - t.Fatalf("templating Helm chart with CRDs failed: %v", err) - } - if !strings.Contains(installContentsWithCRD.String(), "name: connectors.tailscale.com") { - t.Errorf("Connector CRD not found in default chart install") - } - if !strings.Contains(installContentsWithCRD.String(), "name: proxyclasses.tailscale.com") { - t.Errorf("ProxyClass CRD not found in default chart install") - } - if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") { - t.Errorf("DNSConfig CRD not found in default chart install") - } - if !strings.Contains(installContentsWithCRD.String(), "name: recorders.tailscale.com") { - t.Errorf("Recorder CRD not found in default chart install") - } - if !strings.Contains(installContentsWithCRD.String(), "name: proxygroups.tailscale.com") { - t.Errorf("ProxyGroup CRD not found in default chart install") - } - - // Test that CRDs can be excluded from Helm chart install - installContentsWithoutCRD := bytes.NewBuffer([]byte{}) - helmTemplateWithoutCRDCmd := exec.Command(helmCLIPath, "template", helmPackagePath, "--set", "installCRDs=false") - helmTemplateWithoutCRDCmd.Stderr = os.Stderr - helmTemplateWithoutCRDCmd.Stdout = installContentsWithoutCRD - if err := helmTemplateWithoutCRDCmd.Run(); err != nil { - t.Fatalf("templating Helm chart without CRDs failed: %v", err) - } - if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") { - t.Errorf("Connector CRD found in chart install that should not contain a CRD") - } - if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") { - t.Errorf("ProxyClass CRD found in chart install that should not contain a CRD") - } - if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") { - t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD") - } - if strings.Contains(installContentsWithoutCRD.String(), "name: recorders.tailscale.com") { - t.Errorf("Recorder CRD found in chart install that should not contain a CRD") - } - if strings.Contains(installContentsWithoutCRD.String(), "name: proxygroups.tailscale.com") { - t.Errorf("ProxyGroup CRD found in chart install that should not contain a CRD") - } -} diff --git a/cmd/k8s-operator/ingress.go b/cmd/k8s-operator/ingress.go deleted file mode 100644 index acc90d465093a..0000000000000 --- a/cmd/k8s-operator/ingress.go +++ /dev/null @@ -1,340 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "fmt" - "slices" - "strings" - "sync" - - "github.com/pkg/errors" - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "tailscale.com/ipn" - "tailscale.com/kube/kubetypes" - "tailscale.com/types/opt" - "tailscale.com/util/clientmetric" - "tailscale.com/util/set" -) - -const ( - tailscaleIngressClassName = "tailscale" // ingressClass.metadata.name for tailscale IngressClass resource - tailscaleIngressControllerName = "tailscale.com/ts-ingress" // ingressClass.spec.controllerName for tailscale IngressClass resource - ingressClassDefaultAnnotation = "ingressclass.kubernetes.io/is-default-class" // we do not support this https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class -) - -type IngressReconciler struct { - client.Client - - recorder record.EventRecorder - ssr *tailscaleSTSReconciler - logger *zap.SugaredLogger - - mu sync.Mutex // protects following - - // managedIngresses is a set of all ingress resources that we're currently - // managing. This is only used for metrics. - managedIngresses set.Slice[types.UID] - - defaultProxyClass string -} - -var ( - // gaugeIngressResources tracks the number of ingress resources that we're - // currently managing. - gaugeIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressResourceCount) -) - -func (a *IngressReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { - logger := a.logger.With("ingress-ns", req.Namespace, "ingress-name", req.Name) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - ing := new(networkingv1.Ingress) - err = a.Get(ctx, req.NamespacedName, ing) - if apierrors.IsNotFound(err) { - // Request object not found, could have been deleted after reconcile request. - logger.Debugf("ingress not found, assuming it was deleted") - return reconcile.Result{}, nil - } else if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get ing: %w", err) - } - if !ing.DeletionTimestamp.IsZero() || !a.shouldExpose(ing) { - logger.Debugf("ingress is being deleted or should not be exposed, cleaning up") - return reconcile.Result{}, a.maybeCleanup(ctx, logger, ing) - } - - return reconcile.Result{}, a.maybeProvision(ctx, logger, ing) -} - -func (a *IngressReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, ing *networkingv1.Ingress) error { - ix := slices.Index(ing.Finalizers, FinalizerName) - if ix < 0 { - logger.Debugf("no finalizer, nothing to do") - a.mu.Lock() - defer a.mu.Unlock() - a.managedIngresses.Remove(ing.UID) - gaugeIngressResources.Set(int64(a.managedIngresses.Len())) - return nil - } - - if done, err := a.ssr.Cleanup(ctx, logger, childResourceLabels(ing.Name, ing.Namespace, "ingress")); err != nil { - return fmt.Errorf("failed to cleanup: %w", err) - } else if !done { - logger.Debugf("cleanup not done yet, waiting for next reconcile") - return nil - } - - ing.Finalizers = append(ing.Finalizers[:ix], ing.Finalizers[ix+1:]...) - if err := a.Update(ctx, ing); err != nil { - return fmt.Errorf("failed to remove finalizer: %w", err) - } - - // Unlike most log entries in the reconcile loop, this will get printed - // exactly once at the very end of cleanup, because the final step of - // cleanup removes the tailscale finalizer, which will make all future - // reconciles exit early. - logger.Infof("unexposed ingress from tailnet") - a.mu.Lock() - defer a.mu.Unlock() - a.managedIngresses.Remove(ing.UID) - gaugeIngressResources.Set(int64(a.managedIngresses.Len())) - return nil -} - -// maybeProvision ensures that ing is exposed over tailscale, taking any actions -// necessary to reach that state. -// -// This function adds a finalizer to ing, ensuring that we can handle orderly -// deprovisioning later. -func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, ing *networkingv1.Ingress) error { - if err := a.validateIngressClass(ctx); err != nil { - logger.Warnf("error validating tailscale IngressClass: %v. In future this might be a terminal error.", err) - - } - if !slices.Contains(ing.Finalizers, FinalizerName) { - // This log line is printed exactly once during initial provisioning, - // because once the finalizer is in place this block gets skipped. So, - // this is a nice place to tell the operator that the high level, - // multi-reconcile operation is underway. - logger.Infof("exposing ingress over tailscale") - ing.Finalizers = append(ing.Finalizers, FinalizerName) - if err := a.Update(ctx, ing); err != nil { - return fmt.Errorf("failed to add finalizer: %w", err) - } - } - - proxyClass := proxyClassForObject(ing, a.defaultProxyClass) - if proxyClass != "" { - if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil { - return fmt.Errorf("error verifying ProxyClass for Ingress: %w", err) - } else if !ready { - logger.Infof("ProxyClass %s specified for the Ingress, but is not (yet) Ready, waiting..", proxyClass) - return nil - } - } - - a.mu.Lock() - a.managedIngresses.Add(ing.UID) - gaugeIngressResources.Set(int64(a.managedIngresses.Len())) - a.mu.Unlock() - - if !a.ssr.IsHTTPSEnabledOnTailnet() { - a.recorder.Event(ing, corev1.EventTypeWarning, "HTTPSNotEnabled", "HTTPS is not enabled on the tailnet; ingress may not work") - } - - // magic443 is a fake hostname that we can use to tell containerboot to swap - // out with the real hostname once it's known. - const magic443 = "${TS_CERT_DOMAIN}:443" - sc := &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - HTTPS: true, - }, - }, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - magic443: { - Handlers: map[string]*ipn.HTTPHandler{}, - }, - }, - } - if opt.Bool(ing.Annotations[AnnotationFunnel]).EqualBool(true) { - sc.AllowFunnel = map[ipn.HostPort]bool{ - magic443: true, - } - } - - web := sc.Web[magic443] - addIngressBackend := func(b *networkingv1.IngressBackend, path string) { - if b == nil { - return - } - if b.Service == nil { - a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q is missing service", path) - return - } - var svc corev1.Service - if err := a.Get(ctx, types.NamespacedName{Namespace: ing.Namespace, Name: b.Service.Name}, &svc); err != nil { - a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "failed to get service %q for path %q: %v", b.Service.Name, path, err) - return - } - if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" { - a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q has invalid ClusterIP", path) - return - } - var port int32 - if b.Service.Port.Name != "" { - for _, p := range svc.Spec.Ports { - if p.Name == b.Service.Port.Name { - port = p.Port - break - } - } - } else { - port = b.Service.Port.Number - } - if port == 0 { - a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "backend for path %q has invalid port", path) - return - } - proto := "http://" - if port == 443 || b.Service.Port.Name == "https" { - proto = "https+insecure://" - } - web.Handlers[path] = &ipn.HTTPHandler{ - Proxy: proto + svc.Spec.ClusterIP + ":" + fmt.Sprint(port) + path, - } - } - addIngressBackend(ing.Spec.DefaultBackend, "/") - - var tlsHost string // hostname or FQDN or empty - if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 { - tlsHost = ing.Spec.TLS[0].Hosts[0] - } - for _, rule := range ing.Spec.Rules { - // Host is optional, but if it's present it must match the TLS host - // otherwise we ignore the rule. - if rule.Host != "" && rule.Host != tlsHost { - a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "rule with host %q ignored, unsupported", rule.Host) - continue - } - for _, p := range rule.HTTP.Paths { - // Send a warning if folks use Exact path type - to make - // it easier for us to support Exact path type matching - // in the future if needed. - // https://kubernetes.io/docs/concepts/services-networking/ingress/#path-types - if *p.PathType == networkingv1.PathTypeExact { - msg := "Exact path type strict matching is currently not supported and requests will be routed as for Prefix path type. This behaviour might change in the future." - logger.Warnf(fmt.Sprintf("Unsupported Path type exact for path %s. %s", p.Path, msg)) - a.recorder.Eventf(ing, corev1.EventTypeWarning, "UnsupportedPathTypeExact", msg) - } - addIngressBackend(&p.Backend, p.Path) - } - } - - if len(web.Handlers) == 0 { - logger.Warn("Ingress contains no valid backends") - a.recorder.Eventf(ing, corev1.EventTypeWarning, "NoValidBackends", "no valid backends") - return nil - } - - crl := childResourceLabels(ing.Name, ing.Namespace, "ingress") - var tags []string - if tstr, ok := ing.Annotations[AnnotationTags]; ok { - tags = strings.Split(tstr, ",") - } - hostname := ing.Namespace + "-" + ing.Name + "-ingress" - if tlsHost != "" { - hostname, _, _ = strings.Cut(tlsHost, ".") - } - - sts := &tailscaleSTSConfig{ - Hostname: hostname, - ParentResourceName: ing.Name, - ParentResourceUID: string(ing.UID), - ServeConfig: sc, - Tags: tags, - ChildResourceLabels: crl, - ProxyClassName: proxyClass, - } - - if val := ing.GetAnnotations()[AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy]; val == "true" { - sts.ForwardClusterTrafficViaL7IngressProxy = true - } - - if _, err := a.ssr.Provision(ctx, logger, sts); err != nil { - return fmt.Errorf("failed to provision: %w", err) - } - - _, tsHost, _, err := a.ssr.DeviceInfo(ctx, crl) - if err != nil { - return fmt.Errorf("failed to get device ID: %w", err) - } - if tsHost == "" { - logger.Debugf("no Tailscale hostname known yet, waiting for proxy pod to finish auth") - // No hostname yet. Wait for the proxy pod to auth. - ing.Status.LoadBalancer.Ingress = nil - if err := a.Status().Update(ctx, ing); err != nil { - return fmt.Errorf("failed to update ingress status: %w", err) - } - return nil - } - - logger.Debugf("setting ingress hostname to %q", tsHost) - ing.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{ - { - Hostname: tsHost, - Ports: []networkingv1.IngressPortStatus{ - { - Protocol: "TCP", - Port: 443, - }, - }, - }, - } - if err := a.Status().Update(ctx, ing); err != nil { - return fmt.Errorf("failed to update ingress status: %w", err) - } - return nil -} - -func (a *IngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool { - return ing != nil && - ing.Spec.IngressClassName != nil && - *ing.Spec.IngressClassName == tailscaleIngressClassName -} - -// validateIngressClass attempts to validate that 'tailscale' IngressClass -// included in Tailscale installation manifests exists and has not been modified -// to attempt to enable features that we do not support. -func (a *IngressReconciler) validateIngressClass(ctx context.Context) error { - ic := &networkingv1.IngressClass{ - ObjectMeta: metav1.ObjectMeta{ - Name: tailscaleIngressClassName, - }, - } - if err := a.Get(ctx, client.ObjectKeyFromObject(ic), ic); apierrors.IsNotFound(err) { - return errors.New("Tailscale IngressClass not found in cluster. Latest installation manifests include a tailscale IngressClass - please update") - } else if err != nil { - return fmt.Errorf("error retrieving 'tailscale' IngressClass: %w", err) - } - if ic.Spec.Controller != tailscaleIngressControllerName { - return fmt.Errorf("Tailscale Ingress class controller name %s does not match tailscale Ingress controller name %s. Ensure that you are using 'tailscale' IngressClass from latest Tailscale installation manifests", ic.Spec.Controller, tailscaleIngressControllerName) - } - if ic.GetAnnotations()[ingressClassDefaultAnnotation] != "" { - return fmt.Errorf("%s annotation is set on 'tailscale' IngressClass, but Tailscale Ingress controller does not support default Ingress class. Ensure that you are using 'tailscale' IngressClass from latest Tailscale installation manifests", ingressClassDefaultAnnotation) - } - return nil -} diff --git a/cmd/k8s-operator/ingress_test.go b/cmd/k8s-operator/ingress_test.go deleted file mode 100644 index 38a041dde07f9..0000000000000 --- a/cmd/k8s-operator/ingress_test.go +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "testing" - - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "tailscale.com/ipn" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/types/ptr" - "tailscale.com/util/mak" -) - -func TestTailscaleIngress(t *testing.T) { - tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}} - fc := fake.NewFakeClient(tsIngressClass) - ft := &fakeTSClient{} - fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - ingR := &IngressReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - tsnetServer: fakeTsnetServer, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - } - - // 1. Resources get created for regular Ingress - ing := &networkingv1.Ingress{ - TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("tailscale"), - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test", - Port: networkingv1.ServiceBackendPort{ - Number: 8080, - }, - }, - }, - TLS: []networkingv1.IngressTLS{ - {Hosts: []string{"default-test"}}, - }, - }, - } - mustCreate(t, fc, ing) - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "1.2.3.4", - Ports: []corev1.ServicePort{{ - Port: 8080, - Name: "http"}, - }, - }, - }) - - expectReconciled(t, ingR, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "ingress") - opts := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "ingress", - hostname: "default-test", - app: kubetypes.AppIngressResource, - } - serveConfig := &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://1.2.3.4:8080/"}}}}, - } - opts.serveConfig = serveConfig - - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil) - expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation) - - // 2. Ingress status gets updated with ingress proxy's MagicDNS name - // once that becomes available. - mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) { - mak.Set(&secret.Data, "device_id", []byte("1234")) - mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net")) - }) - expectReconciled(t, ingR, "default", "test") - ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer") - ing.Status.LoadBalancer = networkingv1.IngressLoadBalancerStatus{ - Ingress: []networkingv1.IngressLoadBalancerIngress{ - {Hostname: "foo.tailnetxyz.ts.net", Ports: []networkingv1.IngressPortStatus{{Port: 443, Protocol: "TCP"}}}, - }, - } - expectEqual(t, fc, ing, nil) - - // 3. Resources get created for Ingress that should allow forwarding - // cluster traffic - mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) { - mak.Set(&ing.ObjectMeta.Annotations, AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy, "true") - }) - opts.shouldEnableForwardingClusterTrafficViaIngress = true - expectReconciled(t, ingR, "default", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // 4. Resources get cleaned up when Ingress class is unset - mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) { - ing.Spec.IngressClassName = ptr.To("nginx") - }) - expectReconciled(t, ingR, "default", "test") - expectReconciled(t, ingR, "default", "test") // deleting Ingress STS requires two reconciles - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Service](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) -} - -func TestTailscaleIngressWithProxyClass(t *testing.T) { - // Setup - pc := &tsapi.ProxyClass{ - ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"}, - Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{ - Labels: map[string]string{"foo": "bar"}, - Annotations: map[string]string{"bar.io/foo": "some-val"}, - Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}}, - } - tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}} - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(pc, tsIngressClass). - WithStatusSubresource(pc). - Build() - ft := &fakeTSClient{} - fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - ingR := &IngressReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - tsnetServer: fakeTsnetServer, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - } - - // 1. Ingress is created with no ProxyClass specified, default proxy - // resources get configured. - ing := &networkingv1.Ingress{ - TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("tailscale"), - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: "test", - Port: networkingv1.ServiceBackendPort{ - Number: 8080, - }, - }, - }, - TLS: []networkingv1.IngressTLS{ - {Hosts: []string{"default-test"}}, - }, - }, - } - mustCreate(t, fc, ing) - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "1.2.3.4", - Ports: []corev1.ServicePort{{ - Port: 8080, - Name: "http"}, - }, - }, - }) - - expectReconciled(t, ingR, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "ingress") - opts := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "ingress", - hostname: "default-test", - app: kubetypes.AppIngressResource, - } - serveConfig := &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://1.2.3.4:8080/"}}}}, - } - opts.serveConfig = serveConfig - - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil) - expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation) - - // 2. Ingress is updated to specify a ProxyClass, ProxyClass is not yet - // ready, so proxy resource configuration does not change. - mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) { - mak.Set(&ing.ObjectMeta.Labels, LabelProxyClass, "custom-metadata") - }) - expectReconciled(t, ingR, "default", "test") - expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation) - - // 3. ProxyClass is set to Ready by proxy-class reconciler. Ingress get - // reconciled and configuration from the ProxyClass is applied to the - // created proxy resources. - mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) { - pc.Status = tsapi.ProxyClassStatus{ - Conditions: []metav1.Condition{{ - Status: metav1.ConditionTrue, - Type: string(tsapi.ProxyClassReady), - ObservedGeneration: pc.Generation, - }}} - }) - expectReconciled(t, ingR, "default", "test") - opts.proxyClass = pc.Name - expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation) - - // 4. tailscale.com/proxy-class label is removed from the Ingress, the - // Ingress gets reconciled and the custom ProxyClass configuration is - // removed from the proxy resources. - mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) { - delete(ing.ObjectMeta.Labels, LabelProxyClass) - }) - expectReconciled(t, ingR, "default", "test") - opts.proxyClass = "" - expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation) -} diff --git a/cmd/k8s-operator/nameserver.go b/cmd/k8s-operator/nameserver.go deleted file mode 100644 index 52577c929acea..0000000000000 --- a/cmd/k8s-operator/nameserver.go +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "fmt" - "slices" - "sync" - - _ "embed" - - "github.com/pkg/errors" - "go.uber.org/zap" - xslices "golang.org/x/exp/slices" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/yaml" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/tstime" - "tailscale.com/util/clientmetric" - "tailscale.com/util/set" -) - -const ( - reasonNameserverCreationFailed = "NameserverCreationFailed" - reasonMultipleDNSConfigsPresent = "MultipleDNSConfigsPresent" - - reasonNameserverCreated = "NameserverCreated" - - messageNameserverCreationFailed = "Failed creating nameserver resources: %v" - messageMultipleDNSConfigsPresent = "Multiple DNSConfig resources found in cluster. Please ensure no more than one is present." - - defaultNameserverImageRepo = "tailscale/k8s-nameserver" - // TODO (irbekrm): once we start publishing nameserver images for stable - // track, replace 'unstable' here with the version of this operator - // instance. - defaultNameserverImageTag = "unstable" -) - -// NameserverReconciler knows how to create nameserver resources in cluster in -// response to users applying DNSConfig. -type NameserverReconciler struct { - client.Client - logger *zap.SugaredLogger - recorder record.EventRecorder - clock tstime.Clock - tsNamespace string - - mu sync.Mutex // protects following - managedNameservers set.Slice[types.UID] // one or none -} - -var gaugeNameserverResources = clientmetric.NewGauge(kubetypes.MetricNameserverCount) - -func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { - logger := a.logger.With("dnsConfig", req.Name) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - var dnsCfg tsapi.DNSConfig - err = a.Get(ctx, req.NamespacedName, &dnsCfg) - if apierrors.IsNotFound(err) { - // Request object not found, could have been deleted after reconcile request. - logger.Debugf("dnsconfig not found, assuming it was deleted") - return reconcile.Result{}, nil - } else if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get dnsconfig: %w", err) - } - if !dnsCfg.DeletionTimestamp.IsZero() { - ix := xslices.Index(dnsCfg.Finalizers, FinalizerName) - if ix < 0 { - logger.Debugf("no finalizer, nothing to do") - return reconcile.Result{}, nil - } - logger.Info("Cleaning up DNSConfig resources") - if err := a.maybeCleanup(ctx, &dnsCfg, logger); err != nil { - logger.Errorf("error cleaning up reconciler resource: %v", err) - return res, err - } - dnsCfg.Finalizers = append(dnsCfg.Finalizers[:ix], dnsCfg.Finalizers[ix+1:]...) - if err := a.Update(ctx, &dnsCfg); err != nil { - logger.Errorf("error removing finalizer: %v", err) - return reconcile.Result{}, err - } - logger.Infof("Nameserver resources cleaned up") - return reconcile.Result{}, nil - } - - oldCnStatus := dnsCfg.Status.DeepCopy() - setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) { - tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger) - if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) { - // An error encountered here should get returned by the Reconcile function. - if updateErr := a.Client.Status().Update(ctx, dnsCfg); updateErr != nil { - err = errors.Wrap(err, updateErr.Error()) - } - } - return res, err - } - var dnsCfgs tsapi.DNSConfigList - if err := a.List(ctx, &dnsCfgs); err != nil { - return res, fmt.Errorf("error listing DNSConfigs: %w", err) - } - if len(dnsCfgs.Items) > 1 { // enforce DNSConfig to be a singleton - msg := "invalid cluster configuration: more than one tailscale.com/dnsconfigs found. Please ensure that no more than one is created." - logger.Error(msg) - a.recorder.Event(&dnsCfg, corev1.EventTypeWarning, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent) - setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent) - } - - if !slices.Contains(dnsCfg.Finalizers, FinalizerName) { - logger.Infof("ensuring nameserver resources") - dnsCfg.Finalizers = append(dnsCfg.Finalizers, FinalizerName) - if err := a.Update(ctx, &dnsCfg); err != nil { - msg := fmt.Sprintf(messageNameserverCreationFailed, err) - logger.Error(msg) - return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonNameserverCreationFailed, msg) - } - } - if err := a.maybeProvision(ctx, &dnsCfg, logger); err != nil { - return reconcile.Result{}, fmt.Errorf("error provisioning nameserver resources: %w", err) - } - - a.mu.Lock() - a.managedNameservers.Add(dnsCfg.UID) - a.mu.Unlock() - gaugeNameserverResources.Set(int64(a.managedNameservers.Len())) - - svc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: a.tsNamespace}, - } - if err := a.Client.Get(ctx, client.ObjectKeyFromObject(svc), svc); err != nil { - return res, fmt.Errorf("error getting Service: %w", err) - } - if ip := svc.Spec.ClusterIP; ip != "" && ip != "None" { - dnsCfg.Status.Nameserver = &tsapi.NameserverStatus{ - IP: ip, - } - return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated) - } - logger.Info("nameserver Service does not have an IP address allocated, waiting...") - return reconcile.Result{}, nil -} - -func nameserverResourceLabels(name, namespace string) map[string]string { - labels := childResourceLabels(name, namespace, "nameserver") - labels["app.kubernetes.io/name"] = "tailscale" - labels["app.kubernetes.io/component"] = "nameserver" - return labels -} - -func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error { - labels := nameserverResourceLabels(tsDNSCfg.Name, a.tsNamespace) - dCfg := &deployConfig{ - ownerRefs: []metav1.OwnerReference{*metav1.NewControllerRef(tsDNSCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))}, - namespace: a.tsNamespace, - labels: labels, - imageRepo: defaultNameserverImageRepo, - imageTag: defaultNameserverImageTag, - } - if tsDNSCfg.Spec.Nameserver.Image != nil && tsDNSCfg.Spec.Nameserver.Image.Repo != "" { - dCfg.imageRepo = tsDNSCfg.Spec.Nameserver.Image.Repo - } - if tsDNSCfg.Spec.Nameserver.Image != nil && tsDNSCfg.Spec.Nameserver.Image.Tag != "" { - dCfg.imageTag = tsDNSCfg.Spec.Nameserver.Image.Tag - } - for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} { - if err := deployable.updateObj(ctx, dCfg, a.Client); err != nil { - return fmt.Errorf("error reconciling %s: %w", deployable.kind, err) - } - } - return nil -} - -// maybeCleanup removes DNSConfig from being tracked. The cluster resources -// created, will be automatically garbage collected as they are owned by the -// DNSConfig. -func (a *NameserverReconciler) maybeCleanup(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error { - a.mu.Lock() - a.managedNameservers.Remove(dnsCfg.UID) - a.mu.Unlock() - gaugeNameserverResources.Set(int64(a.managedNameservers.Len())) - return nil -} - -type deployable struct { - kind string - updateObj func(context.Context, *deployConfig, client.Client) error -} - -type deployConfig struct { - imageRepo string - imageTag string - labels map[string]string - ownerRefs []metav1.OwnerReference - namespace string -} - -var ( - //go:embed deploy/manifests/nameserver/cm.yaml - cmYaml []byte - //go:embed deploy/manifests/nameserver/deploy.yaml - deployYaml []byte - //go:embed deploy/manifests/nameserver/sa.yaml - saYaml []byte - //go:embed deploy/manifests/nameserver/svc.yaml - svcYaml []byte - - deployDeployable = deployable{ - kind: "Deployment", - updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { - d := new(appsv1.Deployment) - if err := yaml.Unmarshal(deployYaml, &d); err != nil { - return fmt.Errorf("error unmarshalling Deployment yaml: %w", err) - } - d.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag) - d.ObjectMeta.Namespace = cfg.namespace - d.ObjectMeta.Labels = cfg.labels - d.ObjectMeta.OwnerReferences = cfg.ownerRefs - updateF := func(oldD *appsv1.Deployment) { - oldD.Spec = d.Spec - } - _, err := createOrUpdate[appsv1.Deployment](ctx, kubeClient, cfg.namespace, d, updateF) - return err - }, - } - saDeployable = deployable{ - kind: "ServiceAccount", - updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { - sa := new(corev1.ServiceAccount) - if err := yaml.Unmarshal(saYaml, &sa); err != nil { - return fmt.Errorf("error unmarshalling ServiceAccount yaml: %w", err) - } - sa.ObjectMeta.Labels = cfg.labels - sa.ObjectMeta.OwnerReferences = cfg.ownerRefs - sa.ObjectMeta.Namespace = cfg.namespace - _, err := createOrUpdate(ctx, kubeClient, cfg.namespace, sa, func(*corev1.ServiceAccount) {}) - return err - }, - } - svcDeployable = deployable{ - kind: "Service", - updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { - svc := new(corev1.Service) - if err := yaml.Unmarshal(svcYaml, &svc); err != nil { - return fmt.Errorf("error unmarshalling Service yaml: %w", err) - } - svc.ObjectMeta.Labels = cfg.labels - svc.ObjectMeta.OwnerReferences = cfg.ownerRefs - svc.ObjectMeta.Namespace = cfg.namespace - _, err := createOrUpdate[corev1.Service](ctx, kubeClient, cfg.namespace, svc, func(*corev1.Service) {}) - return err - }, - } - cmDeployable = deployable{ - kind: "ConfigMap", - updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { - cm := new(corev1.ConfigMap) - if err := yaml.Unmarshal(cmYaml, &cm); err != nil { - return fmt.Errorf("error unmarshalling ConfigMap yaml: %w", err) - } - cm.ObjectMeta.Labels = cfg.labels - cm.ObjectMeta.OwnerReferences = cfg.ownerRefs - cm.ObjectMeta.Namespace = cfg.namespace - _, err := createOrUpdate[corev1.ConfigMap](ctx, kubeClient, cfg.namespace, cm, func(cm *corev1.ConfigMap) {}) - return err - }, - } -) diff --git a/cmd/k8s-operator/nameserver_test.go b/cmd/k8s-operator/nameserver_test.go deleted file mode 100644 index 695710212e57b..0000000000000 --- a/cmd/k8s-operator/nameserver_test.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// tailscale-operator provides a way to expose services running in a Kubernetes -// cluster to your Tailnet and to make Tailscale nodes available to cluster -// workloads -package main - -import ( - "encoding/json" - "testing" - "time" - - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/yaml" - operatorutils "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstest" - "tailscale.com/util/mak" -) - -func TestNameserverReconciler(t *testing.T) { - dnsCfg := &tsapi.DNSConfig{ - TypeMeta: metav1.TypeMeta{Kind: "DNSConfig", APIVersion: "tailscale.com/v1alpha1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - }, - Spec: tsapi.DNSConfigSpec{ - Nameserver: &tsapi.Nameserver{ - Image: &tsapi.NameserverImage{ - Repo: "test", - Tag: "v0.0.1", - }, - }, - }, - } - - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(dnsCfg). - WithStatusSubresource(dnsCfg). - Build() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - nr := &NameserverReconciler{ - Client: fc, - clock: cl, - logger: zl.Sugar(), - tsNamespace: "tailscale", - } - expectReconciled(t, nr, "", "test") - // Verify that nameserver Deployment has been created and has the expected fields. - wantsDeploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: "tailscale"}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()}} - if err := yaml.Unmarshal(deployYaml, wantsDeploy); err != nil { - t.Fatalf("unmarshalling yaml: %v", err) - } - dnsCfgOwnerRef := metav1.NewControllerRef(dnsCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig")) - wantsDeploy.OwnerReferences = []metav1.OwnerReference{*dnsCfgOwnerRef} - wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.1" - wantsDeploy.Namespace = "tailscale" - labels := nameserverResourceLabels("test", "tailscale") - wantsDeploy.ObjectMeta.Labels = labels - expectEqual(t, fc, wantsDeploy, nil) - - // Verify that DNSConfig advertizes the nameserver's Service IP address, - // has the ready status condition and tailscale finalizer. - mustUpdate(t, fc, "tailscale", "nameserver", func(svc *corev1.Service) { - svc.Spec.ClusterIP = "1.2.3.4" - }) - expectReconciled(t, nr, "", "test") - dnsCfg.Status.Nameserver = &tsapi.NameserverStatus{ - IP: "1.2.3.4", - } - dnsCfg.Finalizers = []string{FinalizerName} - dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{ - Type: string(tsapi.NameserverReady), - Status: metav1.ConditionTrue, - Reason: reasonNameserverCreated, - Message: reasonNameserverCreated, - LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, - }) - expectEqual(t, fc, dnsCfg, nil) - - // // Verify that nameserver image gets updated to match DNSConfig spec. - mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) { - dnsCfg.Spec.Nameserver.Image.Tag = "v0.0.2" - }) - expectReconciled(t, nr, "", "test") - wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.2" - expectEqual(t, fc, wantsDeploy, nil) - - // Verify that when another actor sets ConfigMap data, it does not get - // overwritten by nameserver reconciler. - dnsRecords := &operatorutils.Records{Version: "v1alpha1", IP4: map[string][]string{"foo.ts.net": {"1.2.3.4"}}} - bs, err := json.Marshal(dnsRecords) - if err != nil { - t.Fatalf("error marshalling ConfigMap contents: %v", err) - } - mustUpdate(t, fc, "tailscale", "dnsrecords", func(cm *corev1.ConfigMap) { - mak.Set(&cm.Data, "records.json", string(bs)) - }) - expectReconciled(t, nr, "", "test") - wantCm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords", - Namespace: "tailscale", Labels: labels, OwnerReferences: []metav1.OwnerReference{*dnsCfgOwnerRef}}, - TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, - Data: map[string]string{"records.json": string(bs)}, - } - expectEqual(t, fc, wantCm, nil) - - // Verify that if dnsconfig.spec.nameserver.image.{repo,tag} are unset, - // the nameserver image defaults to tailscale/k8s-nameserver:unstable. - mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) { - dnsCfg.Spec.Nameserver.Image = nil - }) - expectReconciled(t, nr, "", "test") - wantsDeploy.Spec.Template.Spec.Containers[0].Image = "tailscale/k8s-nameserver:unstable" - expectEqual(t, fc, wantsDeploy, nil) -} diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go deleted file mode 100644 index 116ba02e0ce1c..0000000000000 --- a/cmd/k8s-operator/operator.go +++ /dev/null @@ -1,1028 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// tailscale-operator provides a way to expose services running in a Kubernetes -// cluster to your Tailnet. -package main - -import ( - "context" - "os" - "regexp" - "strconv" - "strings" - "time" - - "github.com/go-logr/zapr" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "golang.org/x/oauth2/clientcredentials" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - networkingv1 "k8s.io/api/networking/v1" - rbacv1 "k8s.io/api/rbac/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/config" - "sigs.k8s.io/controller-runtime/pkg/handler" - logf "sigs.k8s.io/controller-runtime/pkg/log" - kzap "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/manager/signals" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "tailscale.com/client/tailscale" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/store/kubestore" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/tsnet" - "tailscale.com/tstime" - "tailscale.com/types/logger" - "tailscale.com/version" -) - -// Generate Connector and ProxyClass CustomResourceDefinition yamls from their Go types. -//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd schemapatch:manifests=./deploy/crds output:dir=./deploy/crds paths=../../k8s-operator/apis/... - -// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart. -//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests - -// Generate CRD API docs. -//go:generate go run github.com/elastic/crd-ref-docs --renderer=markdown --source-path=../../k8s-operator/apis/ --config=../../k8s-operator/api-docs-config.yaml --output-path=../../k8s-operator/api.md - -func main() { - // Required to use our client API. We're fine with the instability since the - // client lives in the same repo as this code. - tailscale.I_Acknowledge_This_API_Is_Unstable = true - - var ( - tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "") - tslogging = defaultEnv("OPERATOR_LOGGING", "info") - image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest") - priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "") - tags = defaultEnv("PROXY_TAGS", "tag:k8s") - tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "") - defaultProxyClass = defaultEnv("PROXY_DEFAULT_CLASS", "") - isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false) - ) - - var opts []kzap.Opts - switch tslogging { - case "info": - opts = append(opts, kzap.Level(zapcore.InfoLevel)) - case "debug": - opts = append(opts, kzap.Level(zapcore.DebugLevel)) - case "dev": - opts = append(opts, kzap.UseDevMode(true), kzap.Level(zapcore.DebugLevel)) - } - zlog := kzap.NewRaw(opts...).Sugar() - logf.SetLogger(zapr.NewLogger(zlog.Desugar())) - - // The operator can run either as a plain operator or it can - // additionally act as api-server proxy - // https://tailscale.com/kb/1236/kubernetes-operator/?q=kubernetes#accessing-the-kubernetes-control-plane-using-an-api-server-proxy. - mode := parseAPIProxyMode() - if mode == apiserverProxyModeDisabled { - hostinfo.SetApp(kubetypes.AppOperator) - } else { - hostinfo.SetApp(kubetypes.AppAPIServerProxy) - } - - s, tsClient := initTSNet(zlog) - defer s.Close() - restConfig := config.GetConfigOrDie() - maybeLaunchAPIServerProxy(zlog, restConfig, s, mode) - rOpts := reconcilerOpts{ - log: zlog, - tsServer: s, - tsClient: tsClient, - tailscaleNamespace: tsNamespace, - restConfig: restConfig, - proxyImage: image, - proxyPriorityClassName: priorityClassName, - proxyActAsDefaultLoadBalancer: isDefaultLoadBalancer, - proxyTags: tags, - proxyFirewallMode: tsFirewallMode, - defaultProxyClass: defaultProxyClass, - } - runReconcilers(rOpts) -} - -// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the -// CLIENT_ID_FILE and CLIENT_SECRET_FILE environment variables to authenticate -// with Tailscale. -func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) { - var ( - clientIDPath = defaultEnv("CLIENT_ID_FILE", "") - clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "") - hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator") - kubeSecret = defaultEnv("OPERATOR_SECRET", "") - operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator") - ) - startlog := zlog.Named("startup") - if clientIDPath == "" || clientSecretPath == "" { - startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set") - } - clientID, err := os.ReadFile(clientIDPath) - if err != nil { - startlog.Fatalf("reading client ID %q: %v", clientIDPath, err) - } - clientSecret, err := os.ReadFile(clientSecretPath) - if err != nil { - startlog.Fatalf("reading client secret %q: %v", clientSecretPath, err) - } - credentials := clientcredentials.Config{ - ClientID: string(clientID), - ClientSecret: string(clientSecret), - TokenURL: "https://login.tailscale.com/api/v2/oauth/token", - } - tsClient := tailscale.NewClient("-", nil) - tsClient.UserAgent = "tailscale-k8s-operator" - tsClient.HTTPClient = credentials.Client(context.Background()) - - s := &tsnet.Server{ - Hostname: hostname, - Logf: zlog.Named("tailscaled").Debugf, - } - if p := os.Getenv("TS_PORT"); p != "" { - port, err := strconv.ParseUint(p, 10, 16) - if err != nil { - startlog.Fatalf("TS_PORT %q cannot be parsed as uint16: %v", p, err) - } - s.Port = uint16(port) - } - if kubeSecret != "" { - st, err := kubestore.New(logger.Discard, kubeSecret) - if err != nil { - startlog.Fatalf("creating kube store: %v", err) - } - s.Store = st - } - if err := s.Start(); err != nil { - startlog.Fatalf("starting tailscale server: %v", err) - } - lc, err := s.LocalClient() - if err != nil { - startlog.Fatalf("getting local client: %v", err) - } - - ctx := context.Background() - loginDone := false - machineAuthShown := false -waitOnline: - for { - startlog.Debugf("querying tailscaled status") - st, err := lc.StatusWithoutPeers(ctx) - if err != nil { - startlog.Fatalf("getting status: %v", err) - } - switch st.BackendState { - case "Running": - break waitOnline - case "NeedsLogin": - if loginDone { - break - } - caps := tailscale.KeyCapabilities{ - Devices: tailscale.KeyDeviceCapabilities{ - Create: tailscale.KeyDeviceCreateCapabilities{ - Reusable: false, - Preauthorized: true, - Tags: strings.Split(operatorTags, ","), - }, - }, - } - authkey, _, err := tsClient.CreateKey(ctx, caps) - if err != nil { - startlog.Fatalf("creating operator authkey: %v", err) - } - if err := lc.Start(ctx, ipn.Options{ - AuthKey: authkey, - }); err != nil { - startlog.Fatalf("starting tailscale: %v", err) - } - if err := lc.StartLoginInteractive(ctx); err != nil { - startlog.Fatalf("starting login: %v", err) - } - startlog.Debugf("requested login by authkey") - loginDone = true - case "NeedsMachineAuth": - if !machineAuthShown { - startlog.Infof("Machine approval required, please visit the admin panel to approve") - machineAuthShown = true - } - default: - startlog.Debugf("waiting for tailscale to start: %v", st.BackendState) - } - time.Sleep(time.Second) - } - return s, tsClient -} - -// runReconcilers starts the controller-runtime manager and registers the -// ServiceReconciler. It blocks forever. -func runReconcilers(opts reconcilerOpts) { - startlog := opts.log.Named("startReconcilers") - // For secrets and statefulsets, we only get permission to touch the objects - // in the controller's own namespace. This cannot be expressed by - // .Watches(...) below, instead you have to add a per-type field selector to - // the cache that sits a few layers below the builder stuff, which will - // implicitly filter what parts of the world the builder code gets to see at - // all. - nsFilter := cache.ByObject{ - Field: client.InNamespace(opts.tailscaleNamespace).AsSelector(), - } - mgrOpts := manager.Options{ - // TODO (irbekrm): stricter filtering what we watch/cache/call - // reconcilers on. c/r by default starts a watch on any - // resources that we GET via the controller manager's client. - Cache: cache.Options{ - ByObject: map[client.Object]cache.ByObject{ - &corev1.Secret{}: nsFilter, - &corev1.ServiceAccount{}: nsFilter, - &corev1.Pod{}: nsFilter, - &corev1.ConfigMap{}: nsFilter, - &appsv1.StatefulSet{}: nsFilter, - &appsv1.Deployment{}: nsFilter, - &discoveryv1.EndpointSlice{}: nsFilter, - &rbacv1.Role{}: nsFilter, - &rbacv1.RoleBinding{}: nsFilter, - }, - }, - Scheme: tsapi.GlobalScheme, - } - mgr, err := manager.New(opts.restConfig, mgrOpts) - if err != nil { - startlog.Fatalf("could not create manager: %v", err) - } - - svcFilter := handler.EnqueueRequestsFromMapFunc(serviceHandler) - svcChildFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("svc")) - // If a ProxyClass changes, enqueue all Services labeled with that - // ProxyClass's name. - proxyClassFilterForSvc := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForSvc(mgr.GetClient(), startlog)) - - eventRecorder := mgr.GetEventRecorderFor("tailscale-operator") - ssr := &tailscaleSTSReconciler{ - Client: mgr.GetClient(), - tsnetServer: opts.tsServer, - tsClient: opts.tsClient, - defaultTags: strings.Split(opts.proxyTags, ","), - operatorNamespace: opts.tailscaleNamespace, - proxyImage: opts.proxyImage, - proxyPriorityClassName: opts.proxyPriorityClassName, - tsFirewallMode: opts.proxyFirewallMode, - } - err = builder. - ControllerManagedBy(mgr). - Named("service-reconciler"). - Watches(&corev1.Service{}, svcFilter). - Watches(&appsv1.StatefulSet{}, svcChildFilter). - Watches(&corev1.Secret{}, svcChildFilter). - Watches(&tsapi.ProxyClass{}, proxyClassFilterForSvc). - Complete(&ServiceReconciler{ - ssr: ssr, - Client: mgr.GetClient(), - logger: opts.log.Named("service-reconciler"), - isDefaultLoadBalancer: opts.proxyActAsDefaultLoadBalancer, - recorder: eventRecorder, - tsNamespace: opts.tailscaleNamespace, - clock: tstime.DefaultClock{}, - defaultProxyClass: opts.defaultProxyClass, - }) - if err != nil { - startlog.Fatalf("could not create service reconciler: %v", err) - } - ingressChildFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("ingress")) - // If a ProxyClassChanges, enqueue all Ingresses labeled with that - // ProxyClass's name. - proxyClassFilterForIngress := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForIngress(mgr.GetClient(), startlog)) - // Enque Ingress if a managed Service or backend Service associated with a tailscale Ingress changes. - svcHandlerForIngress := handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngress(mgr.GetClient(), startlog)) - err = builder. - ControllerManagedBy(mgr). - For(&networkingv1.Ingress{}). - Watches(&appsv1.StatefulSet{}, ingressChildFilter). - Watches(&corev1.Secret{}, ingressChildFilter). - Watches(&corev1.Service{}, svcHandlerForIngress). - Watches(&tsapi.ProxyClass{}, proxyClassFilterForIngress). - Complete(&IngressReconciler{ - ssr: ssr, - recorder: eventRecorder, - Client: mgr.GetClient(), - logger: opts.log.Named("ingress-reconciler"), - defaultProxyClass: opts.defaultProxyClass, - }) - if err != nil { - startlog.Fatalf("could not create ingress reconciler: %v", err) - } - - connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("connector")) - // If a ProxyClassChanges, enqueue all Connectors that have - // .spec.proxyClass set to the name of this ProxyClass. - proxyClassFilterForConnector := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForConnector(mgr.GetClient(), startlog)) - err = builder.ControllerManagedBy(mgr). - For(&tsapi.Connector{}). - Watches(&appsv1.StatefulSet{}, connectorFilter). - Watches(&corev1.Secret{}, connectorFilter). - Watches(&tsapi.ProxyClass{}, proxyClassFilterForConnector). - Complete(&ConnectorReconciler{ - ssr: ssr, - recorder: eventRecorder, - Client: mgr.GetClient(), - logger: opts.log.Named("connector-reconciler"), - clock: tstime.DefaultClock{}, - }) - if err != nil { - startlog.Fatalf("could not create connector reconciler: %v", err) - } - // TODO (irbekrm): switch to metadata-only watches for resources whose - // spec we don't need to inspect to reduce memory consumption. - // https://github.com/kubernetes-sigs/controller-runtime/issues/1159 - nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver")) - err = builder.ControllerManagedBy(mgr). - For(&tsapi.DNSConfig{}). - Watches(&appsv1.Deployment{}, nameserverFilter). - Watches(&corev1.ConfigMap{}, nameserverFilter). - Watches(&corev1.Service{}, nameserverFilter). - Watches(&corev1.ServiceAccount{}, nameserverFilter). - Complete(&NameserverReconciler{ - recorder: eventRecorder, - tsNamespace: opts.tailscaleNamespace, - Client: mgr.GetClient(), - logger: opts.log.Named("nameserver-reconciler"), - clock: tstime.DefaultClock{}, - }) - if err != nil { - startlog.Fatalf("could not create nameserver reconciler: %v", err) - } - - egressSvcFilter := handler.EnqueueRequestsFromMapFunc(egressSvcsHandler) - egressProxyGroupFilter := handler.EnqueueRequestsFromMapFunc(egressSvcsFromEgressProxyGroup(mgr.GetClient(), opts.log)) - err = builder. - ControllerManagedBy(mgr). - Named("egress-svcs-reconciler"). - Watches(&corev1.Service{}, egressSvcFilter). - Watches(&tsapi.ProxyGroup{}, egressProxyGroupFilter). - Complete(&egressSvcsReconciler{ - Client: mgr.GetClient(), - tsNamespace: opts.tailscaleNamespace, - recorder: eventRecorder, - clock: tstime.DefaultClock{}, - logger: opts.log.Named("egress-svcs-reconciler"), - }) - if err != nil { - startlog.Fatalf("could not create egress Services reconciler: %v", err) - } - if err := mgr.GetFieldIndexer().IndexField(context.Background(), new(corev1.Service), indexEgressProxyGroup, indexEgressServices); err != nil { - startlog.Fatalf("failed setting up indexer for egress Services: %v", err) - } - - egressSvcFromEpsFilter := handler.EnqueueRequestsFromMapFunc(egressSvcFromEps) - err = builder. - ControllerManagedBy(mgr). - Named("egress-svcs-readiness-reconciler"). - Watches(&corev1.Service{}, egressSvcFilter). - Watches(&discoveryv1.EndpointSlice{}, egressSvcFromEpsFilter). - Complete(&egressSvcsReadinessReconciler{ - Client: mgr.GetClient(), - tsNamespace: opts.tailscaleNamespace, - clock: tstime.DefaultClock{}, - logger: opts.log.Named("egress-svcs-readiness-reconciler"), - }) - if err != nil { - startlog.Fatalf("could not create egress Services readiness reconciler: %v", err) - } - - epsFilter := handler.EnqueueRequestsFromMapFunc(egressEpsHandler) - podsFilter := handler.EnqueueRequestsFromMapFunc(egressEpsFromPGPods(mgr.GetClient(), opts.tailscaleNamespace)) - secretsFilter := handler.EnqueueRequestsFromMapFunc(egressEpsFromPGStateSecrets(mgr.GetClient(), opts.tailscaleNamespace)) - epsFromExtNSvcFilter := handler.EnqueueRequestsFromMapFunc(epsFromExternalNameService(mgr.GetClient(), opts.log, opts.tailscaleNamespace)) - - err = builder. - ControllerManagedBy(mgr). - Named("egress-eps-reconciler"). - Watches(&discoveryv1.EndpointSlice{}, epsFilter). - Watches(&corev1.Pod{}, podsFilter). - Watches(&corev1.Secret{}, secretsFilter). - Watches(&corev1.Service{}, epsFromExtNSvcFilter). - Complete(&egressEpsReconciler{ - Client: mgr.GetClient(), - tsNamespace: opts.tailscaleNamespace, - logger: opts.log.Named("egress-eps-reconciler"), - }) - if err != nil { - startlog.Fatalf("could not create egress EndpointSlices reconciler: %v", err) - } - - err = builder.ControllerManagedBy(mgr). - For(&tsapi.ProxyClass{}). - Complete(&ProxyClassReconciler{ - Client: mgr.GetClient(), - recorder: eventRecorder, - logger: opts.log.Named("proxyclass-reconciler"), - clock: tstime.DefaultClock{}, - }) - if err != nil { - startlog.Fatal("could not create proxyclass reconciler: %v", err) - } - logger := startlog.Named("dns-records-reconciler-event-handlers") - // On EndpointSlice events, if it is an EndpointSlice for an - // ingress/egress proxy headless Service, reconcile the headless - // Service. - dnsRREpsOpts := handler.EnqueueRequestsFromMapFunc(dnsRecordsReconcilerEndpointSliceHandler) - // On DNSConfig changes, reconcile all headless Services for - // ingress/egress proxies in operator namespace. - dnsRRDNSConfigOpts := handler.EnqueueRequestsFromMapFunc(enqueueAllIngressEgressProxySvcsInNS(opts.tailscaleNamespace, mgr.GetClient(), logger)) - // On Service events, if it is an ingress/egress proxy headless Service, reconcile it. - dnsRRServiceOpts := handler.EnqueueRequestsFromMapFunc(dnsRecordsReconcilerServiceHandler) - // On Ingress events, if it is a tailscale Ingress or if tailscale is the default ingress controller, reconcile the proxy - // headless Service. - dnsRRIngressOpts := handler.EnqueueRequestsFromMapFunc(dnsRecordsReconcilerIngressHandler(opts.tailscaleNamespace, opts.proxyActAsDefaultLoadBalancer, mgr.GetClient(), logger)) - err = builder.ControllerManagedBy(mgr). - Named("dns-records-reconciler"). - Watches(&corev1.Service{}, dnsRRServiceOpts). - Watches(&networkingv1.Ingress{}, dnsRRIngressOpts). - Watches(&discoveryv1.EndpointSlice{}, dnsRREpsOpts). - Watches(&tsapi.DNSConfig{}, dnsRRDNSConfigOpts). - Complete(&dnsRecordsReconciler{ - Client: mgr.GetClient(), - tsNamespace: opts.tailscaleNamespace, - logger: opts.log.Named("dns-records-reconciler"), - isDefaultLoadBalancer: opts.proxyActAsDefaultLoadBalancer, - }) - if err != nil { - startlog.Fatalf("could not create DNS records reconciler: %v", err) - } - - // Recorder reconciler. - recorderFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.Recorder{}) - err = builder.ControllerManagedBy(mgr). - For(&tsapi.Recorder{}). - Watches(&appsv1.StatefulSet{}, recorderFilter). - Watches(&corev1.ServiceAccount{}, recorderFilter). - Watches(&corev1.Secret{}, recorderFilter). - Watches(&rbacv1.Role{}, recorderFilter). - Watches(&rbacv1.RoleBinding{}, recorderFilter). - Complete(&RecorderReconciler{ - recorder: eventRecorder, - tsNamespace: opts.tailscaleNamespace, - Client: mgr.GetClient(), - l: opts.log.Named("recorder-reconciler"), - clock: tstime.DefaultClock{}, - tsClient: opts.tsClient, - }) - if err != nil { - startlog.Fatalf("could not create Recorder reconciler: %v", err) - } - - // Recorder reconciler. - ownedByProxyGroupFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.ProxyGroup{}) - proxyClassFilterForProxyGroup := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForProxyGroup(mgr.GetClient(), startlog)) - err = builder.ControllerManagedBy(mgr). - For(&tsapi.ProxyGroup{}). - Watches(&appsv1.StatefulSet{}, ownedByProxyGroupFilter). - Watches(&corev1.ServiceAccount{}, ownedByProxyGroupFilter). - Watches(&corev1.Secret{}, ownedByProxyGroupFilter). - Watches(&rbacv1.Role{}, ownedByProxyGroupFilter). - Watches(&rbacv1.RoleBinding{}, ownedByProxyGroupFilter). - Watches(&tsapi.ProxyClass{}, proxyClassFilterForProxyGroup). - Complete(&ProxyGroupReconciler{ - recorder: eventRecorder, - Client: mgr.GetClient(), - l: opts.log.Named("proxygroup-reconciler"), - clock: tstime.DefaultClock{}, - tsClient: opts.tsClient, - - tsNamespace: opts.tailscaleNamespace, - proxyImage: opts.proxyImage, - defaultTags: strings.Split(opts.proxyTags, ","), - tsFirewallMode: opts.proxyFirewallMode, - defaultProxyClass: opts.defaultProxyClass, - }) - if err != nil { - startlog.Fatalf("could not create ProxyGroup reconciler: %v", err) - } - - startlog.Infof("Startup complete, operator running, version: %s", version.Long()) - if err := mgr.Start(signals.SetupSignalHandler()); err != nil { - startlog.Fatalf("could not start manager: %v", err) - } -} - -type reconcilerOpts struct { - log *zap.SugaredLogger - tsServer *tsnet.Server - tsClient *tailscale.Client - tailscaleNamespace string // namespace in which operator resources will be deployed - restConfig *rest.Config // config for connecting to the kube API server - proxyImage string // : - // proxyPriorityClassName isPriorityClass to be set for proxy Pods. This - // is a legacy mechanism for cluster resource configuration options - - // going forward use ProxyClass. - // https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/#priorityclass - proxyPriorityClassName string - // proxyTags are ACL tags to tag proxy auth keys. Multiple tags should - // be provided as a string with comma-separated tag values. Proxy tags - // default to tag:k8s. - // https://tailscale.com/kb/1085/auth-keys - proxyTags string - // proxyActAsDefaultLoadBalancer determines whether this operator - // instance should act as the default ingress controller when looking at - // Ingress resources with unset ingress.spec.ingressClassName. - // TODO (irbekrm): this setting does not respect the default - // IngressClass. - // https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class - // We should fix that and preferably integrate with that mechanism as - // well - perhaps make the operator itself create the default - // IngressClass if this is set to true. - proxyActAsDefaultLoadBalancer bool - // proxyFirewallMode determines whether non-userspace proxies should use - // iptables or nftables for firewall configuration. Accepted values are - // iptables, nftables and auto. If set to auto, proxy will automatically - // determine which mode is supported for a given host (prefer nftables). - // Auto is usually the best choice, unless you want to explicitly set - // specific mode for debugging purposes. - proxyFirewallMode string - // defaultProxyClass is the name of the ProxyClass to use as the default - // class for proxies that do not have a ProxyClass set. - // this is defined by an operator env variable. - defaultProxyClass string -} - -// enqueueAllIngressEgressProxySvcsinNS returns a reconcile request for each -// ingress/egress proxy headless Service found in the provided namespace. -func enqueueAllIngressEgressProxySvcsInNS(ns string, cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(ctx context.Context, _ client.Object) []reconcile.Request { - reqs := make([]reconcile.Request, 0) - - // Get all headless Services for proxies configured using Service. - svcProxyLabels := map[string]string{ - LabelManaged: "true", - LabelParentType: "svc", - } - svcHeadlessSvcList := &corev1.ServiceList{} - if err := cl.List(ctx, svcHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(svcProxyLabels)); err != nil { - logger.Errorf("error listing headless Services for tailscale ingress/egress Services in operator namespace: %v", err) - return nil - } - for _, svc := range svcHeadlessSvcList.Items { - reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}}) - } - - // Get all headless Services for proxies configured using Ingress. - ingProxyLabels := map[string]string{ - LabelManaged: "true", - LabelParentType: "ingress", - } - ingHeadlessSvcList := &corev1.ServiceList{} - if err := cl.List(ctx, ingHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(ingProxyLabels)); err != nil { - logger.Errorf("error listing headless Services for tailscale Ingresses in operator namespace: %v", err) - return nil - } - for _, svc := range ingHeadlessSvcList.Items { - reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}}) - } - return reqs - } -} - -// dnsRecordsReconciler filters EndpointSlice events for which -// dns-records-reconciler should reconcile a headless Service. The only events -// it should reconcile are those for EndpointSlices associated with proxy -// headless Services. -func dnsRecordsReconcilerEndpointSliceHandler(ctx context.Context, o client.Object) []reconcile.Request { - if !isManagedByType(o, "svc") && !isManagedByType(o, "ingress") { - return nil - } - headlessSvcName, ok := o.GetLabels()[discoveryv1.LabelServiceName] // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership - if !ok { - return nil - } - return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: o.GetNamespace(), Name: headlessSvcName}}} -} - -// dnsRecordsReconcilerServiceHandler filters Service events for which -// dns-records-reconciler should reconcile. If the event is for a cluster -// ingress/cluster egress proxy's headless Service, returns the Service for -// reconcile. -func dnsRecordsReconcilerServiceHandler(ctx context.Context, o client.Object) []reconcile.Request { - if isManagedByType(o, "svc") || isManagedByType(o, "ingress") { - return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: o.GetNamespace(), Name: o.GetName()}}} - } - return nil -} - -// dnsRecordsReconcilerIngressHandler filters Ingress events to ensure that -// dns-records-reconciler only reconciles on tailscale Ingress events. When an -// event is observed on a tailscale Ingress, reconcile the proxy headless Service. -func dnsRecordsReconcilerIngressHandler(ns string, isDefaultLoadBalancer bool, cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(ctx context.Context, o client.Object) []reconcile.Request { - ing, ok := o.(*networkingv1.Ingress) - if !ok { - return nil - } - if !isDefaultLoadBalancer && (ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != "tailscale") { - return nil - } - proxyResourceLabels := childResourceLabels(ing.Name, ing.Namespace, "ingress") - headlessSvc, err := getSingleObject[corev1.Service](ctx, cl, ns, proxyResourceLabels) - if err != nil { - logger.Errorf("error getting headless Service from parent labels: %v", err) - return nil - } - if headlessSvc == nil { - return nil - } - return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: headlessSvc.Namespace, Name: headlessSvc.Name}}} - } -} - -type tsClient interface { - CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error) - Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error) - DeleteDevice(ctx context.Context, nodeStableID string) error -} - -func isManagedResource(o client.Object) bool { - ls := o.GetLabels() - return ls[LabelManaged] == "true" -} - -func isManagedByType(o client.Object, typ string) bool { - ls := o.GetLabels() - return isManagedResource(o) && ls[LabelParentType] == typ -} - -func parentFromObjectLabels(o client.Object) types.NamespacedName { - ls := o.GetLabels() - return types.NamespacedName{ - Namespace: ls[LabelParentNamespace], - Name: ls[LabelParentName], - } -} - -func managedResourceHandlerForType(typ string) handler.MapFunc { - return func(_ context.Context, o client.Object) []reconcile.Request { - if !isManagedByType(o, typ) { - return nil - } - return []reconcile.Request{ - {NamespacedName: parentFromObjectLabels(o)}, - } - } -} - -// proxyClassHandlerForSvc returns a handler that, for a given ProxyClass, -// returns a list of reconcile requests for all Services labeled with -// tailscale.com/proxy-class: . -func proxyClassHandlerForSvc(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(ctx context.Context, o client.Object) []reconcile.Request { - svcList := new(corev1.ServiceList) - labels := map[string]string{ - LabelProxyClass: o.GetName(), - } - if err := cl.List(ctx, svcList, client.MatchingLabels(labels)); err != nil { - logger.Debugf("error listing Services for ProxyClass: %v", err) - return nil - } - reqs := make([]reconcile.Request, 0) - for _, svc := range svcList.Items { - reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&svc)}) - } - return reqs - } -} - -// proxyClassHandlerForIngress returns a handler that, for a given ProxyClass, -// returns a list of reconcile requests for all Ingresses labeled with -// tailscale.com/proxy-class: . -func proxyClassHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(ctx context.Context, o client.Object) []reconcile.Request { - ingList := new(networkingv1.IngressList) - labels := map[string]string{ - LabelProxyClass: o.GetName(), - } - if err := cl.List(ctx, ingList, client.MatchingLabels(labels)); err != nil { - logger.Debugf("error listing Ingresses for ProxyClass: %v", err) - return nil - } - reqs := make([]reconcile.Request, 0) - for _, ing := range ingList.Items { - reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)}) - } - return reqs - } -} - -// proxyClassHandlerForConnector returns a handler that, for a given ProxyClass, -// returns a list of reconcile requests for all Connectors that have -// .spec.proxyClass set. -func proxyClassHandlerForConnector(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(ctx context.Context, o client.Object) []reconcile.Request { - connList := new(tsapi.ConnectorList) - if err := cl.List(ctx, connList); err != nil { - logger.Debugf("error listing Connectors for ProxyClass: %v", err) - return nil - } - reqs := make([]reconcile.Request, 0) - proxyClassName := o.GetName() - for _, conn := range connList.Items { - if conn.Spec.ProxyClass == proxyClassName { - reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&conn)}) - } - } - return reqs - } -} - -// proxyClassHandlerForConnector returns a handler that, for a given ProxyClass, -// returns a list of reconcile requests for all Connectors that have -// .spec.proxyClass set. -func proxyClassHandlerForProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(ctx context.Context, o client.Object) []reconcile.Request { - pgList := new(tsapi.ProxyGroupList) - if err := cl.List(ctx, pgList); err != nil { - logger.Debugf("error listing ProxyGroups for ProxyClass: %v", err) - return nil - } - reqs := make([]reconcile.Request, 0) - proxyClassName := o.GetName() - for _, pg := range pgList.Items { - if pg.Spec.ProxyClass == proxyClassName { - reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&pg)}) - } - } - return reqs - } -} - -// serviceHandlerForIngress returns a handler for Service events for ingress -// reconciler that ensures that if the Service associated with an event is of -// interest to the reconciler, the associated Ingress(es) gets be reconciled. -// The Services of interest are backend Services for tailscale Ingress and -// managed Services for an StatefulSet for a proxy configured for tailscale -// Ingress -func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(ctx context.Context, o client.Object) []reconcile.Request { - if isManagedByType(o, "ingress") { - ingName := parentFromObjectLabels(o) - return []reconcile.Request{{NamespacedName: ingName}} - } - ingList := networkingv1.IngressList{} - if err := cl.List(ctx, &ingList, client.InNamespace(o.GetNamespace())); err != nil { - logger.Debugf("error listing Ingresses: %v", err) - return nil - } - reqs := make([]reconcile.Request, 0) - for _, ing := range ingList.Items { - if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName { - return nil - } - if ing.Spec.DefaultBackend != nil && ing.Spec.DefaultBackend.Service != nil && ing.Spec.DefaultBackend.Service.Name == o.GetName() { - reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)}) - } - for _, rule := range ing.Spec.Rules { - if rule.HTTP == nil { - continue - } - for _, path := range rule.HTTP.Paths { - if path.Backend.Service != nil && path.Backend.Service.Name == o.GetName() { - reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)}) - } - } - } - } - return reqs - } -} - -func serviceHandler(_ context.Context, o client.Object) []reconcile.Request { - if _, ok := o.GetAnnotations()[AnnotationProxyGroup]; ok { - // Do not reconcile Services for ProxyGroup. - return nil - } - if isManagedByType(o, "svc") { - // If this is a Service managed by a Service we want to enqueue its parent - return []reconcile.Request{{NamespacedName: parentFromObjectLabels(o)}} - } - if isManagedResource(o) { - // If this is a Servce managed by a resource that is not a Service, we leave it alone - return nil - } - // If this is not a managed Service we want to enqueue it - return []reconcile.Request{ - { - NamespacedName: types.NamespacedName{ - Namespace: o.GetNamespace(), - Name: o.GetName(), - }, - }, - } -} - -// isMagicDNSName reports whether name is a full tailnet node FQDN (with or -// without final dot). -func isMagicDNSName(name string) bool { - validMagicDNSName := regexp.MustCompile(`^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.ts\.net\.?$`) - return validMagicDNSName.MatchString(name) -} - -// egressSvcsHandler returns accepts a Kubernetes object and returns a reconcile -// request for it , if the object is a Tailscale egress Service meant to be -// exposed on a ProxyGroup. -func egressSvcsHandler(_ context.Context, o client.Object) []reconcile.Request { - if !isEgressSvcForProxyGroup(o) { - return nil - } - return []reconcile.Request{ - { - NamespacedName: types.NamespacedName{ - Namespace: o.GetNamespace(), - Name: o.GetName(), - }, - }, - } -} - -// egressEpsHandler returns accepts an EndpointSlice and, if the EndpointSlice -// is for an egress service, returns a reconcile request for it. -func egressEpsHandler(_ context.Context, o client.Object) []reconcile.Request { - if typ := o.GetLabels()[labelSvcType]; typ != typeEgress { - return nil - } - return []reconcile.Request{ - { - NamespacedName: types.NamespacedName{ - Namespace: o.GetNamespace(), - Name: o.GetName(), - }, - }, - } -} - -// egressEpsFromEgressPods returns a Pod event handler that checks if Pod is a replica for a ProxyGroup and if it is, -// returns reconciler requests for all egress EndpointSlices for that ProxyGroup. -func egressEpsFromPGPods(cl client.Client, ns string) handler.MapFunc { - return func(_ context.Context, o client.Object) []reconcile.Request { - if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" { - return nil - } - // TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we - // have ingress ProxyGroups. - if typ := o.GetLabels()[LabelParentType]; typ != "proxygroup" { - return nil - } - pg, ok := o.GetLabels()[LabelParentName] - if !ok { - return nil - } - return reconcileRequestsForPG(pg, cl, ns) - } -} - -// egressEpsFromPGStateSecrets returns a Secret event handler that checks if Secret is a state Secret for a ProxyGroup and if it is, -// returns reconciler requests for all egress EndpointSlices for that ProxyGroup. -func egressEpsFromPGStateSecrets(cl client.Client, ns string) handler.MapFunc { - return func(_ context.Context, o client.Object) []reconcile.Request { - if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" { - return nil - } - // TODO(irbekrm): for now this is good enough as all ProxyGroups are egress. Add a type check once we - // have ingress ProxyGroups. - if parentType := o.GetLabels()[LabelParentType]; parentType != "proxygroup" { - return nil - } - if secretType := o.GetLabels()[labelSecretType]; secretType != "state" { - return nil - } - pg, ok := o.GetLabels()[LabelParentName] - if !ok { - return nil - } - return reconcileRequestsForPG(pg, cl, ns) - } -} - -// egressSvcFromEps is an event handler for EndpointSlices. If an EndpointSlice is for an egress ExternalName Service -// meant to be exposed on a ProxyGroup, returns a reconcile request for the Service. -func egressSvcFromEps(_ context.Context, o client.Object) []reconcile.Request { - if typ := o.GetLabels()[labelSvcType]; typ != typeEgress { - return nil - } - if v, ok := o.GetLabels()[LabelManaged]; !ok || v != "true" { - return nil - } - svcName, ok := o.GetLabels()[LabelParentName] - if !ok { - return nil - } - svcNs, ok := o.GetLabels()[LabelParentNamespace] - if !ok { - return nil - } - return []reconcile.Request{ - { - NamespacedName: types.NamespacedName{ - Namespace: svcNs, - Name: svcName, - }, - }, - } -} - -func reconcileRequestsForPG(pg string, cl client.Client, ns string) []reconcile.Request { - epsList := discoveryv1.EndpointSliceList{} - if err := cl.List(context.Background(), &epsList, - client.InNamespace(ns), - client.MatchingLabels(map[string]string{labelProxyGroup: pg})); err != nil { - return nil - } - reqs := make([]reconcile.Request, 0) - for _, ep := range epsList.Items { - reqs = append(reqs, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Namespace: ep.Namespace, - Name: ep.Name, - }, - }) - } - return reqs -} - -// egressSvcsFromEgressProxyGroup is an event handler for egress ProxyGroups. It returns reconcile requests for all -// user-created ExternalName Services that should be exposed on this ProxyGroup. -func egressSvcsFromEgressProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc { - return func(_ context.Context, o client.Object) []reconcile.Request { - pg, ok := o.(*tsapi.ProxyGroup) - if !ok { - logger.Infof("[unexpected] ProxyGroup handler triggered for an object that is not a ProxyGroup") - return nil - } - if pg.Spec.Type != tsapi.ProxyGroupTypeEgress { - return nil - } - svcList := &corev1.ServiceList{} - if err := cl.List(context.Background(), svcList, client.MatchingFields{indexEgressProxyGroup: pg.Name}); err != nil { - logger.Infof("error listing Services: %v, skipping a reconcile for event on ProxyGroup %s", err, pg.Name) - return nil - } - reqs := make([]reconcile.Request, 0) - for _, svc := range svcList.Items { - reqs = append(reqs, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Namespace: svc.Namespace, - Name: svc.Name, - }, - }) - } - return reqs - } -} - -// epsFromExternalNameService is an event handler for ExternalName Services that define a Tailscale egress service that -// should be exposed on a ProxyGroup. It returns reconcile requests for EndpointSlices created for this Service. -func epsFromExternalNameService(cl client.Client, logger *zap.SugaredLogger, ns string) handler.MapFunc { - return func(_ context.Context, o client.Object) []reconcile.Request { - svc, ok := o.(*corev1.Service) - if !ok { - logger.Infof("[unexpected] Service handler triggered for an object that is not a Service") - return nil - } - if !isEgressSvcForProxyGroup(svc) { - return nil - } - epsList := &discoveryv1.EndpointSliceList{} - if err := cl.List(context.Background(), epsList, client.InNamespace(ns), - client.MatchingLabels(egressSvcChildResourceLabels(svc))); err != nil { - logger.Infof("error listing EndpointSlices: %v, skipping a reconcile for event on Service %s", err, svc.Name) - return nil - } - reqs := make([]reconcile.Request, 0) - for _, eps := range epsList.Items { - reqs = append(reqs, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Namespace: eps.Namespace, - Name: eps.Name, - }, - }) - } - return reqs - } -} - -// indexEgressServices adds a local index to a cached Tailscale egress Services meant to be exposed on a ProxyGroup. The -// index is used a list filter. -func indexEgressServices(o client.Object) []string { - if !isEgressSvcForProxyGroup(o) { - return nil - } - return []string{o.GetAnnotations()[AnnotationProxyGroup]} -} diff --git a/cmd/k8s-operator/operator_test.go b/cmd/k8s-operator/operator_test.go deleted file mode 100644 index 21ef08e520a26..0000000000000 --- a/cmd/k8s-operator/operator_test.go +++ /dev/null @@ -1,1791 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/net/dns/resolvconffile" - "tailscale.com/tstest" - "tailscale.com/tstime" - "tailscale.com/types/ptr" - "tailscale.com/util/dnsname" - "tailscale.com/util/mak" -) - -func TestLoadBalancerClass(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - recorder: record.NewFakeRecorder(100), - } - - // Create a service that we should manage, but start with a miconfiguration - // in the annotations. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetFQDN: "invalid.example.com", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - }) - - expectReconciled(t, sr, "default", "test") - - // The expected value of .status.conditions[0].LastTransitionTime until the - // proxy becomes ready. - t0 := conditionTime(clock) - - // Should have an error about invalid config. - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetFQDN: "invalid.example.com", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - Status: corev1.ServiceStatus{ - Conditions: []metav1.Condition{{ - Type: string(tsapi.ProxyReady), - Status: metav1.ConditionFalse, - LastTransitionTime: t0, - Reason: reasonProxyInvalid, - Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-fqdn: "invalid.example.com" does not appear to be a valid MagicDNS name`, - }}, - }, - } - expectEqual(t, fc, want, nil) - - // Delete the misconfiguration so the proxy starts getting created on the - // next reconcile. - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - s.ObjectMeta.Annotations = nil - }) - - clock.Advance(time.Second) - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - opts := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - want.Annotations = nil - want.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"} - want.Status = corev1.ServiceStatus{ - Conditions: []metav1.Condition{{ - Type: string(tsapi.ProxyReady), - Status: metav1.ConditionFalse, - LastTransitionTime: t0, // Status is still false, no update to transition time - Reason: reasonProxyPending, - Message: "no Tailscale hostname known yet, waiting for proxy pod to finish auth", - }}, - } - expectEqual(t, fc, want, nil) - - // Normally the Tailscale proxy pod would come up here and write its info - // into the secret. Simulate that, then verify reconcile again and verify - // that we get to the end. - mustUpdate(t, fc, "operator-ns", fullName, func(s *corev1.Secret) { - if s.Data == nil { - s.Data = map[string][]byte{} - } - s.Data["device_id"] = []byte("ts-id-1234") - s.Data["device_fqdn"] = []byte("tailscale.device.name.") - s.Data["device_ips"] = []byte(`["100.99.98.97", "2c0a:8083:94d4:2012:3165:34a5:3616:5fdf"]`) - }) - clock.Advance(time.Second) - expectReconciled(t, sr, "default", "test") - want.Status.Conditions = proxyCreatedCondition(clock) - want.Status.LoadBalancer = corev1.LoadBalancerStatus{ - Ingress: []corev1.LoadBalancerIngress{ - { - Hostname: "tailscale.device.name", - }, - { - IP: "100.99.98.97", - }, - }, - } - expectEqual(t, fc, want, nil) - - // Turn the service back into a ClusterIP service, which should make the - // operator clean up. - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - s.Spec.Type = corev1.ServiceTypeClusterIP - s.Spec.LoadBalancerClass = nil - }) - mustUpdateStatus(t, fc, "default", "test", func(s *corev1.Service) { - // Fake client doesn't automatically delete the LoadBalancer status when - // changing away from the LoadBalancer type, we have to do - // controller-manager's work by hand. - s.Status = corev1.ServiceStatus{} - }) - // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet - // didn't create any child resources since this is all faked, so the - // deletion goes through immediately. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - // The deletion triggers another reconcile, to finish the cleanup. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Service](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) - - // Note that the Tailscale-specific condition status should be gone now. - want = &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - } - expectEqual(t, fc, want, nil) -} - -func TestTailnetTargetFQDNAnnotation(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tailnetTargetFQDN := "foo.bar.ts.net." - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetFQDN: tailnetTargetFQDN, - }, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: map[string]string{ - "foo": "bar", - }, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - tailnetTargetFQDN: tailnetTargetFQDN, - hostname: "default-test", - app: kubetypes.AppEgressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetFQDN: tailnetTargetFQDN, - }, - }, - Spec: corev1.ServiceSpec{ - ExternalName: fmt.Sprintf("%s.operator-ns.svc.cluster.local", shortName), - Type: corev1.ServiceTypeExternalName, - Selector: nil, - }, - Status: corev1.ServiceStatus{ - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - - // Change the tailscale-target-fqdn annotation which should update the - // StatefulSet - tailnetTargetFQDN = "bar.baz.ts.net" - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - s.ObjectMeta.Annotations = map[string]string{ - AnnotationTailnetTargetFQDN: tailnetTargetFQDN, - } - }) - - // Remove the tailscale-target-fqdn annotation which should make the - // operator clean up - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - s.ObjectMeta.Annotations = map[string]string{} - }) - expectReconciled(t, sr, "default", "test") - - // // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet - // // didn't create any child resources since this is all faked, so the - // // deletion goes through immediately. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - // // The deletion triggers another reconcile, to finish the cleanup. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Service](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) -} - -func TestTailnetTargetIPAnnotation(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tailnetTargetIP := "100.66.66.66" - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetIP: tailnetTargetIP, - }, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: map[string]string{ - "foo": "bar", - }, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - tailnetTargetIP: tailnetTargetIP, - hostname: "default-test", - app: kubetypes.AppEgressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetIP: tailnetTargetIP, - }, - }, - Spec: corev1.ServiceSpec{ - ExternalName: fmt.Sprintf("%s.operator-ns.svc.cluster.local", shortName), - Type: corev1.ServiceTypeExternalName, - Selector: nil, - }, - Status: corev1.ServiceStatus{ - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - - // Change the tailscale-target-ip annotation which should update the - // StatefulSet - tailnetTargetIP = "100.77.77.77" - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - s.ObjectMeta.Annotations = map[string]string{ - AnnotationTailnetTargetIP: tailnetTargetIP, - } - }) - - // Remove the tailscale-target-ip annotation which should make the - // operator clean up - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - s.ObjectMeta.Annotations = map[string]string{} - }) - expectReconciled(t, sr, "default", "test") - - // // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet - // // didn't create any child resources since this is all faked, so the - // // deletion goes through immediately. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - // // The deletion triggers another reconcile, to finish the cleanup. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Service](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) -} - -func TestTailnetTargetIPAnnotation_IPCouldNotBeParsed(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - recorder: record.NewFakeRecorder(100), - } - tailnetTargetIP := "invalid-ip" - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetIP: tailnetTargetIP, - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - }) - - expectReconciled(t, sr, "default", "test") - - t0 := conditionTime(clock) - - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetIP: tailnetTargetIP, - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - Status: corev1.ServiceStatus{ - Conditions: []metav1.Condition{{ - Type: string(tsapi.ProxyReady), - Status: metav1.ConditionFalse, - LastTransitionTime: t0, - Reason: reasonProxyInvalid, - Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-ip: "invalid-ip" could not be parsed as a valid IP Address, error: ParseAddr("invalid-ip"): unable to parse IP`, - }}, - }, - } - - expectEqual(t, fc, want, nil) -} - -func TestTailnetTargetIPAnnotation_InvalidIP(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - recorder: record.NewFakeRecorder(100), - } - tailnetTargetIP := "999.999.999.999" - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetIP: tailnetTargetIP, - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - }) - - expectReconciled(t, sr, "default", "test") - - t0 := conditionTime(clock) - - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationTailnetTargetIP: tailnetTargetIP, - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - Status: corev1.ServiceStatus{ - Conditions: []metav1.Condition{{ - Type: string(tsapi.ProxyReady), - Status: metav1.ConditionFalse, - LastTransitionTime: t0, - Reason: reasonProxyInvalid, - Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-ip: "999.999.999.999" could not be parsed as a valid IP Address, error: ParseAddr("999.999.999.999"): IPv4 field has value >255`, - }}, - }, - } - - expectEqual(t, fc, want, nil) -} - -func TestAnnotations(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/expose": "true", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/expose": "true", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - Status: corev1.ServiceStatus{ - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) - - // Turn the service back into a ClusterIP service, which should make the - // operator clean up. - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - delete(s.ObjectMeta.Annotations, "tailscale.com/expose") - }) - // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet - // didn't create any child resources since this is all faked, so the - // deletion goes through immediately. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - // Second time around, the rest of cleanup happens. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Service](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) - want = &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - } - expectEqual(t, fc, want, nil) -} - -func TestAnnotationIntoLB(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/expose": "true", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - - // Normally the Tailscale proxy pod would come up here and write its info - // into the secret. Simulate that, since it would have normally happened at - // this point and the LoadBalancer is going to expect this. - mustUpdate(t, fc, "operator-ns", fullName, func(s *corev1.Secret) { - if s.Data == nil { - s.Data = map[string][]byte{} - } - s.Data["device_id"] = []byte("ts-id-1234") - s.Data["device_fqdn"] = []byte("tailscale.device.name.") - s.Data["device_ips"] = []byte(`["100.99.98.97", "2c0a:8083:94d4:2012:3165:34a5:3616:5fdf"]`) - }) - expectReconciled(t, sr, "default", "test") - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/expose": "true", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - Status: corev1.ServiceStatus{ - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) - - // Remove Tailscale's annotation, and at the same time convert the service - // into a tailscale LoadBalancer. - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - delete(s.ObjectMeta.Annotations, "tailscale.com/expose") - s.Spec.Type = corev1.ServiceTypeLoadBalancer - s.Spec.LoadBalancerClass = ptr.To("tailscale") - }) - expectReconciled(t, sr, "default", "test") - // None of the proxy machinery should have changed... - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - // ... but the service should have a LoadBalancer status. - - want = &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - Status: corev1.ServiceStatus{ - LoadBalancer: corev1.LoadBalancerStatus{ - Ingress: []corev1.LoadBalancerIngress{ - { - Hostname: "tailscale.device.name", - }, - { - IP: "100.99.98.97", - }, - }, - }, - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) -} - -func TestLBIntoAnnotation(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - - // Normally the Tailscale proxy pod would come up here and write its info - // into the secret. Simulate that, then verify reconcile again and verify - // that we get to the end. - mustUpdate(t, fc, "operator-ns", fullName, func(s *corev1.Secret) { - if s.Data == nil { - s.Data = map[string][]byte{} - } - s.Data["device_id"] = []byte("ts-id-1234") - s.Data["device_fqdn"] = []byte("tailscale.device.name.") - s.Data["device_ips"] = []byte(`["100.99.98.97", "2c0a:8083:94d4:2012:3165:34a5:3616:5fdf"]`) - }) - expectReconciled(t, sr, "default", "test") - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - Status: corev1.ServiceStatus{ - LoadBalancer: corev1.LoadBalancerStatus{ - Ingress: []corev1.LoadBalancerIngress{ - { - Hostname: "tailscale.device.name", - }, - { - IP: "100.99.98.97", - }, - }, - }, - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) - - // Turn the service back into a ClusterIP service, but also add the - // tailscale annotation. - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - s.ObjectMeta.Annotations = map[string]string{ - "tailscale.com/expose": "true", - } - s.Spec.Type = corev1.ServiceTypeClusterIP - s.Spec.LoadBalancerClass = nil - }) - mustUpdateStatus(t, fc, "default", "test", func(s *corev1.Service) { - // Fake client doesn't automatically delete the LoadBalancer status when - // changing away from the LoadBalancer type, we have to do - // controller-manager's work by hand. - s.Status = corev1.ServiceStatus{} - }) - expectReconciled(t, sr, "default", "test") - - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - - want = &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - Annotations: map[string]string{ - "tailscale.com/expose": "true", - }, - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - Status: corev1.ServiceStatus{ - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) -} - -func TestCustomHostname(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/expose": "true", - "tailscale.com/hostname": "reindeer-flotilla", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "reindeer-flotilla", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, o), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - want := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Finalizers: []string{"tailscale.com/finalizer"}, - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/expose": "true", - "tailscale.com/hostname": "reindeer-flotilla", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - Status: corev1.ServiceStatus{ - Conditions: proxyCreatedCondition(clock), - }, - } - expectEqual(t, fc, want, nil) - - // Turn the service back into a ClusterIP service, which should make the - // operator clean up. - mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { - delete(s.ObjectMeta.Annotations, "tailscale.com/expose") - }) - // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet - // didn't create any child resources since this is all faked, so the - // deletion goes through immediately. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - // Second time around, the rest of cleanup happens. - expectReconciled(t, sr, "default", "test") - expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) - expectMissing[corev1.Service](t, fc, "operator-ns", shortName) - expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) - want = &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/hostname": "reindeer-flotilla", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - } - expectEqual(t, fc, want, nil) -} - -func TestCustomPriorityClassName(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - proxyPriorityClassName: "custom-priority-class-name", - }, - logger: zl.Sugar(), - clock: clock, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - "tailscale.com/expose": "true", - "tailscale.com/hostname": "tailscale-critical", - }, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeClusterIP, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "tailscale-critical", - priorityClassName: "custom-priority-class-name", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) -} - -func TestProxyClassForService(t *testing.T) { - // Setup - pc := &tsapi.ProxyClass{ - ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"}, - Spec: tsapi.ProxyClassSpec{ - TailscaleConfig: &tsapi.TailscaleConfig{ - AcceptRoutes: true, - }, - StatefulSet: &tsapi.StatefulSet{ - Labels: map[string]string{"foo": "bar"}, - Annotations: map[string]string{"bar.io/foo": "some-val"}, - Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}}, - } - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(pc). - WithStatusSubresource(pc). - Build() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // 1. A new tailscale LoadBalancer Service is created without any - // ProxyClass. Resources get created for it as usual. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - }) - expectReconciled(t, sr, "default", "test") - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - opts := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // 2. The Service gets updated with tailscale.com/proxy-class label - // pointing at the 'custom-metadata' ProxyClass. The ProxyClass is not - // yet ready, so no changes are actually applied to the proxy resources. - mustUpdate(t, fc, "default", "test", func(svc *corev1.Service) { - mak.Set(&svc.Labels, LabelProxyClass, "custom-metadata") - }) - expectReconciled(t, sr, "default", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - - // 3. ProxyClass is set to Ready, the Service gets reconciled by the - // services-reconciler and the customization from the ProxyClass is - // applied to the proxy resources. - mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) { - pc.Status = tsapi.ProxyClassStatus{ - Conditions: []metav1.Condition{{ - Status: metav1.ConditionTrue, - Type: string(tsapi.ProxyClassReady), - ObservedGeneration: pc.Generation, - }}} - }) - opts.proxyClass = pc.Name - expectReconciled(t, sr, "default", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - expectEqual(t, fc, expectedSecret(t, fc, opts), removeAuthKeyIfExistsModifier(t)) - - // 4. tailscale.com/proxy-class label is removed from the Service, the - // configuration from the ProxyClass is removed from the cluster - // resources. - mustUpdate(t, fc, "default", "test", func(svc *corev1.Service) { - delete(svc.Labels, LabelProxyClass) - }) - opts.proxyClass = "" - expectReconciled(t, sr, "default", "test") - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) -} - -func TestDefaultLoadBalancer(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - isDefaultLoadBalancer: true, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) - -} - -func TestProxyFirewallMode(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - tsFirewallMode: "nftables", - }, - logger: zl.Sugar(), - clock: clock, - isDefaultLoadBalancer: true, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - firewallMode: "nftables", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation) -} - -func TestTailscaledConfigfileHash(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - isDefaultLoadBalancer: true, - } - - // Create a service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - o := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - confFileHash: "a67b5ad3ff605531c822327e8f1a23dd0846e1075b722c13402f7d5d0ba32ba2", - app: kubetypes.AppIngressProxy, - } - expectEqual(t, fc, expectedSTS(t, fc, o), nil) - - // 2. Hostname gets changed, configfile is updated and a new hash value - // is produced. - mustUpdate(t, fc, "default", "test", func(svc *corev1.Service) { - mak.Set(&svc.Annotations, AnnotationHostname, "another-test") - }) - o.hostname = "another-test" - o.confFileHash = "888a993ebee20ad6be99623b45015339de117946850cf1252bede0b570e04293" - expectReconciled(t, sr, "default", "test") - expectEqual(t, fc, expectedSTS(t, fc, o), nil) -} -func Test_isMagicDNSName(t *testing.T) { - tests := []struct { - in string - want bool - }{ - { - in: "foo.tail4567.ts.net", - want: true, - }, - { - in: "foo.tail4567.ts.net.", - want: true, - }, - { - in: "foo.tail4567", - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.in, func(t *testing.T) { - if got := isMagicDNSName(tt.in); got != tt.want { - t.Errorf("isMagicDNSName(%q) = %v, want %v", tt.in, got, tt.want) - } - }) - } -} - -func Test_serviceHandlerForIngress(t *testing.T) { - fc := fake.NewFakeClient() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - - // 1. An event on a headless Service for a tailscale Ingress results in - // the Ingress being reconciled. - mustCreate(t, fc, &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ing-1", - Namespace: "ns-1", - }, - Spec: networkingv1.IngressSpec{IngressClassName: ptr.To(tailscaleIngressClassName)}, - }) - svc1 := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "headless-1", - Namespace: "tailscale", - Labels: map[string]string{ - LabelManaged: "true", - LabelParentName: "ing-1", - LabelParentNamespace: "ns-1", - LabelParentType: "ingress", - }, - }, - } - mustCreate(t, fc, svc1) - wantReqs := []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-1", Name: "ing-1"}}} - gotReqs := serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), svc1) - if diff := cmp.Diff(gotReqs, wantReqs); diff != "" { - t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff) - } - - // 2. An event on a Service that is the default backend for a tailscale - // Ingress results in the Ingress being reconciled. - mustCreate(t, fc, &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ing-2", - Namespace: "ns-2", - }, - Spec: networkingv1.IngressSpec{ - DefaultBackend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{Name: "def-backend"}, - }, - IngressClassName: ptr.To(tailscaleIngressClassName), - }, - }) - backendSvc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "def-backend", - Namespace: "ns-2", - }, - } - mustCreate(t, fc, backendSvc) - wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-2", Name: "ing-2"}}} - gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc) - if diff := cmp.Diff(gotReqs, wantReqs); diff != "" { - t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff) - } - - // 3. An event on a Service that is one of the non-default backends for - // a tailscale Ingress results in the Ingress being reconciled. - mustCreate(t, fc, &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ing-3", - Namespace: "ns-3", - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To(tailscaleIngressClassName), - Rules: []networkingv1.IngressRule{{IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - {Backend: networkingv1.IngressBackend{Service: &networkingv1.IngressServiceBackend{Name: "backend"}}}}, - }}}}, - }, - }) - backendSvc2 := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "backend", - Namespace: "ns-3", - }, - } - mustCreate(t, fc, backendSvc2) - wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-3", Name: "ing-3"}}} - gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc2) - if diff := cmp.Diff(gotReqs, wantReqs); diff != "" { - t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff) - } - - // 4. An event on a Service that is a backend for an Ingress that is not - // tailscale Ingress does not result in an Ingress reconcile. - mustCreate(t, fc, &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ing-4", - Namespace: "ns-4", - }, - Spec: networkingv1.IngressSpec{ - Rules: []networkingv1.IngressRule{{IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - {Backend: networkingv1.IngressBackend{Service: &networkingv1.IngressServiceBackend{Name: "non-ts-backend"}}}}, - }}}}, - }, - }) - nonTSBackend := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "non-ts-backend", - Namespace: "ns-4", - }, - } - mustCreate(t, fc, nonTSBackend) - gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), nonTSBackend) - if len(gotReqs) > 0 { - t.Errorf("unexpected reconcile request for a Service that does not belong to a Tailscale Ingress: %#+v\n", gotReqs) - } - - // 5. An event on a Service not related to any Ingress does not result - // in an Ingress reconcile. - someSvc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "some-svc", - Namespace: "ns-4", - }, - } - mustCreate(t, fc, someSvc) - gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), someSvc) - if len(gotReqs) > 0 { - t.Errorf("unexpected reconcile request for a Service that does not belong to any Ingress: %#+v\n", gotReqs) - } -} - -func Test_clusterDomainFromResolverConf(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tests := []struct { - name string - conf *resolvconffile.Config - namespace string - want string - }{ - { - name: "success- custom domain", - conf: &resolvconffile.Config{ - SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.department.org.io"), toFQDN(t, "svc.department.org.io"), toFQDN(t, "department.org.io")}, - }, - namespace: "foo", - want: "department.org.io", - }, - { - name: "success- default domain", - conf: &resolvconffile.Config{ - SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.cluster.local."), toFQDN(t, "svc.cluster.local."), toFQDN(t, "cluster.local.")}, - }, - namespace: "foo", - want: "cluster.local", - }, - { - name: "only two search domains found", - conf: &resolvconffile.Config{ - SearchDomains: []dnsname.FQDN{toFQDN(t, "svc.department.org.io"), toFQDN(t, "department.org.io")}, - }, - namespace: "foo", - want: "cluster.local", - }, - { - name: "first search domain does not match the expected structure", - conf: &resolvconffile.Config{ - SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.bar.department.org.io"), toFQDN(t, "svc.department.org.io"), toFQDN(t, "some.other.fqdn")}, - }, - namespace: "foo", - want: "cluster.local", - }, - { - name: "second search domain does not match the expected structure", - conf: &resolvconffile.Config{ - SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.department.org.io"), toFQDN(t, "foo.department.org.io"), toFQDN(t, "some.other.fqdn")}, - }, - namespace: "foo", - want: "cluster.local", - }, - { - name: "third search domain does not match the expected structure", - conf: &resolvconffile.Config{ - SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.department.org.io"), toFQDN(t, "svc.department.org.io"), toFQDN(t, "some.other.fqdn")}, - }, - namespace: "foo", - want: "cluster.local", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := clusterDomainFromResolverConf(tt.conf, tt.namespace, zl.Sugar()); got != tt.want { - t.Errorf("clusterDomainFromResolverConf() = %v, want %v", got, tt.want) - } - }) - } -} -func Test_authKeyRemoval(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - - // 1. A new Service that should be exposed via Tailscale gets created, a Secret with a config that contains auth - // key is generated. - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - UID: types.UID("1234-UID"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "10.20.30.40", - Type: corev1.ServiceTypeLoadBalancer, - LoadBalancerClass: ptr.To("tailscale"), - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - opts := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetIP: "10.20.30.40", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // 2. Apply update to the Secret that imitates the proxy setting device_id. - s := expectedSecret(t, fc, opts) - mustUpdate(t, fc, s.Namespace, s.Name, func(s *corev1.Secret) { - mak.Set(&s.Data, "device_id", []byte("dkkdi4CNTRL")) - }) - - // 3. Config should no longer contain auth key - expectReconciled(t, sr, "default", "test") - opts.shouldRemoveAuthKey = true - opts.secretExtraData = map[string][]byte{"device_id": []byte("dkkdi4CNTRL")} - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) -} - -func Test_externalNameService(t *testing.T) { - fc := fake.NewFakeClient() - ft := &fakeTSClient{} - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - - // 1. A External name Service that should be exposed via Tailscale gets - // created. - clock := tstest.NewClock(tstest.ClockOpts{}) - sr := &ServiceReconciler{ - Client: fc, - ssr: &tailscaleSTSReconciler{ - Client: fc, - tsClient: ft, - defaultTags: []string{"tag:k8s"}, - operatorNamespace: "operator-ns", - proxyImage: "tailscale/tailscale", - }, - logger: zl.Sugar(), - clock: clock, - } - - // 1. Create an ExternalName Service that we should manage, and check that the initial round - // of objects looks right. - mustCreate(t, fc, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Annotations: map[string]string{ - AnnotationExpose: "true", - }, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeExternalName, - ExternalName: "foo.com", - }, - }) - - expectReconciled(t, sr, "default", "test") - - fullName, shortName := findGenName(t, fc, "default", "test", "svc") - opts := configOpts{ - stsName: shortName, - secretName: fullName, - namespace: "default", - parentType: "svc", - hostname: "default-test", - clusterTargetDNS: "foo.com", - app: kubetypes.AppIngressProxy, - } - - expectEqual(t, fc, expectedSecret(t, fc, opts), nil) - expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil) - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) - - // 2. Change the ExternalName and verify that changes get propagated. - mustUpdate(t, sr, "default", "test", func(s *corev1.Service) { - s.Spec.ExternalName = "bar.com" - }) - expectReconciled(t, sr, "default", "test") - opts.clusterTargetDNS = "bar.com" - expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation) -} - -func toFQDN(t *testing.T, s string) dnsname.FQDN { - t.Helper() - fqdn, err := dnsname.ToFQDN(s) - if err != nil { - t.Fatalf("error coverting %q to dnsname.FQDN: %v", s, err) - } - return fqdn -} - -func proxyCreatedCondition(clock tstime.Clock) []metav1.Condition { - return []metav1.Condition{{ - Type: string(tsapi.ProxyReady), - Status: metav1.ConditionTrue, - ObservedGeneration: 0, - LastTransitionTime: conditionTime(clock), - Reason: reasonProxyCreated, - Message: reasonProxyCreated, - }} -} - -func conditionTime(clock tstime.Clock) metav1.Time { - return metav1.NewTime(clock.Now().Truncate(time.Second)) -} diff --git a/cmd/k8s-operator/proxy.go b/cmd/k8s-operator/proxy.go deleted file mode 100644 index 672f07b1f1608..0000000000000 --- a/cmd/k8s-operator/proxy.go +++ /dev/null @@ -1,421 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "crypto/tls" - "fmt" - "log" - "net/http" - "net/http/httputil" - "net/netip" - "net/url" - "os" - "strings" - - "github.com/pkg/errors" - "go.uber.org/zap" - "k8s.io/client-go/rest" - "k8s.io/client-go/transport" - "tailscale.com/client/tailscale" - "tailscale.com/client/tailscale/apitype" - ksr "tailscale.com/k8s-operator/sessionrecording" - "tailscale.com/kube/kubetypes" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" - "tailscale.com/util/clientmetric" - "tailscale.com/util/ctxkey" - "tailscale.com/util/set" -) - -var ( - // counterNumRequestsproxies counts the number of API server requests proxied via this proxy. - counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied") - whoIsKey = ctxkey.New("", (*apitype.WhoIsResponse)(nil)) -) - -type apiServerProxyMode int - -func (a apiServerProxyMode) String() string { - switch a { - case apiserverProxyModeDisabled: - return "disabled" - case apiserverProxyModeEnabled: - return "auth" - case apiserverProxyModeNoAuth: - return "noauth" - default: - return "unknown" - } -} - -const ( - apiserverProxyModeDisabled apiServerProxyMode = iota - apiserverProxyModeEnabled - apiserverProxyModeNoAuth -) - -func parseAPIProxyMode() apiServerProxyMode { - haveAuthProxyEnv := os.Getenv("AUTH_PROXY") != "" - haveAPIProxyEnv := os.Getenv("APISERVER_PROXY") != "" - switch { - case haveAPIProxyEnv && haveAuthProxyEnv: - log.Fatal("AUTH_PROXY and APISERVER_PROXY are mutually exclusive") - case haveAuthProxyEnv: - var authProxyEnv = defaultBool("AUTH_PROXY", false) // deprecated - if authProxyEnv { - return apiserverProxyModeEnabled - } - return apiserverProxyModeDisabled - case haveAPIProxyEnv: - var apiProxyEnv = defaultEnv("APISERVER_PROXY", "") // true, false or "noauth" - switch apiProxyEnv { - case "true": - return apiserverProxyModeEnabled - case "false", "": - return apiserverProxyModeDisabled - case "noauth": - return apiserverProxyModeNoAuth - default: - panic(fmt.Sprintf("unknown APISERVER_PROXY value %q", apiProxyEnv)) - } - } - return apiserverProxyModeDisabled -} - -// maybeLaunchAPIServerProxy launches the auth proxy, which is a small HTTP server -// that authenticates requests using the Tailscale LocalAPI and then proxies -// them to the kube-apiserver. -func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server, mode apiServerProxyMode) { - if mode == apiserverProxyModeDisabled { - return - } - startlog := zlog.Named("launchAPIProxy") - if mode == apiserverProxyModeNoAuth { - restConfig = rest.AnonymousClientConfig(restConfig) - } - cfg, err := restConfig.TransportConfig() - if err != nil { - startlog.Fatalf("could not get rest.TransportConfig(): %v", err) - } - - // 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 get transport.TLSConfigFor(): %v", err) - } - 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 runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode, restConfig.Host) -} - -// runAPIServerProxy 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. -// -// mode controls how the proxy behaves: -// - apiserverProxyModeDisabled: the proxy is not started. -// - apiserverProxyModeEnabled: the proxy is started and requests are impersonated using the -// caller's identity from the Tailscale LocalAPI. -// - apiserverProxyModeNoAuth: the proxy is started and requests are not impersonated and -// are passed through to the Kubernetes API. -// -// It never returns. -func runAPIServerProxy(ts *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode, host string) { - if mode == apiserverProxyModeDisabled { - return - } - ln, err := ts.Listen("tcp", ":443") - if err != nil { - log.Fatalf("could not listen on :443: %v", err) - } - u, err := url.Parse(host) - if err != nil { - log.Fatalf("runAPIServerProxy: failed to parse URL %v", err) - } - - lc, err := ts.LocalClient() - if err != nil { - log.Fatalf("could not get local client: %v", err) - } - - ap := &apiserverProxy{ - log: log, - lc: lc, - mode: mode, - upstreamURL: u, - ts: ts, - } - ap.rp = &httputil.ReverseProxy{ - Rewrite: func(pr *httputil.ProxyRequest) { - ap.addImpersonationHeadersAsRequired(pr.Out) - }, - Transport: rt, - } - - mux := http.NewServeMux() - mux.HandleFunc("/", ap.serveDefault) - mux.HandleFunc("POST /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecSPDY) - mux.HandleFunc("GET /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecWS) - - 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: mux, - } - log.Infof("API server proxy in %q mode is listening on %s", mode, ln.Addr()) - if err := hs.ServeTLS(ln, "", ""); err != nil { - log.Fatalf("runAPIServerProxy: failed to serve %v", err) - } -} - -// apiserverProxy is an [net/http.Handler] that authenticates requests using the Tailscale -// LocalAPI and then proxies them to the Kubernetes API. -type apiserverProxy struct { - log *zap.SugaredLogger - lc *tailscale.LocalClient - rp *httputil.ReverseProxy - - mode apiServerProxyMode - ts *tsnet.Server - upstreamURL *url.URL -} - -// serveDefault is the default handler for Kubernetes API server requests. -func (ap *apiserverProxy) serveDefault(w http.ResponseWriter, r *http.Request) { - who, err := ap.whoIs(r) - if err != nil { - ap.authError(w, err) - return - } - counterNumRequestsProxied.Add(1) - ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who))) -} - -// serveExecSPDY serves 'kubectl exec' requests for sessions streamed over SPDY, -// optionally configuring the kubectl exec sessions to be recorded. -func (ap *apiserverProxy) serveExecSPDY(w http.ResponseWriter, r *http.Request) { - ap.execForProto(w, r, ksr.SPDYProtocol) -} - -// serveExecWS serves 'kubectl exec' requests for sessions streamed over WebSocket, -// optionally configuring the kubectl exec sessions to be recorded. -func (ap *apiserverProxy) serveExecWS(w http.ResponseWriter, r *http.Request) { - ap.execForProto(w, r, ksr.WSProtocol) -} - -func (ap *apiserverProxy) execForProto(w http.ResponseWriter, r *http.Request, proto ksr.Protocol) { - const ( - podNameKey = "pod" - namespaceNameKey = "namespace" - upgradeHeaderKey = "Upgrade" - ) - - who, err := ap.whoIs(r) - if err != nil { - ap.authError(w, err) - return - } - counterNumRequestsProxied.Add(1) - failOpen, addrs, err := determineRecorderConfig(who) - if err != nil { - ap.log.Errorf("error trying to determine whether the 'kubectl exec' session needs to be recorded: %v", err) - return - } - if failOpen && len(addrs) == 0 { // will not record - ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who))) - return - } - ksr.CounterSessionRecordingsAttempted.Add(1) // at this point we know that users intended for this session to be recorded - if !failOpen && len(addrs) == 0 { - msg := "forbidden: 'kubectl exec' session must be recorded, but no recorders are available." - ap.log.Error(msg) - http.Error(w, msg, http.StatusForbidden) - return - } - - wantsHeader := upgradeHeaderForProto[proto] - if h := r.Header.Get(upgradeHeaderKey); h != wantsHeader { - msg := fmt.Sprintf("[unexpected] unable to verify that streaming protocol is %s, wants Upgrade header %q, got: %q", proto, wantsHeader, h) - if failOpen { - msg = msg + "; failure mode is 'fail open'; continuing session without recording." - ap.log.Warn(msg) - ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who))) - return - } - ap.log.Error(msg) - msg += "; failure mode is 'fail closed'; closing connection." - http.Error(w, msg, http.StatusForbidden) - return - } - - opts := ksr.HijackerOpts{ - Req: r, - W: w, - Proto: proto, - TS: ap.ts, - Who: who, - Addrs: addrs, - FailOpen: failOpen, - Pod: r.PathValue(podNameKey), - Namespace: r.PathValue(namespaceNameKey), - Log: ap.log, - } - h := ksr.New(opts) - - ap.rp.ServeHTTP(h, r.WithContext(whoIsKey.WithValue(r.Context(), who))) -} - -func (h *apiserverProxy) addImpersonationHeadersAsRequired(r *http.Request) { - r.URL.Scheme = h.upstreamURL.Scheme - r.URL.Host = h.upstreamURL.Host - if h.mode == apiserverProxyModeNoAuth { - // If we are not providing authentication, then we are just - // proxying to the Kubernetes API, so we don't need to do - // anything else. - return - } - - // 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 - - // 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-") { - r.Header.Del(k) - } - } - - // Now add the impersonation headers that we want. - if err := addImpersonationHeaders(r, h.log); err != nil { - log.Printf("failed to add impersonation headers: " + err.Error()) - } -} - -func (ap *apiserverProxy) whoIs(r *http.Request) (*apitype.WhoIsResponse, error) { - return ap.lc.WhoIs(r.Context(), r.RemoteAddr) -} - -func (ap *apiserverProxy) authError(w http.ResponseWriter, err error) { - ap.log.Errorf("failed to authenticate caller: %v", err) - http.Error(w, "failed to authenticate caller", http.StatusInternalServerError) -} - -const ( - // oldCapabilityName is a legacy form of - // tailfcg.PeerCapabilityKubernetes capability. The only capability rule - // that is respected for this form is group impersonation - for - // backwards compatibility reasons. - // TODO (irbekrm): determine if anyone uses this and remove if possible. - oldCapabilityName = "https://" + tailcfg.PeerCapabilityKubernetes -) - -// addImpersonationHeaders adds the appropriate headers to r to impersonate the -// caller when proxying to the Kubernetes API. It uses the WhoIsResponse stashed -// in the context by the apiserverProxy. -func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error { - log = log.With("remote", r.RemoteAddr) - who := whoIsKey.Value(r.Context()) - rules, err := tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes) - if len(rules) == 0 && err == nil { - // Try the old capability name for backwards compatibility. - rules, err = tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, oldCapabilityName) - } - if err != nil { - return fmt.Errorf("failed to unmarshal capability: %v", err) - } - - var groupsAdded set.Slice[string] - for _, rule := range rules { - if rule.Impersonate == nil { - continue - } - for _, group := range rule.Impersonate.Groups { - if groupsAdded.Contains(group) { - continue - } - r.Header.Add("Impersonate-Group", group) - groupsAdded.Add(group) - log.Debugf("adding group impersonation header for user group %s", group) - } - } - - if !who.Node.IsTagged() { - r.Header.Set("Impersonate-User", who.UserProfile.LoginName) - log.Debugf("adding user impersonation header for user %s", who.UserProfile.LoginName) - return nil - } - // "Impersonate-Group" requires "Impersonate-User" to be set, so we set it - // to the node FQDN for tagged nodes. - nodeName := strings.TrimSuffix(who.Node.Name, ".") - r.Header.Set("Impersonate-User", nodeName) - log.Debugf("adding user impersonation header for node name %s", nodeName) - - // For legacy behavior (before caps), set the groups to the nodes tags. - if groupsAdded.Slice().Len() == 0 { - for _, tag := range who.Node.Tags { - r.Header.Add("Impersonate-Group", tag) - log.Debugf("adding group impersonation header for node tag %s", tag) - } - } - return nil -} - -// determineRecorderConfig determines recorder config from requester's peer -// capabilities. Determines whether a 'kubectl exec' session from this requester -// needs to be recorded and what recorders the recording should be sent to. -func determineRecorderConfig(who *apitype.WhoIsResponse) (failOpen bool, recorderAddresses []netip.AddrPort, _ error) { - if who == nil { - return false, nil, errors.New("[unexpected] cannot determine caller") - } - failOpen = true - rules, err := tailcfg.UnmarshalCapJSON[kubetypes.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes) - if err != nil { - return failOpen, nil, fmt.Errorf("failed to unmarshal Kubernetes capability: %w", err) - } - if len(rules) == 0 { - return failOpen, nil, nil - } - - for _, rule := range rules { - if len(rule.RecorderAddrs) != 0 { - // TODO (irbekrm): here or later determine if the - // recorders behind those addrs are online - else we - // spend 30s trying to reach a recorder whose tailscale - // status is offline. - recorderAddresses = append(recorderAddresses, rule.RecorderAddrs...) - } - if rule.EnforceRecorder { - failOpen = false - } - } - return failOpen, recorderAddresses, nil -} - -var upgradeHeaderForProto = map[ksr.Protocol]string{ - ksr.SPDYProtocol: "SPDY/3.1", - ksr.WSProtocol: "websocket", -} diff --git a/cmd/k8s-operator/proxy_test.go b/cmd/k8s-operator/proxy_test.go deleted file mode 100644 index d1d5733e7f49f..0000000000000 --- a/cmd/k8s-operator/proxy_test.go +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "net/http" - "net/netip" - "reflect" - "testing" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/tailcfg" - "tailscale.com/util/must" -) - -func TestImpersonationHeaders(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tests := []struct { - name string - emailish string - tags []string - capMap tailcfg.PeerCapMap - - wantHeaders http.Header - }{ - { - name: "user", - emailish: "foo@example.com", - wantHeaders: http.Header{ - "Impersonate-User": {"foo@example.com"}, - }, - }, - { - name: "tagged", - emailish: "tagged-device", - tags: []string{"tag:foo", "tag:bar"}, - wantHeaders: http.Header{ - "Impersonate-User": {"node.ts.net"}, - "Impersonate-Group": {"tag:foo", "tag:bar"}, - }, - }, - { - name: "user-with-cap", - emailish: "foo@example.com", - capMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityKubernetes: { - tailcfg.RawMessage(`{"impersonate":{"groups":["group1","group2"]}}`), - tailcfg.RawMessage(`{"impersonate":{"groups":["group1","group3"]}}`), // One group is duplicated. - tailcfg.RawMessage(`{"impersonate":{"groups":["group4"]}}`), - tailcfg.RawMessage(`{"impersonate":{"groups":["group2"]}}`), // duplicate - - // These should be ignored, but should parse correctly. - tailcfg.RawMessage(`{}`), - tailcfg.RawMessage(`{"impersonate":{}}`), - tailcfg.RawMessage(`{"impersonate":{"groups":[]}}`), - }, - }, - wantHeaders: http.Header{ - "Impersonate-Group": {"group1", "group2", "group3", "group4"}, - "Impersonate-User": {"foo@example.com"}, - }, - }, - { - name: "tagged-with-cap", - emailish: "tagged-device", - tags: []string{"tag:foo", "tag:bar"}, - capMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityKubernetes: { - tailcfg.RawMessage(`{"impersonate":{"groups":["group1"]}}`), - }, - }, - wantHeaders: http.Header{ - "Impersonate-Group": {"group1"}, - "Impersonate-User": {"node.ts.net"}, - }, - }, - { - name: "mix-of-caps", - emailish: "tagged-device", - tags: []string{"tag:foo", "tag:bar"}, - capMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityKubernetes: { - tailcfg.RawMessage(`{"impersonate":{"groups":["group1"]},"recorder":["tag:foo"],"enforceRecorder":true}`), - }, - }, - wantHeaders: http.Header{ - "Impersonate-Group": {"group1"}, - "Impersonate-User": {"node.ts.net"}, - }, - }, - { - name: "bad-cap", - emailish: "tagged-device", - tags: []string{"tag:foo", "tag:bar"}, - capMap: tailcfg.PeerCapMap{ - tailcfg.PeerCapabilityKubernetes: { - tailcfg.RawMessage(`[]`), - }, - }, - wantHeaders: http.Header{}, - }, - } - - for _, tc := range tests { - r := must.Get(http.NewRequest("GET", "https://op.ts.net/api/foo", nil)) - r = r.WithContext(whoIsKey.WithValue(r.Context(), &apitype.WhoIsResponse{ - Node: &tailcfg.Node{ - Name: "node.ts.net", - Tags: tc.tags, - }, - UserProfile: &tailcfg.UserProfile{ - LoginName: tc.emailish, - }, - CapMap: tc.capMap, - })) - addImpersonationHeaders(r, zl.Sugar()) - - if d := cmp.Diff(tc.wantHeaders, r.Header); d != "" { - t.Errorf("unexpected header (-want +got):\n%s", d) - } - } -} - -func Test_determineRecorderConfig(t *testing.T) { - addr1, addr2 := netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"), netip.MustParseAddrPort("100.99.99.99:80") - tests := []struct { - name string - wantFailOpen bool - wantRecorderAddresses []netip.AddrPort - who *apitype.WhoIsResponse - }{ - { - name: "two_ips_fail_closed", - who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"],"enforceRecorder":true}`}}), - wantRecorderAddresses: []netip.AddrPort{addr1, addr2}, - }, - { - name: "two_ips_fail_open", - who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"]}`}}), - wantRecorderAddresses: []netip.AddrPort{addr1, addr2}, - wantFailOpen: true, - }, - { - name: "odd_rule_combination_fail_closed", - who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["100.99.99.99:80"],"enforceRecorder":false}`, `{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"]}`, `{"enforceRecorder":true,"impersonate":{"groups":["system:masters"]}}`}}), - wantRecorderAddresses: []netip.AddrPort{addr2, addr1}, - }, - { - name: "no_caps", - who: whoResp(map[string][]string{}), - wantFailOpen: true, - }, - { - name: "no_recorder_caps", - who: whoResp(map[string][]string{"foo": {`{"x":"y"}`}, string(tailcfg.PeerCapabilityKubernetes): {`{"impersonate":{"groups":["system:masters"]}}`}}), - wantFailOpen: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotFailOpen, gotRecorderAddresses, err := determineRecorderConfig(tt.who) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotFailOpen != tt.wantFailOpen { - t.Errorf("determineRecorderConfig() gotFailOpen = %v, want %v", gotFailOpen, tt.wantFailOpen) - } - if !reflect.DeepEqual(gotRecorderAddresses, tt.wantRecorderAddresses) { - t.Errorf("determineRecorderConfig() gotRecorderAddresses = %v, want %v", gotRecorderAddresses, tt.wantRecorderAddresses) - } - }) - } -} - -func whoResp(capMap map[string][]string) *apitype.WhoIsResponse { - resp := &apitype.WhoIsResponse{ - CapMap: tailcfg.PeerCapMap{}, - } - for cap, rules := range capMap { - resp.CapMap[tailcfg.PeerCapability(cap)] = raw(rules...) - } - return resp -} - -func raw(in ...string) []tailcfg.RawMessage { - var out []tailcfg.RawMessage - for _, i := range in { - out = append(out, tailcfg.RawMessage(i)) - } - return out -} diff --git a/cmd/k8s-operator/proxyclass.go b/cmd/k8s-operator/proxyclass.go deleted file mode 100644 index 882a9030fa75d..0000000000000 --- a/cmd/k8s-operator/proxyclass.go +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "fmt" - "slices" - "strings" - "sync" - - dockerref "github.com/distribution/reference" - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - apivalidation "k8s.io/apimachinery/pkg/api/validation" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - metavalidation "k8s.io/apimachinery/pkg/apis/meta/v1/validation" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/validation/field" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstime" - "tailscale.com/util/clientmetric" - "tailscale.com/util/set" -) - -const ( - reasonProxyClassInvalid = "ProxyClassInvalid" - reasonProxyClassValid = "ProxyClassValid" - reasonCustomTSEnvVar = "CustomTSEnvVar" - messageProxyClassInvalid = "ProxyClass is not valid: %v" - messageCustomTSEnvVar = "ProxyClass overrides the default value for %s env var for %s container. Running with custom values for Tailscale env vars is not recommended and might break in the future." -) - -type ProxyClassReconciler struct { - client.Client - - recorder record.EventRecorder - logger *zap.SugaredLogger - clock tstime.Clock - - mu sync.Mutex // protects following - - // managedProxyClasses is a set of all ProxyClass resources that we're currently - // managing. This is only used for metrics. - managedProxyClasses set.Slice[types.UID] -} - -var ( - // gaugeProxyClassResources tracks the number of ProxyClass resources - // that we're currently managing. - gaugeProxyClassResources = clientmetric.NewGauge("k8s_proxyclass_resources") -) - -func (pcr *ProxyClassReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { - logger := pcr.logger.With("ProxyClass", req.Name) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - pc := new(tsapi.ProxyClass) - err = pcr.Get(ctx, req.NamespacedName, pc) - if apierrors.IsNotFound(err) { - logger.Debugf("ProxyClass not found, assuming it was deleted") - return reconcile.Result{}, nil - } else if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com ProxyClass: %w", err) - } - if !pc.DeletionTimestamp.IsZero() { - logger.Debugf("ProxyClass is being deleted") - return reconcile.Result{}, pcr.maybeCleanup(ctx, logger, pc) - } - - // Add a finalizer so that we can ensure that metrics get updated when - // this ProxyClass is deleted. - if !slices.Contains(pc.Finalizers, FinalizerName) { - logger.Debugf("updating ProxyClass finalizers") - pc.Finalizers = append(pc.Finalizers, FinalizerName) - if err := pcr.Update(ctx, pc); err != nil { - return res, fmt.Errorf("failed to add finalizer: %w", err) - } - } - - // Ensure this ProxyClass is tracked in metrics. - pcr.mu.Lock() - pcr.managedProxyClasses.Add(pc.UID) - gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len())) - pcr.mu.Unlock() - - oldPCStatus := pc.Status.DeepCopy() - if errs := pcr.validate(pc); errs != nil { - msg := fmt.Sprintf(messageProxyClassInvalid, errs.ToAggregate().Error()) - pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonProxyClassInvalid, msg) - tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, pc.Generation, pcr.clock, logger) - } else { - tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionTrue, reasonProxyClassValid, reasonProxyClassValid, pc.Generation, pcr.clock, logger) - } - if !apiequality.Semantic.DeepEqual(oldPCStatus, pc.Status) { - if err := pcr.Client.Status().Update(ctx, pc); err != nil { - logger.Errorf("error updating ProxyClass status: %v", err) - return reconcile.Result{}, err - } - } - return reconcile.Result{}, nil -} - -func (pcr *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.ErrorList) { - if sts := pc.Spec.StatefulSet; sts != nil { - if len(sts.Labels) > 0 { - if errs := metavalidation.ValidateLabels(sts.Labels, field.NewPath(".spec.statefulSet.labels")); errs != nil { - violations = append(violations, errs...) - } - } - if len(sts.Annotations) > 0 { - if errs := apivalidation.ValidateAnnotations(sts.Annotations, field.NewPath(".spec.statefulSet.annotations")); errs != nil { - violations = append(violations, errs...) - } - } - if pod := sts.Pod; pod != nil { - if len(pod.Labels) > 0 { - if errs := metavalidation.ValidateLabels(pod.Labels, field.NewPath(".spec.statefulSet.pod.labels")); errs != nil { - violations = append(violations, errs...) - } - } - if len(pod.Annotations) > 0 { - if errs := apivalidation.ValidateAnnotations(pod.Annotations, field.NewPath(".spec.statefulSet.pod.annotations")); errs != nil { - violations = append(violations, errs...) - } - } - if tc := pod.TailscaleContainer; tc != nil { - for _, e := range tc.Env { - if strings.HasPrefix(string(e.Name), "TS_") { - pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale")) - } - if strings.EqualFold(string(e.Name), "EXPERIMENTAL_TS_CONFIGFILE_PATH") { - pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale")) - } - if strings.EqualFold(string(e.Name), "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS") { - pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale")) - } - } - if tc.Image != "" { - // Same validation as used by kubelet https://github.com/kubernetes/kubernetes/blob/release-1.30/pkg/kubelet/images/image_manager.go#L212 - if _, err := dockerref.ParseNormalizedNamed(tc.Image); err != nil { - violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleContainer", "image"), tc.Image, err.Error())) - } - } - } - if tc := pod.TailscaleInitContainer; tc != nil { - if tc.Image != "" { - // Same validation as used by kubelet https://github.com/kubernetes/kubernetes/blob/release-1.30/pkg/kubelet/images/image_manager.go#L212 - if _, err := dockerref.ParseNormalizedNamed(tc.Image); err != nil { - violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleInitContainer", "image"), tc.Image, err.Error())) - } - } - } - } - } - // We do not validate embedded fields (security context, resource - // requirements etc) as we inherit upstream validation for those fields. - // Invalid values would get rejected by upstream validations at apply - // time. - return violations -} - -// maybeCleanup removes tailscale.com finalizer and ensures that the ProxyClass -// is no longer counted towards k8s_proxyclass_resources. -func (pcr *ProxyClassReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, pc *tsapi.ProxyClass) error { - ix := slices.Index(pc.Finalizers, FinalizerName) - if ix < 0 { - logger.Debugf("no finalizer, nothing to do") - pcr.mu.Lock() - defer pcr.mu.Unlock() - pcr.managedProxyClasses.Remove(pc.UID) - gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len())) - return nil - } - pc.Finalizers = append(pc.Finalizers[:ix], pc.Finalizers[ix+1:]...) - if err := pcr.Update(ctx, pc); err != nil { - return fmt.Errorf("failed to remove finalizer: %w", err) - } - pcr.mu.Lock() - defer pcr.mu.Unlock() - pcr.managedProxyClasses.Remove(pc.UID) - gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len())) - logger.Infof("ProxyClass resources have been cleaned up") - return nil -} diff --git a/cmd/k8s-operator/proxyclass_test.go b/cmd/k8s-operator/proxyclass_test.go deleted file mode 100644 index eb68811fc6b94..0000000000000 --- a/cmd/k8s-operator/proxyclass_test.go +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// tailscale-operator provides a way to expose services running in a Kubernetes -// cluster to your Tailnet. -package main - -import ( - "testing" - "time" - - "go.uber.org/zap" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstest" -) - -func TestProxyClass(t *testing.T) { - pc := &tsapi.ProxyClass{ - TypeMeta: metav1.TypeMeta{Kind: "ProxyClass", APIVersion: "tailscale.com/v1alpha1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - // The apiserver is supposed to set the UID, but the fake client - // doesn't. So, set it explicitly because other code later depends - // on it being set. - UID: types.UID("1234-UID"), - Finalizers: []string{"tailscale.com/finalizer"}, - }, - Spec: tsapi.ProxyClassSpec{ - StatefulSet: &tsapi.StatefulSet{ - Labels: map[string]string{"foo": "bar", "xyz1234": "abc567"}, - Annotations: map[string]string{"foo.io/bar": "{'key': 'val1232'}"}, - Pod: &tsapi.Pod{ - Labels: map[string]string{"foo": "bar", "xyz1234": "abc567"}, - Annotations: map[string]string{"foo.io/bar": "{'key': 'val1232'}"}, - TailscaleContainer: &tsapi.Container{ - Env: []tsapi.Env{{Name: "FOO", Value: "BAR"}}, - ImagePullPolicy: "IfNotPresent", - Image: "ghcr.my-repo/tailscale:v0.01testsomething", - }, - }, - }, - }, - } - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(pc). - WithStatusSubresource(pc). - Build() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - fr := record.NewFakeRecorder(3) // bump this if you expect a test case to throw more events - cl := tstest.NewClock(tstest.ClockOpts{}) - pcr := &ProxyClassReconciler{ - Client: fc, - logger: zl.Sugar(), - clock: cl, - recorder: fr, - } - - // 1. A valid ProxyClass resource gets its status updated to Ready. - expectReconciled(t, pcr, "", "test") - pc.Status.Conditions = append(pc.Status.Conditions, metav1.Condition{ - Type: string(tsapi.ProxyClassReady), - Status: metav1.ConditionTrue, - Reason: reasonProxyClassValid, - Message: reasonProxyClassValid, - LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, - }) - - expectEqual(t, fc, pc, nil) - - // 2. A ProxyClass resource with invalid labels gets its status updated to Invalid with an error message. - pc.Spec.StatefulSet.Labels["foo"] = "?!someVal" - mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) { - proxyClass.Spec.StatefulSet.Labels = pc.Spec.StatefulSet.Labels - }) - expectReconciled(t, pcr, "", "test") - msg := `ProxyClass is not valid: .spec.statefulSet.labels: Invalid value: "?!someVal": a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')` - tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar()) - expectEqual(t, fc, pc, nil) - expectedEvent := "Warning ProxyClassInvalid ProxyClass is not valid: .spec.statefulSet.labels: Invalid value: \"?!someVal\": a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')" - expectEvents(t, fr, []string{expectedEvent}) - - // 3. A ProxyClass resource with invalid image reference gets it status updated to Invalid with an error message. - pc.Spec.StatefulSet.Labels = nil - pc.Spec.StatefulSet.Pod.TailscaleContainer.Image = "FOO bar" - mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) { - proxyClass.Spec.StatefulSet.Labels = nil - proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleContainer.Image - }) - expectReconciled(t, pcr, "", "test") - msg = `ProxyClass is not valid: spec.statefulSet.pod.tailscaleContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase` - tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar()) - expectEqual(t, fc, pc, nil) - expectedEvent = `Warning ProxyClassInvalid ProxyClass is not valid: spec.statefulSet.pod.tailscaleContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase` - expectEvents(t, fr, []string{expectedEvent}) - - // 4. A ProxyClass resource with invalid init container image reference gets it status updated to Invalid with an error message. - pc.Spec.StatefulSet.Labels = nil - pc.Spec.StatefulSet.Pod.TailscaleContainer.Image = "" - pc.Spec.StatefulSet.Pod.TailscaleInitContainer = &tsapi.Container{ - Image: "FOO bar", - } - mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) { - proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleContainer.Image - proxyClass.Spec.StatefulSet.Pod.TailscaleInitContainer = &tsapi.Container{ - Image: pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image, - } - }) - expectReconciled(t, pcr, "", "test") - msg = `ProxyClass is not valid: spec.statefulSet.pod.tailscaleInitContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase` - tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar()) - expectEqual(t, fc, pc, nil) - expectedEvent = `Warning ProxyClassInvalid ProxyClass is not valid: spec.statefulSet.pod.tailscaleInitContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase` - expectEvents(t, fr, []string{expectedEvent}) - - // 5. An valid ProxyClass but with a Tailscale env vars set results in warning events. - pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image = "" // unset previous test - mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) { - proxyClass.Spec.StatefulSet.Pod.TailscaleInitContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image - proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Env = []tsapi.Env{{Name: "TS_USERSPACE", Value: "true"}, {Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH"}, {Name: "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS"}} - }) - expectedEvents := []string{"Warning CustomTSEnvVar ProxyClass overrides the default value for TS_USERSPACE env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future.", - "Warning CustomTSEnvVar ProxyClass overrides the default value for EXPERIMENTAL_TS_CONFIGFILE_PATH env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future.", - "Warning CustomTSEnvVar ProxyClass overrides the default value for EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future."} - expectReconciled(t, pcr, "", "test") - expectEvents(t, fr, expectedEvents) -} diff --git a/cmd/k8s-operator/proxygroup.go b/cmd/k8s-operator/proxygroup.go deleted file mode 100644 index 6b76724662b6d..0000000000000 --- a/cmd/k8s-operator/proxygroup.go +++ /dev/null @@ -1,549 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "slices" - "sync" - - "github.com/pkg/errors" - "go.uber.org/zap" - xslices "golang.org/x/exp/slices" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/types/ptr" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/set" -) - -const ( - reasonProxyGroupCreationFailed = "ProxyGroupCreationFailed" - reasonProxyGroupReady = "ProxyGroupReady" - reasonProxyGroupCreating = "ProxyGroupCreating" - reasonProxyGroupInvalid = "ProxyGroupInvalid" -) - -var gaugeProxyGroupResources = clientmetric.NewGauge(kubetypes.MetricProxyGroupEgressCount) - -// ProxyGroupReconciler ensures cluster resources for a ProxyGroup definition. -type ProxyGroupReconciler struct { - client.Client - l *zap.SugaredLogger - recorder record.EventRecorder - clock tstime.Clock - tsClient tsClient - - // User-specified defaults from the helm installation. - tsNamespace string - proxyImage string - defaultTags []string - tsFirewallMode string - defaultProxyClass string - - mu sync.Mutex // protects following - proxyGroups set.Slice[types.UID] // for proxygroups gauge -} - -func (r *ProxyGroupReconciler) logger(name string) *zap.SugaredLogger { - return r.l.With("ProxyGroup", name) -} - -func (r *ProxyGroupReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { - logger := r.logger(req.Name) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - pg := new(tsapi.ProxyGroup) - err = r.Get(ctx, req.NamespacedName, pg) - if apierrors.IsNotFound(err) { - logger.Debugf("ProxyGroup not found, assuming it was deleted") - return reconcile.Result{}, nil - } else if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com ProxyGroup: %w", err) - } - if markedForDeletion(pg) { - logger.Debugf("ProxyGroup is being deleted, cleaning up resources") - ix := xslices.Index(pg.Finalizers, FinalizerName) - if ix < 0 { - logger.Debugf("no finalizer, nothing to do") - return reconcile.Result{}, nil - } - - if done, err := r.maybeCleanup(ctx, pg); err != nil { - return reconcile.Result{}, err - } else if !done { - logger.Debugf("ProxyGroup resource cleanup not yet finished, will retry...") - return reconcile.Result{RequeueAfter: shortRequeue}, nil - } - - pg.Finalizers = slices.Delete(pg.Finalizers, ix, ix+1) - if err := r.Update(ctx, pg); err != nil { - return reconcile.Result{}, err - } - return reconcile.Result{}, nil - } - - oldPGStatus := pg.Status.DeepCopy() - setStatusReady := func(pg *tsapi.ProxyGroup, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) { - tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, status, reason, message, pg.Generation, r.clock, logger) - if !apiequality.Semantic.DeepEqual(oldPGStatus, pg.Status) { - // An error encountered here should get returned by the Reconcile function. - if updateErr := r.Client.Status().Update(ctx, pg); updateErr != nil { - err = errors.Wrap(err, updateErr.Error()) - } - } - return reconcile.Result{}, err - } - - if !slices.Contains(pg.Finalizers, FinalizerName) { - // This log line is printed exactly once during initial provisioning, - // because once the finalizer is in place this block gets skipped. So, - // this is a nice place to log that the high level, multi-reconcile - // operation is underway. - logger.Infof("ensuring ProxyGroup is set up") - pg.Finalizers = append(pg.Finalizers, FinalizerName) - if err = r.Update(ctx, pg); err != nil { - err = fmt.Errorf("error adding finalizer: %w", err) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupCreationFailed, reasonProxyGroupCreationFailed) - } - } - - if err = r.validate(pg); err != nil { - message := fmt.Sprintf("ProxyGroup is invalid: %s", err) - r.recorder.Eventf(pg, corev1.EventTypeWarning, reasonProxyGroupInvalid, message) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupInvalid, message) - } - - proxyClassName := r.defaultProxyClass - if pg.Spec.ProxyClass != "" { - proxyClassName = pg.Spec.ProxyClass - } - - var proxyClass *tsapi.ProxyClass - if proxyClassName != "" { - proxyClass = new(tsapi.ProxyClass) - err := r.Get(ctx, types.NamespacedName{Name: proxyClassName}, proxyClass) - if apierrors.IsNotFound(err) { - err = nil - message := fmt.Sprintf("the ProxyGroup's ProxyClass %s does not (yet) exist", proxyClassName) - logger.Info(message) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupCreating, message) - } - if err != nil { - err = fmt.Errorf("error getting ProxyGroup's ProxyClass %s: %s", proxyClassName, err) - r.recorder.Eventf(pg, corev1.EventTypeWarning, reasonProxyGroupCreationFailed, err.Error()) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupCreationFailed, err.Error()) - } - if !tsoperator.ProxyClassIsReady(proxyClass) { - message := fmt.Sprintf("the ProxyGroup's ProxyClass %s is not yet in a ready state, waiting...", proxyClassName) - logger.Info(message) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupCreating, message) - } - } - - if err = r.maybeProvision(ctx, pg, proxyClass); err != nil { - err = fmt.Errorf("error provisioning ProxyGroup resources: %w", err) - r.recorder.Eventf(pg, corev1.EventTypeWarning, reasonProxyGroupCreationFailed, err.Error()) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupCreationFailed, err.Error()) - } - - desiredReplicas := int(pgReplicas(pg)) - if len(pg.Status.Devices) < desiredReplicas { - message := fmt.Sprintf("%d/%d ProxyGroup pods running", len(pg.Status.Devices), desiredReplicas) - logger.Debug(message) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupCreating, message) - } - - if len(pg.Status.Devices) > desiredReplicas { - message := fmt.Sprintf("waiting for %d ProxyGroup pods to shut down", len(pg.Status.Devices)-desiredReplicas) - logger.Debug(message) - return setStatusReady(pg, metav1.ConditionFalse, reasonProxyGroupCreating, message) - } - - logger.Info("ProxyGroup resources synced") - return setStatusReady(pg, metav1.ConditionTrue, reasonProxyGroupReady, reasonProxyGroupReady) -} - -func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, pg *tsapi.ProxyGroup, proxyClass *tsapi.ProxyClass) error { - logger := r.logger(pg.Name) - r.mu.Lock() - r.proxyGroups.Add(pg.UID) - gaugeProxyGroupResources.Set(int64(r.proxyGroups.Len())) - r.mu.Unlock() - - cfgHash, err := r.ensureConfigSecretsCreated(ctx, pg, proxyClass) - if err != nil { - return fmt.Errorf("error provisioning config Secrets: %w", err) - } - // State secrets are precreated so we can use the ProxyGroup CR as their owner ref. - stateSecrets := pgStateSecrets(pg, r.tsNamespace) - for _, sec := range stateSecrets { - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) { - s.ObjectMeta.Labels = sec.ObjectMeta.Labels - s.ObjectMeta.Annotations = sec.ObjectMeta.Annotations - s.ObjectMeta.OwnerReferences = sec.ObjectMeta.OwnerReferences - }); err != nil { - return fmt.Errorf("error provisioning state Secrets: %w", err) - } - } - sa := pgServiceAccount(pg, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sa, func(s *corev1.ServiceAccount) { - s.ObjectMeta.Labels = sa.ObjectMeta.Labels - s.ObjectMeta.Annotations = sa.ObjectMeta.Annotations - s.ObjectMeta.OwnerReferences = sa.ObjectMeta.OwnerReferences - }); err != nil { - return fmt.Errorf("error provisioning ServiceAccount: %w", err) - } - role := pgRole(pg, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, func(r *rbacv1.Role) { - r.ObjectMeta.Labels = role.ObjectMeta.Labels - r.ObjectMeta.Annotations = role.ObjectMeta.Annotations - r.ObjectMeta.OwnerReferences = role.ObjectMeta.OwnerReferences - r.Rules = role.Rules - }); err != nil { - return fmt.Errorf("error provisioning Role: %w", err) - } - roleBinding := pgRoleBinding(pg, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, roleBinding, func(r *rbacv1.RoleBinding) { - r.ObjectMeta.Labels = roleBinding.ObjectMeta.Labels - r.ObjectMeta.Annotations = roleBinding.ObjectMeta.Annotations - r.ObjectMeta.OwnerReferences = roleBinding.ObjectMeta.OwnerReferences - r.RoleRef = roleBinding.RoleRef - r.Subjects = roleBinding.Subjects - }); err != nil { - return fmt.Errorf("error provisioning RoleBinding: %w", err) - } - if pg.Spec.Type == tsapi.ProxyGroupTypeEgress { - cm := pgEgressCM(pg, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, cm, func(existing *corev1.ConfigMap) { - existing.ObjectMeta.Labels = cm.ObjectMeta.Labels - existing.ObjectMeta.OwnerReferences = cm.ObjectMeta.OwnerReferences - }); err != nil { - return fmt.Errorf("error provisioning ConfigMap: %w", err) - } - } - ss, err := pgStatefulSet(pg, r.tsNamespace, r.proxyImage, r.tsFirewallMode, cfgHash) - if err != nil { - return fmt.Errorf("error generating StatefulSet spec: %w", err) - } - ss = applyProxyClassToStatefulSet(proxyClass, ss, nil, logger) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, ss, func(s *appsv1.StatefulSet) { - s.ObjectMeta.Labels = ss.ObjectMeta.Labels - s.ObjectMeta.Annotations = ss.ObjectMeta.Annotations - s.ObjectMeta.OwnerReferences = ss.ObjectMeta.OwnerReferences - s.Spec = ss.Spec - }); err != nil { - return fmt.Errorf("error provisioning StatefulSet: %w", err) - } - - if err := r.cleanupDanglingResources(ctx, pg); err != nil { - return fmt.Errorf("error cleaning up dangling resources: %w", err) - } - - devices, err := r.getDeviceInfo(ctx, pg) - if err != nil { - return fmt.Errorf("failed to get device info: %w", err) - } - - pg.Status.Devices = devices - - return nil -} - -// cleanupDanglingResources ensures we don't leak config secrets, state secrets, and -// tailnet devices when the number of replicas specified is reduced. -func (r *ProxyGroupReconciler) cleanupDanglingResources(ctx context.Context, pg *tsapi.ProxyGroup) error { - logger := r.logger(pg.Name) - metadata, err := r.getNodeMetadata(ctx, pg) - if err != nil { - return err - } - - for _, m := range metadata { - if m.ordinal+1 <= int(pgReplicas(pg)) { - continue - } - - // Dangling resource, delete the config + state Secrets, as well as - // deleting the device from the tailnet. - if err := r.deleteTailnetDevice(ctx, m.tsID, logger); err != nil { - return err - } - if err := r.Delete(ctx, m.stateSecret); err != nil { - if !apierrors.IsNotFound(err) { - return fmt.Errorf("error deleting state Secret %s: %w", m.stateSecret.Name, err) - } - } - configSecret := m.stateSecret.DeepCopy() - configSecret.Name += "-config" - if err := r.Delete(ctx, configSecret); err != nil { - if !apierrors.IsNotFound(err) { - return fmt.Errorf("error deleting config Secret %s: %w", configSecret.Name, err) - } - } - } - - return nil -} - -// maybeCleanup just deletes the device from the tailnet. All the kubernetes -// resources linked to a ProxyGroup will get cleaned up via owner references -// (which we can use because they are all in the same namespace). -func (r *ProxyGroupReconciler) maybeCleanup(ctx context.Context, pg *tsapi.ProxyGroup) (bool, error) { - logger := r.logger(pg.Name) - - metadata, err := r.getNodeMetadata(ctx, pg) - if err != nil { - return false, err - } - - for _, m := range metadata { - if err := r.deleteTailnetDevice(ctx, m.tsID, logger); err != nil { - return false, err - } - } - - logger.Infof("cleaned up ProxyGroup resources") - r.mu.Lock() - r.proxyGroups.Remove(pg.UID) - gaugeProxyGroupResources.Set(int64(r.proxyGroups.Len())) - r.mu.Unlock() - return true, nil -} - -func (r *ProxyGroupReconciler) deleteTailnetDevice(ctx context.Context, id tailcfg.StableNodeID, logger *zap.SugaredLogger) error { - logger.Debugf("deleting device %s from control", string(id)) - if err := r.tsClient.DeleteDevice(ctx, string(id)); err != nil { - errResp := &tailscale.ErrResponse{} - if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound { - logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id)) - } else { - return fmt.Errorf("error deleting device: %w", err) - } - } else { - logger.Debugf("device %s deleted from control", string(id)) - } - - return nil -} - -func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(ctx context.Context, pg *tsapi.ProxyGroup, proxyClass *tsapi.ProxyClass) (hash string, err error) { - logger := r.logger(pg.Name) - var configSHA256Sum string - for i := range pgReplicas(pg) { - cfgSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%d-config", pg.Name, i), - Namespace: r.tsNamespace, - Labels: pgSecretLabels(pg.Name, "config"), - OwnerReferences: pgOwnerReference(pg), - }, - } - - var existingCfgSecret *corev1.Secret // unmodified copy of secret - if err := r.Get(ctx, client.ObjectKeyFromObject(cfgSecret), cfgSecret); err == nil { - logger.Debugf("secret %s/%s already exists", cfgSecret.GetNamespace(), cfgSecret.GetName()) - existingCfgSecret = cfgSecret.DeepCopy() - } else if !apierrors.IsNotFound(err) { - return "", err - } - - var authKey string - if existingCfgSecret == nil { - logger.Debugf("creating authkey for new ProxyGroup proxy") - tags := pg.Spec.Tags.Stringify() - if len(tags) == 0 { - tags = r.defaultTags - } - authKey, err = newAuthKey(ctx, r.tsClient, tags) - if err != nil { - return "", err - } - } - - configs, err := pgTailscaledConfig(pg, proxyClass, i, authKey, existingCfgSecret) - if err != nil { - return "", fmt.Errorf("error creating tailscaled config: %w", err) - } - - for cap, cfg := range configs { - cfgJSON, err := json.Marshal(cfg) - if err != nil { - return "", fmt.Errorf("error marshalling tailscaled config: %w", err) - } - mak.Set(&cfgSecret.StringData, tsoperator.TailscaledConfigFileName(cap), string(cfgJSON)) - } - - // The config sha256 sum is a value for a hash annotation used to trigger - // pod restarts when tailscaled config changes. Any config changes apply - // to all replicas, so it is sufficient to only hash the config for the - // first replica. - // - // In future, we're aiming to eliminate restarts altogether and have - // pods dynamically reload their config when it changes. - if i == 0 { - sum := sha256.New() - for _, cfg := range configs { - // Zero out the auth key so it doesn't affect the sha256 hash when we - // remove it from the config after the pods have all authed. Otherwise - // all the pods will need to restart immediately after authing. - cfg.AuthKey = nil - b, err := json.Marshal(cfg) - if err != nil { - return "", err - } - if _, err := sum.Write(b); err != nil { - return "", err - } - } - - configSHA256Sum = fmt.Sprintf("%x", sum.Sum(nil)) - } - - if existingCfgSecret != nil { - logger.Debugf("patching the existing ProxyGroup config Secret %s", cfgSecret.Name) - if err := r.Patch(ctx, cfgSecret, client.MergeFrom(existingCfgSecret)); err != nil { - return "", err - } - } else { - logger.Debugf("creating a new config Secret %s for the ProxyGroup", cfgSecret.Name) - if err := r.Create(ctx, cfgSecret); err != nil { - return "", err - } - } - } - - return configSHA256Sum, nil -} - -func pgTailscaledConfig(pg *tsapi.ProxyGroup, class *tsapi.ProxyClass, idx int32, authKey string, oldSecret *corev1.Secret) (tailscaledConfigs, error) { - conf := &ipn.ConfigVAlpha{ - Version: "alpha0", - AcceptDNS: "false", - AcceptRoutes: "false", // AcceptRoutes defaults to true - Locked: "false", - Hostname: ptr.To(fmt.Sprintf("%s-%d", pg.Name, idx)), - } - - if pg.Spec.HostnamePrefix != "" { - conf.Hostname = ptr.To(fmt.Sprintf("%s%d", pg.Spec.HostnamePrefix, idx)) - } - - if shouldAcceptRoutes(class) { - conf.AcceptRoutes = "true" - } - - deviceAuthed := false - for _, d := range pg.Status.Devices { - if d.Hostname == *conf.Hostname { - deviceAuthed = true - break - } - } - - if authKey != "" { - conf.AuthKey = &authKey - } else if !deviceAuthed { - key, err := authKeyFromSecret(oldSecret) - if err != nil { - return nil, fmt.Errorf("error retrieving auth key from Secret: %w", err) - } - conf.AuthKey = key - } - capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) - capVerConfigs[106] = *conf - return capVerConfigs, nil -} - -func (r *ProxyGroupReconciler) validate(_ *tsapi.ProxyGroup) error { - return nil -} - -// getNodeMetadata gets metadata for all the pods owned by this ProxyGroup by -// querying their state Secrets. It may not return the same number of items as -// specified in the ProxyGroup spec if e.g. it is getting scaled up or down, or -// some pods have failed to write state. -func (r *ProxyGroupReconciler) getNodeMetadata(ctx context.Context, pg *tsapi.ProxyGroup) (metadata []nodeMetadata, _ error) { - // List all state secrets owned by this ProxyGroup. - secrets := &corev1.SecretList{} - if err := r.List(ctx, secrets, client.InNamespace(r.tsNamespace), client.MatchingLabels(pgSecretLabels(pg.Name, "state"))); err != nil { - return nil, fmt.Errorf("failed to list state Secrets: %w", err) - } - for _, secret := range secrets.Items { - var ordinal int - if _, err := fmt.Sscanf(secret.Name, pg.Name+"-%d", &ordinal); err != nil { - return nil, fmt.Errorf("unexpected secret %s was labelled as owned by the ProxyGroup %s: %w", secret.Name, pg.Name, err) - } - - id, dnsName, ok, err := getNodeMetadata(ctx, &secret) - if err != nil { - return nil, err - } - if !ok { - continue - } - - metadata = append(metadata, nodeMetadata{ - ordinal: ordinal, - stateSecret: &secret, - tsID: id, - dnsName: dnsName, - }) - } - - return metadata, nil -} - -func (r *ProxyGroupReconciler) getDeviceInfo(ctx context.Context, pg *tsapi.ProxyGroup) (devices []tsapi.TailnetDevice, _ error) { - metadata, err := r.getNodeMetadata(ctx, pg) - if err != nil { - return nil, err - } - - for _, m := range metadata { - device, ok, err := getDeviceInfo(ctx, r.tsClient, m.stateSecret) - if err != nil { - return nil, err - } - if !ok { - continue - } - devices = append(devices, tsapi.TailnetDevice{ - Hostname: device.Hostname, - TailnetIPs: device.TailnetIPs, - }) - } - - return devices, nil -} - -type nodeMetadata struct { - ordinal int - stateSecret *corev1.Secret - tsID tailcfg.StableNodeID - dnsName string -} diff --git a/cmd/k8s-operator/proxygroup_specs.go b/cmd/k8s-operator/proxygroup_specs.go deleted file mode 100644 index b47cb39b1e9c6..0000000000000 --- a/cmd/k8s-operator/proxygroup_specs.go +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "fmt" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/egressservices" - "tailscale.com/kube/kubetypes" - "tailscale.com/types/ptr" -) - -// Returns the base StatefulSet definition for a ProxyGroup. A ProxyClass may be -// applied over the top after. -func pgStatefulSet(pg *tsapi.ProxyGroup, namespace, image, tsFirewallMode, cfgHash string) (*appsv1.StatefulSet, error) { - ss := new(appsv1.StatefulSet) - if err := yaml.Unmarshal(proxyYaml, &ss); err != nil { - return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err) - } - // Validate some base assumptions. - if len(ss.Spec.Template.Spec.InitContainers) != 1 { - return nil, fmt.Errorf("[unexpected] base proxy config had %d init containers instead of 1", len(ss.Spec.Template.Spec.InitContainers)) - } - if len(ss.Spec.Template.Spec.Containers) != 1 { - return nil, fmt.Errorf("[unexpected] base proxy config had %d containers instead of 1", len(ss.Spec.Template.Spec.Containers)) - } - - // StatefulSet config. - ss.ObjectMeta = metav1.ObjectMeta{ - Name: pg.Name, - Namespace: namespace, - Labels: pgLabels(pg.Name, nil), - OwnerReferences: pgOwnerReference(pg), - } - ss.Spec.Replicas = ptr.To(pgReplicas(pg)) - ss.Spec.Selector = &metav1.LabelSelector{ - MatchLabels: pgLabels(pg.Name, nil), - } - - // Template config. - tmpl := &ss.Spec.Template - tmpl.ObjectMeta = metav1.ObjectMeta{ - Name: pg.Name, - Namespace: namespace, - Labels: pgLabels(pg.Name, nil), - DeletionGracePeriodSeconds: ptr.To[int64](10), - Annotations: map[string]string{ - podAnnotationLastSetConfigFileHash: cfgHash, - }, - } - tmpl.Spec.ServiceAccountName = pg.Name - tmpl.Spec.InitContainers[0].Image = image - tmpl.Spec.Volumes = func() []corev1.Volume { - var volumes []corev1.Volume - for i := range pgReplicas(pg) { - volumes = append(volumes, corev1.Volume{ - Name: fmt.Sprintf("tailscaledconfig-%d", i), - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: fmt.Sprintf("%s-%d-config", pg.Name, i), - }, - }, - }) - } - - if pg.Spec.Type == tsapi.ProxyGroupTypeEgress { - volumes = append(volumes, corev1.Volume{ - Name: pgEgressCMName(pg.Name), - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: pgEgressCMName(pg.Name), - }, - }, - }, - }) - } - - return volumes - }() - - // Main container config. - c := &ss.Spec.Template.Spec.Containers[0] - c.Image = image - c.VolumeMounts = func() []corev1.VolumeMount { - var mounts []corev1.VolumeMount - - // TODO(tomhjp): Read config directly from the secret instead. The - // mounts change on scaling up/down which causes unnecessary restarts - // for pods that haven't meaningfully changed. - for i := range pgReplicas(pg) { - mounts = append(mounts, corev1.VolumeMount{ - Name: fmt.Sprintf("tailscaledconfig-%d", i), - ReadOnly: true, - MountPath: fmt.Sprintf("/etc/tsconfig/%s-%d", pg.Name, i), - }) - } - - if pg.Spec.Type == tsapi.ProxyGroupTypeEgress { - mounts = append(mounts, corev1.VolumeMount{ - Name: pgEgressCMName(pg.Name), - MountPath: "/etc/proxies", - ReadOnly: true, - }) - } - - return mounts - }() - c.Env = func() []corev1.EnvVar { - envs := []corev1.EnvVar{ - { - // TODO(irbekrm): verify that .status.podIPs are always set, else read in .status.podIP as well. - Name: "POD_IPS", // this will be a comma separate list i.e 10.136.0.6,2600:1900:4011:161:0:e:0:6 - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - FieldPath: "status.podIPs", - }, - }, - }, - { - Name: "TS_KUBE_SECRET", - Value: "$(POD_NAME)", - }, - { - Name: "TS_STATE", - Value: "kube:$(POD_NAME)", - }, - { - Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", - Value: "/etc/tsconfig/$(POD_NAME)", - }, - { - Name: "TS_INTERNAL_APP", - Value: kubetypes.AppProxyGroupEgress, - }, - } - - if tsFirewallMode != "" { - envs = append(envs, corev1.EnvVar{ - Name: "TS_DEBUG_FIREWALL_MODE", - Value: tsFirewallMode, - }) - } - - if pg.Spec.Type == tsapi.ProxyGroupTypeEgress { - envs = append(envs, corev1.EnvVar{ - Name: "TS_EGRESS_SERVICES_CONFIG_PATH", - Value: fmt.Sprintf("/etc/proxies/%s", egressservices.KeyEgressServices), - }) - } - - return append(c.Env, envs...) - }() - - return ss, nil -} - -func pgServiceAccount(pg *tsapi.ProxyGroup, namespace string) *corev1.ServiceAccount { - return &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: pg.Name, - Namespace: namespace, - Labels: pgLabels(pg.Name, nil), - OwnerReferences: pgOwnerReference(pg), - }, - } -} - -func pgRole(pg *tsapi.ProxyGroup, namespace string) *rbacv1.Role { - return &rbacv1.Role{ - ObjectMeta: metav1.ObjectMeta{ - Name: pg.Name, - Namespace: namespace, - Labels: pgLabels(pg.Name, nil), - OwnerReferences: pgOwnerReference(pg), - }, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{ - "get", - "patch", - "update", - }, - ResourceNames: func() (secrets []string) { - for i := range pgReplicas(pg) { - secrets = append(secrets, - fmt.Sprintf("%s-%d-config", pg.Name, i), // Config with auth key. - fmt.Sprintf("%s-%d", pg.Name, i), // State. - ) - } - return secrets - }(), - }, - { - APIGroups: []string{""}, - Resources: []string{"events"}, - Verbs: []string{ - "create", - "patch", - "get", - }, - }, - }, - } -} - -func pgRoleBinding(pg *tsapi.ProxyGroup, namespace string) *rbacv1.RoleBinding { - return &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: pg.Name, - Namespace: namespace, - Labels: pgLabels(pg.Name, nil), - OwnerReferences: pgOwnerReference(pg), - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: pg.Name, - Namespace: namespace, - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "Role", - Name: pg.Name, - }, - } -} - -func pgStateSecrets(pg *tsapi.ProxyGroup, namespace string) (secrets []*corev1.Secret) { - for i := range pgReplicas(pg) { - secrets = append(secrets, &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%d", pg.Name, i), - Namespace: namespace, - Labels: pgSecretLabels(pg.Name, "state"), - OwnerReferences: pgOwnerReference(pg), - }, - }) - } - - return secrets -} - -func pgEgressCM(pg *tsapi.ProxyGroup, namespace string) *corev1.ConfigMap { - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: pgEgressCMName(pg.Name), - Namespace: namespace, - Labels: pgLabels(pg.Name, nil), - OwnerReferences: pgOwnerReference(pg), - }, - } -} - -func pgSecretLabels(pgName, typ string) map[string]string { - return pgLabels(pgName, map[string]string{ - labelSecretType: typ, // "config" or "state". - }) -} - -func pgLabels(pgName string, customLabels map[string]string) map[string]string { - l := make(map[string]string, len(customLabels)+3) - for k, v := range customLabels { - l[k] = v - } - - l[LabelManaged] = "true" - l[LabelParentType] = "proxygroup" - l[LabelParentName] = pgName - - return l -} - -func pgOwnerReference(owner *tsapi.ProxyGroup) []metav1.OwnerReference { - return []metav1.OwnerReference{*metav1.NewControllerRef(owner, tsapi.SchemeGroupVersion.WithKind("ProxyGroup"))} -} - -func pgReplicas(pg *tsapi.ProxyGroup) int32 { - if pg.Spec.Replicas != nil { - return *pg.Spec.Replicas - } - - return 2 -} - -func pgEgressCMName(pg string) string { - return fmt.Sprintf("%s-egress-config", pg) -} diff --git a/cmd/k8s-operator/proxygroup_test.go b/cmd/k8s-operator/proxygroup_test.go deleted file mode 100644 index 23f50cc7a576d..0000000000000 --- a/cmd/k8s-operator/proxygroup_test.go +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "tailscale.com/client/tailscale" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstest" - "tailscale.com/types/ptr" -) - -const testProxyImage = "tailscale/tailscale:test" - -var defaultProxyClassAnnotations = map[string]string{ - "some-annotation": "from-the-proxy-class", -} - -func TestProxyGroup(t *testing.T) { - const initialCfgHash = "6632726be70cf224049580deb4d317bba065915b5fd415461d60ed621c91b196" - - pc := &tsapi.ProxyClass{ - ObjectMeta: metav1.ObjectMeta{ - Name: "default-pc", - }, - Spec: tsapi.ProxyClassSpec{ - StatefulSet: &tsapi.StatefulSet{ - Annotations: defaultProxyClassAnnotations, - }, - }, - } - pg := &tsapi.ProxyGroup{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Finalizers: []string{"tailscale.com/finalizer"}, - }, - } - - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(pg, pc). - WithStatusSubresource(pg, pc). - Build() - tsClient := &fakeTSClient{} - zl, _ := zap.NewDevelopment() - fr := record.NewFakeRecorder(1) - cl := tstest.NewClock(tstest.ClockOpts{}) - reconciler := &ProxyGroupReconciler{ - tsNamespace: tsNamespace, - proxyImage: testProxyImage, - defaultTags: []string{"tag:test-tag"}, - tsFirewallMode: "auto", - defaultProxyClass: "default-pc", - - Client: fc, - tsClient: tsClient, - recorder: fr, - l: zl.Sugar(), - clock: cl, - } - - t.Run("proxyclass_not_ready", func(t *testing.T) { - expectReconciled(t, reconciler, "", pg.Name) - - tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, metav1.ConditionFalse, reasonProxyGroupCreating, "the ProxyGroup's ProxyClass default-pc is not yet in a ready state, waiting...", 0, cl, zl.Sugar()) - expectEqual(t, fc, pg, nil) - expectProxyGroupResources(t, fc, pg, false, "") - }) - - t.Run("observe_ProxyGroupCreating_status_reason", func(t *testing.T) { - pc.Status = tsapi.ProxyClassStatus{ - Conditions: []metav1.Condition{{ - Type: string(tsapi.ProxyClassReady), - Status: metav1.ConditionTrue, - Reason: reasonProxyClassValid, - Message: reasonProxyClassValid, - LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)}, - }}, - } - if err := fc.Status().Update(context.Background(), pc); err != nil { - t.Fatal(err) - } - - expectReconciled(t, reconciler, "", pg.Name) - - tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, metav1.ConditionFalse, reasonProxyGroupCreating, "0/2 ProxyGroup pods running", 0, cl, zl.Sugar()) - expectEqual(t, fc, pg, nil) - expectProxyGroupResources(t, fc, pg, true, initialCfgHash) - if expected := 1; reconciler.proxyGroups.Len() != expected { - t.Fatalf("expected %d recorders, got %d", expected, reconciler.proxyGroups.Len()) - } - expectProxyGroupResources(t, fc, pg, true, initialCfgHash) - keyReq := tailscale.KeyCapabilities{ - Devices: tailscale.KeyDeviceCapabilities{ - Create: tailscale.KeyDeviceCreateCapabilities{ - Reusable: false, - Ephemeral: false, - Preauthorized: true, - Tags: []string{"tag:test-tag"}, - }, - }, - } - if diff := cmp.Diff(tsClient.KeyRequests(), []tailscale.KeyCapabilities{keyReq, keyReq}); diff != "" { - t.Fatalf("unexpected secrets (-got +want):\n%s", diff) - } - }) - - t.Run("simulate_successful_device_auth", func(t *testing.T) { - addNodeIDToStateSecrets(t, fc, pg) - expectReconciled(t, reconciler, "", pg.Name) - - pg.Status.Devices = []tsapi.TailnetDevice{ - { - Hostname: "hostname-nodeid-0", - TailnetIPs: []string{"1.2.3.4", "::1"}, - }, - { - Hostname: "hostname-nodeid-1", - TailnetIPs: []string{"1.2.3.4", "::1"}, - }, - } - tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, metav1.ConditionTrue, reasonProxyGroupReady, reasonProxyGroupReady, 0, cl, zl.Sugar()) - expectEqual(t, fc, pg, nil) - expectProxyGroupResources(t, fc, pg, true, initialCfgHash) - }) - - t.Run("scale_up_to_3", func(t *testing.T) { - pg.Spec.Replicas = ptr.To[int32](3) - mustUpdate(t, fc, "", pg.Name, func(p *tsapi.ProxyGroup) { - p.Spec = pg.Spec - }) - expectReconciled(t, reconciler, "", pg.Name) - tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, metav1.ConditionFalse, reasonProxyGroupCreating, "2/3 ProxyGroup pods running", 0, cl, zl.Sugar()) - expectEqual(t, fc, pg, nil) - expectProxyGroupResources(t, fc, pg, true, initialCfgHash) - - addNodeIDToStateSecrets(t, fc, pg) - expectReconciled(t, reconciler, "", pg.Name) - tsoperator.SetProxyGroupCondition(pg, tsapi.ProxyGroupReady, metav1.ConditionTrue, reasonProxyGroupReady, reasonProxyGroupReady, 0, cl, zl.Sugar()) - pg.Status.Devices = append(pg.Status.Devices, tsapi.TailnetDevice{ - Hostname: "hostname-nodeid-2", - TailnetIPs: []string{"1.2.3.4", "::1"}, - }) - expectEqual(t, fc, pg, nil) - expectProxyGroupResources(t, fc, pg, true, initialCfgHash) - }) - - t.Run("scale_down_to_1", func(t *testing.T) { - pg.Spec.Replicas = ptr.To[int32](1) - mustUpdate(t, fc, "", pg.Name, func(p *tsapi.ProxyGroup) { - p.Spec = pg.Spec - }) - - expectReconciled(t, reconciler, "", pg.Name) - - pg.Status.Devices = pg.Status.Devices[:1] // truncate to only the first device. - expectEqual(t, fc, pg, nil) - expectProxyGroupResources(t, fc, pg, true, initialCfgHash) - }) - - t.Run("trigger_config_change_and_observe_new_config_hash", func(t *testing.T) { - pc.Spec.TailscaleConfig = &tsapi.TailscaleConfig{ - AcceptRoutes: true, - } - mustUpdate(t, fc, "", pc.Name, func(p *tsapi.ProxyClass) { - p.Spec = pc.Spec - }) - - expectReconciled(t, reconciler, "", pg.Name) - - expectEqual(t, fc, pg, nil) - expectProxyGroupResources(t, fc, pg, true, "518a86e9fae64f270f8e0ec2a2ea6ca06c10f725035d3d6caca132cd61e42a74") - }) - - t.Run("delete_and_cleanup", func(t *testing.T) { - if err := fc.Delete(context.Background(), pg); err != nil { - t.Fatal(err) - } - - expectReconciled(t, reconciler, "", pg.Name) - - expectMissing[tsapi.Recorder](t, fc, "", pg.Name) - if expected := 0; reconciler.proxyGroups.Len() != expected { - t.Fatalf("expected %d ProxyGroups, got %d", expected, reconciler.proxyGroups.Len()) - } - // 2 nodes should get deleted as part of the scale down, and then finally - // the first node gets deleted with the ProxyGroup cleanup. - if diff := cmp.Diff(tsClient.deleted, []string{"nodeid-1", "nodeid-2", "nodeid-0"}); diff != "" { - t.Fatalf("unexpected deleted devices (-got +want):\n%s", diff) - } - // The fake client does not clean up objects whose owner has been - // deleted, so we can't test for the owned resources getting deleted. - }) -} - -func expectProxyGroupResources(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyGroup, shouldExist bool, cfgHash string) { - t.Helper() - - role := pgRole(pg, tsNamespace) - roleBinding := pgRoleBinding(pg, tsNamespace) - serviceAccount := pgServiceAccount(pg, tsNamespace) - statefulSet, err := pgStatefulSet(pg, tsNamespace, testProxyImage, "auto", cfgHash) - if err != nil { - t.Fatal(err) - } - statefulSet.Annotations = defaultProxyClassAnnotations - - if shouldExist { - expectEqual(t, fc, role, nil) - expectEqual(t, fc, roleBinding, nil) - expectEqual(t, fc, serviceAccount, nil) - expectEqual(t, fc, statefulSet, nil) - } else { - expectMissing[rbacv1.Role](t, fc, role.Namespace, role.Name) - expectMissing[rbacv1.RoleBinding](t, fc, roleBinding.Namespace, roleBinding.Name) - expectMissing[corev1.ServiceAccount](t, fc, serviceAccount.Namespace, serviceAccount.Name) - expectMissing[appsv1.StatefulSet](t, fc, statefulSet.Namespace, statefulSet.Name) - } - - var expectedSecrets []string - if shouldExist { - for i := range pgReplicas(pg) { - expectedSecrets = append(expectedSecrets, - fmt.Sprintf("%s-%d", pg.Name, i), - fmt.Sprintf("%s-%d-config", pg.Name, i), - ) - } - } - expectSecrets(t, fc, expectedSecrets) -} - -func expectSecrets(t *testing.T, fc client.WithWatch, expected []string) { - t.Helper() - - secrets := &corev1.SecretList{} - if err := fc.List(context.Background(), secrets); err != nil { - t.Fatal(err) - } - - var actual []string - for _, secret := range secrets.Items { - actual = append(actual, secret.Name) - } - - if diff := cmp.Diff(actual, expected); diff != "" { - t.Fatalf("unexpected secrets (-got +want):\n%s", diff) - } -} - -func addNodeIDToStateSecrets(t *testing.T, fc client.WithWatch, pg *tsapi.ProxyGroup) { - const key = "profile-abc" - for i := range pgReplicas(pg) { - bytes, err := json.Marshal(map[string]any{ - "Config": map[string]any{ - "NodeID": fmt.Sprintf("nodeid-%d", i), - }, - }) - if err != nil { - t.Fatal(err) - } - - mustUpdate(t, fc, tsNamespace, fmt.Sprintf("test-%d", i), func(s *corev1.Secret) { - s.Data = map[string][]byte{ - currentProfileKey: []byte(key), - key: bytes, - } - }) - } -} diff --git a/cmd/k8s-operator/sts.go b/cmd/k8s-operator/sts.go deleted file mode 100644 index bdacec39b0e98..0000000000000 --- a/cmd/k8s-operator/sts.go +++ /dev/null @@ -1,1009 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "crypto/sha256" - _ "embed" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "slices" - "strings" - - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apiserver/pkg/storage/names" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/net/netutil" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/util/mak" -) - -const ( - // Labels that the operator sets on StatefulSets and Pods. If you add a - // new label here, do also add it to tailscaleManagedLabels var to - // ensure that it does not get overwritten by ProxyClass configuration. - LabelManaged = "tailscale.com/managed" - LabelParentType = "tailscale.com/parent-resource-type" - LabelParentName = "tailscale.com/parent-resource" - LabelParentNamespace = "tailscale.com/parent-resource-ns" - labelSecretType = "tailscale.com/secret-type" // "config" or "state". - - // LabelProxyClass can be set by users on tailscale Ingresses and Services that define cluster ingress or - // cluster egress, to specify that configuration in this ProxyClass should be applied to resources created for - // the Ingress or Service. - LabelProxyClass = "tailscale.com/proxy-class" - - FinalizerName = "tailscale.com/finalizer" - - // Annotations settable by users on services. - AnnotationExpose = "tailscale.com/expose" - AnnotationTags = "tailscale.com/tags" - AnnotationHostname = "tailscale.com/hostname" - annotationTailnetTargetIPOld = "tailscale.com/ts-tailnet-target-ip" - AnnotationTailnetTargetIP = "tailscale.com/tailnet-ip" - //MagicDNS name of tailnet node. - AnnotationTailnetTargetFQDN = "tailscale.com/tailnet-fqdn" - - AnnotationProxyGroup = "tailscale.com/proxy-group" - - // Annotations settable by users on ingresses. - AnnotationFunnel = "tailscale.com/funnel" - - // If set to true, set up iptables/nftables rules in the proxy forward - // cluster traffic to the tailnet IP of that proxy. This can only be set - // on an Ingress. This is useful in cases where a cluster target needs - // to be able to reach a cluster workload exposed to tailnet via Ingress - // using the same hostname as a tailnet workload (in this case, the - // MagicDNS name of the ingress proxy). This annotation is experimental. - // If it is set to true, the proxy set up for Ingress, will run - // tailscale in non-userspace, with NET_ADMIN cap for tailscale - // container and will also run a privileged init container that enables - // forwarding. - // Eventually this behaviour might become the default. - AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy = "tailscale.com/experimental-forward-cluster-traffic-via-ingress" - - // Annotations set by the operator on pods to trigger restarts when the - // hostname, IP, FQDN or tailscaled config changes. If you add a new - // annotation here, also add it to tailscaleManagedAnnotations var to - // ensure that it does not get removed when a ProxyClass configuration - // is applied. - podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip" - podAnnotationLastSetClusterDNSName = "tailscale.com/operator-last-set-cluster-dns-name" - podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip" - podAnnotationLastSetTailnetTargetFQDN = "tailscale.com/operator-last-set-ts-tailnet-target-fqdn" - // podAnnotationLastSetConfigFileHash is sha256 hash of the current tailscaled configuration contents. - podAnnotationLastSetConfigFileHash = "tailscale.com/operator-last-set-config-file-hash" -) - -var ( - // tailscaleManagedLabels are label keys that tailscale operator sets on StatefulSets and Pods. - tailscaleManagedLabels = []string{LabelManaged, LabelParentType, LabelParentName, LabelParentNamespace, "app"} - // tailscaleManagedAnnotations are annotation keys that tailscale operator sets on StatefulSets and Pods. - tailscaleManagedAnnotations = []string{podAnnotationLastSetClusterIP, podAnnotationLastSetTailnetTargetIP, podAnnotationLastSetTailnetTargetFQDN, podAnnotationLastSetConfigFileHash} -) - -type tailscaleSTSConfig struct { - ParentResourceName string - ParentResourceUID string - ChildResourceLabels map[string]string - - ServeConfig *ipn.ServeConfig // if serve config is set, this is a proxy for Ingress - ClusterTargetIP string // ingress target IP - ClusterTargetDNSName string // ingress target DNS name - // If set to true, operator should configure containerboot to forward - // cluster traffic via the proxy set up for Kubernetes Ingress. - ForwardClusterTrafficViaL7IngressProxy bool - - TailnetTargetIP string // egress target IP - - TailnetTargetFQDN string // egress target FQDN - - Hostname string - Tags []string // if empty, use defaultTags - - // Connector specifies a configuration of a Connector instance if that's - // what this StatefulSet should be created for. - Connector *connector - - ProxyClassName string // name of ProxyClass if one needs to be applied to the proxy - - ProxyClass *tsapi.ProxyClass // ProxyClass that needs to be applied to the proxy (if there is one) -} - -type connector struct { - // routes is a list of routes that this Connector should advertise either as a subnet router or as an app - // connector. - routes string - // isExitNode defines whether this Connector should act as an exit node. - isExitNode bool - // isAppConnector defines whether this Connector should act as an app connector. - isAppConnector bool -} -type tsnetServer interface { - CertDomains() []string -} - -type tailscaleSTSReconciler struct { - client.Client - tsnetServer tsnetServer - tsClient tsClient - defaultTags []string - operatorNamespace string - proxyImage string - proxyPriorityClassName string - tsFirewallMode string -} - -func (sts tailscaleSTSReconciler) validate() error { - if sts.tsFirewallMode != "" && !isValidFirewallMode(sts.tsFirewallMode) { - return fmt.Errorf("invalid proxy firewall mode %s, valid modes are iptables, nftables or unset", sts.tsFirewallMode) - } - return nil -} - -// IsHTTPSEnabledOnTailnet reports whether HTTPS is enabled on the tailnet. -func (a *tailscaleSTSReconciler) IsHTTPSEnabledOnTailnet() bool { - return len(a.tsnetServer.CertDomains()) > 0 -} - -// Provision ensures that the StatefulSet for the given service is running and -// up to date. -func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) { - // Do full reconcile. - // TODO (don't create Service for the Connector) - hsvc, err := a.reconcileHeadlessService(ctx, logger, sts) - if err != nil { - return nil, fmt.Errorf("failed to reconcile headless service: %w", err) - } - - proxyClass := new(tsapi.ProxyClass) - if sts.ProxyClassName != "" { - if err := a.Get(ctx, types.NamespacedName{Name: sts.ProxyClassName}, proxyClass); err != nil { - return nil, fmt.Errorf("failed to get ProxyClass: %w", err) - } - if !tsoperator.ProxyClassIsReady(proxyClass) { - logger.Infof("ProxyClass %s specified for the proxy, but it is not (yet) in a ready state, waiting..") - return nil, nil - } - } - sts.ProxyClass = proxyClass - - secretName, tsConfigHash, configs, err := a.createOrGetSecret(ctx, logger, sts, hsvc) - if err != nil { - return nil, fmt.Errorf("failed to create or get API key secret: %w", err) - } - _, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName, tsConfigHash, configs) - if err != nil { - return nil, fmt.Errorf("failed to reconcile statefulset: %w", err) - } - - return hsvc, nil -} - -// Cleanup removes all resources associated that were created by Provision with -// the given labels. It returns true when all resources have been removed, -// otherwise it returns false and the caller should retry later. -func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.SugaredLogger, labels map[string]string) (done bool, _ error) { - // Need to delete the StatefulSet first, and delete it with foreground - // cascading deletion. That way, the pod that's writing to the Secret will - // stop running before we start looking at the Secret's contents, and - // assuming k8s ordering semantics don't mess with us, that should avoid - // tailscale device deletion races where we fail to notice a device that - // should be removed. - sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, labels) - if err != nil { - return false, fmt.Errorf("getting statefulset: %w", err) - } - if sts != nil { - if !sts.GetDeletionTimestamp().IsZero() { - // Deletion in progress, check again later. We'll get another - // notification when the deletion is complete. - logger.Debugf("waiting for statefulset %s/%s deletion", sts.GetNamespace(), sts.GetName()) - return false, nil - } - err := a.DeleteAllOf(ctx, &appsv1.StatefulSet{}, client.InNamespace(a.operatorNamespace), client.MatchingLabels(labels), client.PropagationPolicy(metav1.DeletePropagationForeground)) - if err != nil { - return false, fmt.Errorf("deleting statefulset: %w", err) - } - logger.Debugf("started deletion of statefulset %s/%s", sts.GetNamespace(), sts.GetName()) - return false, nil - } - - id, _, _, err := a.DeviceInfo(ctx, labels) - if err != nil { - return false, fmt.Errorf("getting device info: %w", err) - } - if id != "" { - logger.Debugf("deleting device %s from control", string(id)) - if err := a.tsClient.DeleteDevice(ctx, string(id)); err != nil { - errResp := &tailscale.ErrResponse{} - if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound { - logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id)) - } else { - return false, fmt.Errorf("deleting device: %w", err) - } - } else { - logger.Debugf("device %s deleted from control", string(id)) - } - } - - types := []client.Object{ - &corev1.Service{}, - &corev1.Secret{}, - } - for _, typ := range types { - if err := a.DeleteAllOf(ctx, typ, client.InNamespace(a.operatorNamespace), client.MatchingLabels(labels)); err != nil { - return false, err - } - } - return true, nil -} - -// maxStatefulSetNameLength is maximum length the StatefulSet name can -// have to NOT result in a too long value for controller-revision-hash -// label value (see https://github.com/kubernetes/kubernetes/issues/64023). -// controller-revision-hash label value consists of StatefulSet's name + hyphen + revision hash. -// Maximum label value length is 63 chars. Length of revision hash is 10 chars. -// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set -// https://github.com/kubernetes/kubernetes/blob/v1.28.4/pkg/controller/history/controller_history.go#L90-L104 -const maxStatefulSetNameLength = 63 - 10 - 1 - -// statefulSetNameBase accepts name of parent resource and returns a string in -// form ts-- that, when passed to Kubernetes name -// generation will NOT result in a StatefulSet name longer than 52 chars. -// This is done because of https://github.com/kubernetes/kubernetes/issues/64023. -func statefulSetNameBase(parent string) string { - base := fmt.Sprintf("ts-%s-", parent) - generator := names.SimpleNameGenerator - for { - generatedName := generator.GenerateName(base) - excess := len(generatedName) - maxStatefulSetNameLength - if excess <= 0 { - return base - } - base = base[:len(base)-1-excess] // cut off the excess chars - base = base + "-" // re-instate the dash - } -} - -func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) { - nameBase := statefulSetNameBase(sts.ParentResourceName) - hsvc := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: nameBase, - Namespace: a.operatorNamespace, - Labels: sts.ChildResourceLabels, - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "None", - Selector: map[string]string{ - "app": sts.ParentResourceUID, - }, - IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack), - }, - } - logger.Debugf("reconciling headless service for StatefulSet") - return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec }) -} - -func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service) (secretName, hash string, configs tailscaledConfigs, _ error) { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - // Hardcode a -0 suffix so that in future, if we support - // multiple StatefulSet replicas, we can provision -N for - // those. - Name: hsvc.Name + "-0", - Namespace: a.operatorNamespace, - Labels: stsC.ChildResourceLabels, - }, - } - var orig *corev1.Secret // unmodified copy of secret - if err := a.Get(ctx, client.ObjectKeyFromObject(secret), secret); err == nil { - logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName()) - orig = secret.DeepCopy() - } else if !apierrors.IsNotFound(err) { - return "", "", nil, err - } - - var authKey string - if orig == nil { - // Initially it contains only tailscaled config, but when the - // proxy starts, it will also store there the state, certs and - // ACME account key. - sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, stsC.ChildResourceLabels) - if err != nil { - return "", "", nil, err - } - if sts != nil { - // StatefulSet exists, so we have already created the secret. - // If the secret is missing, they should delete the StatefulSet. - logger.Errorf("Tailscale proxy secret doesn't exist, but the corresponding StatefulSet %s/%s already does. Something is wrong, please delete the StatefulSet.", sts.GetNamespace(), sts.GetName()) - return "", "", nil, nil - } - // Create API Key secret which is going to be used by the statefulset - // to authenticate with Tailscale. - logger.Debugf("creating authkey for new tailscale proxy") - tags := stsC.Tags - if len(tags) == 0 { - tags = a.defaultTags - } - authKey, err = newAuthKey(ctx, a.tsClient, tags) - if err != nil { - return "", "", nil, err - } - } - configs, err := tailscaledConfig(stsC, authKey, orig) - if err != nil { - return "", "", nil, fmt.Errorf("error creating tailscaled config: %w", err) - } - hash, err = tailscaledConfigHash(configs) - if err != nil { - return "", "", nil, fmt.Errorf("error calculating hash of tailscaled configs: %w", err) - } - - latest := tailcfg.CapabilityVersion(-1) - var latestConfig ipn.ConfigVAlpha - for key, val := range configs { - fn := tsoperator.TailscaledConfigFileName(key) - b, err := json.Marshal(val) - if err != nil { - return "", "", nil, fmt.Errorf("error marshalling tailscaled config: %w", err) - } - mak.Set(&secret.StringData, fn, string(b)) - if key > latest { - latest = key - latestConfig = val - } - } - - if stsC.ServeConfig != nil { - j, err := json.Marshal(stsC.ServeConfig) - if err != nil { - return "", "", nil, err - } - mak.Set(&secret.StringData, "serve-config", string(j)) - } - - if orig != nil { - logger.Debugf("patching the existing proxy Secret with tailscaled config %s", sanitizeConfigBytes(latestConfig)) - if err := a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil { - return "", "", nil, err - } - } else { - logger.Debugf("creating a new Secret for the proxy with tailscaled config %s", sanitizeConfigBytes(latestConfig)) - if err := a.Create(ctx, secret); err != nil { - return "", "", nil, err - } - } - return secret.Name, hash, configs, nil -} - -// sanitizeConfigBytes returns ipn.ConfigVAlpha in string form with redacted -// auth key. -func sanitizeConfigBytes(c ipn.ConfigVAlpha) string { - if c.AuthKey != nil { - c.AuthKey = ptr.To("**redacted**") - } - sanitizedBytes, err := json.Marshal(c) - if err != nil { - return "invalid config" - } - return string(sanitizedBytes) -} - -// DeviceInfo returns the device ID, hostname and IPs for the Tailscale device -// that acts as an operator proxy. It retrieves info from a Kubernetes Secret -// labeled with the provided labels. -// Either of device ID, hostname and IPs can be empty string if not found in the Secret. -func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map[string]string) (id tailcfg.StableNodeID, hostname string, ips []string, err error) { - sec, err := getSingleObject[corev1.Secret](ctx, a.Client, a.operatorNamespace, childLabels) - if err != nil { - return "", "", nil, err - } - if sec == nil { - return "", "", nil, nil - } - - return deviceInfo(sec) -} - -func deviceInfo(sec *corev1.Secret) (id tailcfg.StableNodeID, hostname string, ips []string, err error) { - id = tailcfg.StableNodeID(sec.Data["device_id"]) - if id == "" { - return "", "", nil, nil - } - // Kubernetes chokes on well-formed FQDNs with the trailing dot, so we have - // to remove it. - hostname = strings.TrimSuffix(string(sec.Data["device_fqdn"]), ".") - if hostname == "" { - // Device ID gets stored and retrieved in a different flow than - // FQDN and IPs. A device that acts as Kubernetes operator - // proxy, but whose route setup has failed might have an device - // ID, but no FQDN/IPs. If so, return the ID, to allow the - // operator to clean up such devices. - return id, "", nil, nil - } - if rawDeviceIPs, ok := sec.Data["device_ips"]; ok { - if err := json.Unmarshal(rawDeviceIPs, &ips); err != nil { - return "", "", nil, err - } - } - return id, hostname, ips, nil -} - -func newAuthKey(ctx context.Context, tsClient tsClient, tags []string) (string, error) { - caps := tailscale.KeyCapabilities{ - Devices: tailscale.KeyDeviceCapabilities{ - Create: tailscale.KeyDeviceCreateCapabilities{ - Reusable: false, - Preauthorized: true, - Tags: tags, - }, - }, - } - - key, _, err := tsClient.CreateKey(ctx, caps) - if err != nil { - return "", err - } - return key, nil -} - -//go:embed deploy/manifests/proxy.yaml -var proxyYaml []byte - -//go:embed deploy/manifests/userspace-proxy.yaml -var userspaceProxyYaml []byte - -func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string, configs map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) (*appsv1.StatefulSet, error) { - ss := new(appsv1.StatefulSet) - if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding - if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil { - return nil, fmt.Errorf("failed to unmarshal userspace proxy spec: %v", err) - } - } else { - if err := yaml.Unmarshal(proxyYaml, &ss); err != nil { - return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err) - } - for i := range ss.Spec.Template.Spec.InitContainers { - c := &ss.Spec.Template.Spec.InitContainers[i] - if c.Name == "sysctler" { - c.Image = a.proxyImage - break - } - } - } - pod := &ss.Spec.Template - container := &pod.Spec.Containers[0] - container.Image = a.proxyImage - ss.ObjectMeta = metav1.ObjectMeta{ - Name: headlessSvc.Name, - Namespace: a.operatorNamespace, - } - for key, val := range sts.ChildResourceLabels { - mak.Set(&ss.ObjectMeta.Labels, key, val) - } - ss.Spec.ServiceName = headlessSvc.Name - ss.Spec.Selector = &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": sts.ParentResourceUID, - }, - } - mak.Set(&pod.Labels, "app", sts.ParentResourceUID) - for key, val := range sts.ChildResourceLabels { - pod.Labels[key] = val // sync StatefulSet labels to Pod to make it easier for users to select the Pod - } - - // Generic containerboot configuration options. - container.Env = append(container.Env, - corev1.EnvVar{ - Name: "TS_KUBE_SECRET", - Value: proxySecret, - }, - corev1.EnvVar{ - // New style is in the form of cap-.hujson. - Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", - Value: "/etc/tsconfig", - }, - ) - if sts.ForwardClusterTrafficViaL7IngressProxy { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", - Value: "true", - }) - } - // Configure containeboot to run tailscaled with a configfile read from the state Secret. - mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash, tsConfigHash) - - configVolume := corev1.Volume{ - Name: "tailscaledconfig", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: proxySecret, - }, - }, - } - pod.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, configVolume) - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: "tailscaledconfig", - ReadOnly: true, - MountPath: "/etc/tsconfig", - }) - - if a.tsFirewallMode != "" { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "TS_DEBUG_FIREWALL_MODE", - Value: a.tsFirewallMode, - }) - } - pod.Spec.PriorityClassName = a.proxyPriorityClassName - - // Ingress/egress proxy configuration options. - if sts.ClusterTargetIP != "" { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "TS_DEST_IP", - Value: sts.ClusterTargetIP, - }) - mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetClusterIP, sts.ClusterTargetIP) - } else if sts.ClusterTargetDNSName != "" { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "TS_EXPERIMENTAL_DEST_DNS_NAME", - Value: sts.ClusterTargetDNSName, - }) - mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetClusterDNSName, sts.ClusterTargetDNSName) - } else if sts.TailnetTargetIP != "" { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "TS_TAILNET_TARGET_IP", - Value: sts.TailnetTargetIP, - }) - mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetTailnetTargetIP, sts.TailnetTargetIP) - } else if sts.TailnetTargetFQDN != "" { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "TS_TAILNET_TARGET_FQDN", - Value: sts.TailnetTargetFQDN, - }) - mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetTailnetTargetFQDN, sts.TailnetTargetFQDN) - } else if sts.ServeConfig != nil { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "TS_SERVE_CONFIG", - Value: "/etc/tailscaled/serve-config", - }) - container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: "serve-config", - ReadOnly: true, - MountPath: "/etc/tailscaled", - }) - pod.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{ - Name: "serve-config", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: proxySecret, - Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}, - }, - }, - }) - } - app, err := appInfoForProxy(sts) - if err != nil { - // No need to error out if now or in future we end up in a - // situation where app info cannot be determined for one of the - // many proxy configurations that the operator can produce. - logger.Error("[unexpected] unable to determine proxy type") - } else { - container.Env = append(container.Env, corev1.EnvVar{ - Name: "TS_INTERNAL_APP", - Value: app, - }) - } - logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName()) - if sts.ProxyClassName != "" { - logger.Debugf("configuring proxy resources with ProxyClass %s", sts.ProxyClassName) - ss = applyProxyClassToStatefulSet(sts.ProxyClass, ss, sts, logger) - } - updateSS := func(s *appsv1.StatefulSet) { - s.Spec = ss.Spec - s.ObjectMeta.Labels = ss.Labels - s.ObjectMeta.Annotations = ss.Annotations - } - return createOrUpdate(ctx, a.Client, a.operatorNamespace, ss, updateSS) -} - -func appInfoForProxy(cfg *tailscaleSTSConfig) (string, error) { - if cfg.ClusterTargetDNSName != "" || cfg.ClusterTargetIP != "" { - return kubetypes.AppIngressProxy, nil - } - if cfg.TailnetTargetFQDN != "" || cfg.TailnetTargetIP != "" { - return kubetypes.AppEgressProxy, nil - } - if cfg.ServeConfig != nil { - return kubetypes.AppIngressResource, nil - } - if cfg.Connector != nil { - return kubetypes.AppConnector, nil - } - return "", errors.New("unable to determine proxy type") -} - -// mergeStatefulSetLabelsOrAnnots returns a map that contains all keys/values -// present in 'custom' map as well as those keys/values from the current map -// whose keys are present in the 'managed' map. The reason why this merge is -// necessary is to ensure that labels/annotations applied from a ProxyClass get removed -// if they are removed from a ProxyClass or if the ProxyClass no longer applies -// to this StatefulSet whilst any tailscale managed labels/annotations remain present. -func mergeStatefulSetLabelsOrAnnots(current, custom map[string]string, managed []string) map[string]string { - if custom == nil { - custom = make(map[string]string) - } - if current == nil { - return custom - } - for key, val := range current { - if slices.Contains(managed, key) { - custom[key] = val - } - } - return custom -} - -func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet, stsCfg *tailscaleSTSConfig, logger *zap.SugaredLogger) *appsv1.StatefulSet { - if pc == nil || ss == nil { - return ss - } - if stsCfg != nil && pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable { - if stsCfg.TailnetTargetFQDN == "" && stsCfg.TailnetTargetIP == "" && !stsCfg.ForwardClusterTrafficViaL7IngressProxy { - enableMetrics(ss) - } else if stsCfg.ForwardClusterTrafficViaL7IngressProxy { - // TODO (irbekrm): fix this - // For Ingress proxies that have been configured with - // tailscale.com/experimental-forward-cluster-traffic-via-ingress - // annotation, all cluster traffic is forwarded to the - // Ingress backend(s). - logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.") - } else { - // TODO (irbekrm): fix this - // For egress proxies, currently all cluster traffic is forwarded to the tailnet target. - logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.") - } - } - - if pc.Spec.StatefulSet == nil { - return ss - } - - // Update StatefulSet metadata. - if wantsSSLabels := pc.Spec.StatefulSet.Labels; len(wantsSSLabels) > 0 { - ss.ObjectMeta.Labels = mergeStatefulSetLabelsOrAnnots(ss.ObjectMeta.Labels, wantsSSLabels, tailscaleManagedLabels) - } - if wantsSSAnnots := pc.Spec.StatefulSet.Annotations; len(wantsSSAnnots) > 0 { - ss.ObjectMeta.Annotations = mergeStatefulSetLabelsOrAnnots(ss.ObjectMeta.Annotations, wantsSSAnnots, tailscaleManagedAnnotations) - } - - // Update Pod fields. - if pc.Spec.StatefulSet.Pod == nil { - return ss - } - wantsPod := pc.Spec.StatefulSet.Pod - if wantsPodLabels := wantsPod.Labels; len(wantsPodLabels) > 0 { - ss.Spec.Template.ObjectMeta.Labels = mergeStatefulSetLabelsOrAnnots(ss.Spec.Template.ObjectMeta.Labels, wantsPodLabels, tailscaleManagedLabels) - } - if wantsPodAnnots := wantsPod.Annotations; len(wantsPodAnnots) > 0 { - ss.Spec.Template.ObjectMeta.Annotations = mergeStatefulSetLabelsOrAnnots(ss.Spec.Template.ObjectMeta.Annotations, wantsPodAnnots, tailscaleManagedAnnotations) - } - ss.Spec.Template.Spec.SecurityContext = wantsPod.SecurityContext - ss.Spec.Template.Spec.ImagePullSecrets = wantsPod.ImagePullSecrets - ss.Spec.Template.Spec.NodeName = wantsPod.NodeName - ss.Spec.Template.Spec.NodeSelector = wantsPod.NodeSelector - ss.Spec.Template.Spec.Affinity = wantsPod.Affinity - ss.Spec.Template.Spec.Tolerations = wantsPod.Tolerations - ss.Spec.Template.Spec.TopologySpreadConstraints = wantsPod.TopologySpreadConstraints - - // Update containers. - updateContainer := func(overlay *tsapi.Container, base corev1.Container) corev1.Container { - if overlay == nil { - return base - } - if overlay.SecurityContext != nil { - base.SecurityContext = overlay.SecurityContext - } - base.Resources = overlay.Resources - for _, e := range overlay.Env { - // Env vars configured via ProxyClass might override env - // vars that have been specified by the operator, i.e - // TS_USERSPACE. The intended behaviour is to allow this - // and in practice it works without explicitly removing - // the operator configured value here as a later value - // in the env var list overrides an earlier one. - base.Env = append(base.Env, corev1.EnvVar{Name: string(e.Name), Value: e.Value}) - } - if overlay.Image != "" { - base.Image = overlay.Image - } - if overlay.ImagePullPolicy != "" { - base.ImagePullPolicy = overlay.ImagePullPolicy - } - return base - } - for i, c := range ss.Spec.Template.Spec.Containers { - if c.Name == "tailscale" { - ss.Spec.Template.Spec.Containers[i] = updateContainer(wantsPod.TailscaleContainer, ss.Spec.Template.Spec.Containers[i]) - break - } - } - if initContainers := ss.Spec.Template.Spec.InitContainers; len(initContainers) > 0 { - for i, c := range initContainers { - if c.Name == "sysctler" { - ss.Spec.Template.Spec.InitContainers[i] = updateContainer(wantsPod.TailscaleInitContainer, initContainers[i]) - break - } - } - } - return ss -} - -func enableMetrics(ss *appsv1.StatefulSet) { - for i, c := range ss.Spec.Template.Spec.Containers { - if c.Name == "tailscale" { - // Serve metrics on on :9001/debug/metrics. If - // we didn't specify Pod IP here, the proxy would, in - // some cases, also listen to its Tailscale IP- we don't - // want folks to start relying on this side-effect as a - // feature. - ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"}) - ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports, corev1.ContainerPort{Name: "metrics", Protocol: "TCP", HostPort: 9001, ContainerPort: 9001}) - break - } - } -} - -func readAuthKey(secret *corev1.Secret, key string) (*string, error) { - origConf := &ipn.ConfigVAlpha{} - if err := json.Unmarshal([]byte(secret.Data[key]), origConf); err != nil { - return nil, fmt.Errorf("error unmarshaling previous tailscaled config in %q: %w", key, err) - } - return origConf.AuthKey, nil -} - -// tailscaledConfig takes a proxy config, a newly generated auth key if generated and a Secret with the previous proxy -// state and auth key and returns tailscaled config files for currently supported proxy versions and a hash of that -// configuration. -func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret) (tailscaledConfigs, error) { - conf := &ipn.ConfigVAlpha{ - Version: "alpha0", - AcceptDNS: "false", - AcceptRoutes: "false", // AcceptRoutes defaults to true - Locked: "false", - Hostname: &stsC.Hostname, - NoStatefulFiltering: "false", - AppConnector: &ipn.AppConnectorPrefs{Advertise: false}, - } - - // For egress proxies only, we need to ensure that stateful filtering is - // not in place so that traffic from cluster can be forwarded via - // Tailscale IPs. - // TODO (irbekrm): set it to true always as this is now the default in core. - if stsC.TailnetTargetFQDN != "" || stsC.TailnetTargetIP != "" { - conf.NoStatefulFiltering = "true" - } - if stsC.Connector != nil { - routes, err := netutil.CalcAdvertiseRoutes(stsC.Connector.routes, stsC.Connector.isExitNode) - if err != nil { - return nil, fmt.Errorf("error calculating routes: %w", err) - } - conf.AdvertiseRoutes = routes - if stsC.Connector.isAppConnector { - conf.AppConnector.Advertise = true - } - } - if shouldAcceptRoutes(stsC.ProxyClass) { - conf.AcceptRoutes = "true" - } - - if newAuthkey != "" { - conf.AuthKey = &newAuthkey - } else if shouldRetainAuthKey(oldSecret) { - key, err := authKeyFromSecret(oldSecret) - if err != nil { - return nil, fmt.Errorf("error retrieving auth key from Secret: %w", err) - } - conf.AuthKey = key - } - - capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) - capVerConfigs[107] = *conf - - // AppConnector config option is only understood by clients of capver 107 and newer. - conf.AppConnector = nil - capVerConfigs[95] = *conf - return capVerConfigs, nil -} - -func authKeyFromSecret(s *corev1.Secret) (key *string, err error) { - latest := tailcfg.CapabilityVersion(-1) - latestStr := "" - for k, data := range s.Data { - // write to StringData, read from Data as StringData is write-only - if len(data) == 0 { - continue - } - v, err := tsoperator.CapVerFromFileName(k) - if err != nil { - continue - } - if v > latest { - latestStr = k - latest = v - } - } - // Allow for configs that don't contain an auth key. Perhaps - // users have some mechanisms to delete them. Auth key is - // normally not needed after the initial login. - if latestStr != "" { - return readAuthKey(s, latestStr) - } - return key, nil -} - -// shouldRetainAuthKey returns true if the state stored in a proxy's state Secret suggests that auth key should be -// retained (because the proxy has not yet successfully authenticated). -func shouldRetainAuthKey(s *corev1.Secret) bool { - if s == nil { - return false // nothing to retain here - } - return len(s.Data["device_id"]) == 0 // proxy has not authed yet -} - -func shouldAcceptRoutes(pc *tsapi.ProxyClass) bool { - return pc != nil && pc.Spec.TailscaleConfig != nil && pc.Spec.TailscaleConfig.AcceptRoutes -} - -// ptrObject is a type constraint for pointer types that implement -// client.Object. -type ptrObject[T any] interface { - client.Object - *T -} - -type tailscaledConfigs map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha - -// hashBytes produces a hash for the provided tailscaled config that is the same across -// different invocations of this code. We do not use the -// tailscale.com/deephash.Hash here because that produces a different hash for -// the same value in different tailscale builds. The hash we are producing here -// is used to determine if the container running the Connector Tailscale node -// needs to be restarted. The container does not need restarting when the only -// thing that changed is operator version (the hash is also exposed to users via -// an annotation and might be confusing if it changes without the config having -// changed). -func tailscaledConfigHash(c tailscaledConfigs) (string, error) { - b, err := json.Marshal(c) - if err != nil { - return "", fmt.Errorf("error marshalling tailscaled configs: %w", err) - } - h := sha256.New() - if _, err = h.Write(b); err != nil { - return "", fmt.Errorf("error calculating hash: %w", err) - } - return fmt.Sprintf("%x", h.Sum(nil)), nil -} - -// createOrUpdate adds obj to the k8s cluster, unless the object already exists, -// in which case update is called to make changes to it. If update is nil, the -// existing object is returned unmodified. -// -// obj is looked up by its Name and Namespace if Name is set, otherwise it's -// looked up by labels. -func createOrUpdate[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, obj O, update func(O)) (O, error) { - var ( - existing O - err error - ) - if obj.GetName() != "" { - existing = new(T) - existing.SetName(obj.GetName()) - existing.SetNamespace(obj.GetNamespace()) - err = c.Get(ctx, client.ObjectKeyFromObject(obj), existing) - } else { - existing, err = getSingleObject[T, O](ctx, c, ns, obj.GetLabels()) - } - if err == nil && existing != nil { - if update != nil { - update(existing) - if err := c.Update(ctx, existing); err != nil { - return nil, err - } - } - return existing, nil - } - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get object: %w", err) - } - if err := c.Create(ctx, obj); err != nil { - return nil, err - } - return obj, nil -} - -// getSingleObject searches for k8s objects of type T -// (e.g. corev1.Service) with the given labels, and returns -// it. Returns nil if no objects match the labels, and an error if -// more than one object matches. -func getSingleObject[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, labels map[string]string) (O, error) { - ret := O(new(T)) - kinds, _, err := c.Scheme().ObjectKinds(ret) - if err != nil { - return nil, err - } - if len(kinds) != 1 { - // TODO: the runtime package apparently has a "pick the best - // GVK" function somewhere that might be good enough? - return nil, fmt.Errorf("more than 1 GroupVersionKind for %T", ret) - } - - gvk := kinds[0] - gvk.Kind += "List" - lst := unstructured.UnstructuredList{} - lst.SetGroupVersionKind(gvk) - if err := c.List(ctx, &lst, client.InNamespace(ns), client.MatchingLabels(labels)); err != nil { - return nil, err - } - - if len(lst.Items) == 0 { - return nil, nil - } - if len(lst.Items) > 1 { - return nil, fmt.Errorf("found multiple matching %T objects", ret) - } - if err := c.Scheme().Convert(&lst.Items[0], ret, nil); err != nil { - return nil, err - } - 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 == "" { - return defVal - } - return v -} - -func nameForService(svc *corev1.Service) string { - if h, ok := svc.Annotations[AnnotationHostname]; ok { - return h - } - return svc.Namespace + "-" + svc.Name -} - -func isValidFirewallMode(m string) bool { - return m == "auto" || m == "nftables" || m == "iptables" -} diff --git a/cmd/k8s-operator/sts_test.go b/cmd/k8s-operator/sts_test.go deleted file mode 100644 index 7263c56c36bb9..0000000000000 --- a/cmd/k8s-operator/sts_test.go +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - _ "embed" - "fmt" - "reflect" - "regexp" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/types/ptr" -) - -// Test_statefulSetNameBase tests that parent name portion in a StatefulSet name -// base will be truncated if the parent name is longer than 43 chars to ensure -// that the total does not exceed 52 chars. -// How many chars need to be cut off parent name depends on an internal var in -// kube name generation code that can change at which point this test will break -// and need to be changed. This is okay as we do not rely on that value in -// code whilst being aware when it changes might still be useful. -// https://github.com/kubernetes/kubernetes/blob/v1.28.4/staging/src/k8s.io/apiserver/pkg/storage/names/generate.go#L45. -// https://github.com/kubernetes/kubernetes/pull/116430 -func Test_statefulSetNameBase(t *testing.T) { - // Service name lengths can be 1 - 63 chars, be paranoid and test them all. - var b strings.Builder - for b.Len() < 63 { - if _, err := b.WriteString("a"); err != nil { - t.Fatalf("error writing to string builder: %v", err) - } - baseLength := b.Len() - if baseLength > 43 { - baseLength = 43 // currently 43 is the max base length - } - wantsNameR := regexp.MustCompile(`^ts-a{` + fmt.Sprint(baseLength) + `}-$`) // to match a string like ts-aaaa- - gotName := statefulSetNameBase(b.String()) - if !wantsNameR.MatchString(gotName) { - t.Fatalf("expected string %s to match regex %s ", gotName, wantsNameR.String()) // fatal rather than error as this test is called 63 times - } - } -} - -func Test_applyProxyClassToStatefulSet(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - // Setup - proxyClassAllOpts := &tsapi.ProxyClass{ - Spec: tsapi.ProxyClassSpec{ - StatefulSet: &tsapi.StatefulSet{ - Labels: map[string]string{"foo": "bar"}, - Annotations: map[string]string{"foo.io/bar": "foo"}, - Pod: &tsapi.Pod{ - Labels: map[string]string{"bar": "foo"}, - Annotations: map[string]string{"bar.io/foo": "foo"}, - SecurityContext: &corev1.PodSecurityContext{ - RunAsUser: ptr.To(int64(0)), - }, - ImagePullSecrets: []corev1.LocalObjectReference{{Name: "docker-creds"}}, - NodeName: "some-node", - NodeSelector: map[string]string{"beta.kubernetes.io/os": "linux"}, - Affinity: &corev1.Affinity{NodeAffinity: &corev1.NodeAffinity{RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{}}}, - Tolerations: []corev1.Toleration{{Key: "", Operator: "Exists"}}, - TopologySpreadConstraints: []corev1.TopologySpreadConstraint{ - { - WhenUnsatisfiable: "DoNotSchedule", - TopologyKey: "kubernetes.io/hostname", - MaxSkew: 3, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"foo": "bar"}, - }, - }, - }, - TailscaleContainer: &tsapi.Container{ - SecurityContext: &corev1.SecurityContext{ - Privileged: ptr.To(true), - }, - Resources: corev1.ResourceRequirements{ - Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1000m"), corev1.ResourceMemory: resource.MustParse("128Mi")}, - Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("500m"), corev1.ResourceMemory: resource.MustParse("64Mi")}, - }, - Env: []tsapi.Env{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}, - ImagePullPolicy: "IfNotPresent", - Image: "ghcr.io/my-repo/tailscale:v0.01testsomething", - }, - TailscaleInitContainer: &tsapi.Container{ - SecurityContext: &corev1.SecurityContext{ - Privileged: ptr.To(true), - RunAsUser: ptr.To(int64(0)), - }, - Resources: corev1.ResourceRequirements{ - Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1000m"), corev1.ResourceMemory: resource.MustParse("128Mi")}, - Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("500m"), corev1.ResourceMemory: resource.MustParse("64Mi")}, - }, - Env: []tsapi.Env{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}, - ImagePullPolicy: "IfNotPresent", - Image: "ghcr.io/my-repo/tailscale:v0.01testsomething", - }, - }, - }, - }, - } - proxyClassJustLabels := &tsapi.ProxyClass{ - Spec: tsapi.ProxyClassSpec{ - StatefulSet: &tsapi.StatefulSet{ - Labels: map[string]string{"foo": "bar"}, - Annotations: map[string]string{"foo.io/bar": "foo"}, - Pod: &tsapi.Pod{ - Labels: map[string]string{"bar": "foo"}, - Annotations: map[string]string{"bar.io/foo": "foo"}, - }, - }, - }, - } - proxyClassMetrics := &tsapi.ProxyClass{ - Spec: tsapi.ProxyClassSpec{ - Metrics: &tsapi.Metrics{Enable: true}, - }, - } - - var userspaceProxySS, nonUserspaceProxySS appsv1.StatefulSet - if err := yaml.Unmarshal(userspaceProxyYaml, &userspaceProxySS); err != nil { - t.Fatalf("unmarshaling userspace proxy template: %v", err) - } - if err := yaml.Unmarshal(proxyYaml, &nonUserspaceProxySS); err != nil { - t.Fatalf("unmarshaling non-userspace proxy template: %v", err) - } - // Set a couple additional fields so we can test that we don't - // mistakenly override those. - labels := map[string]string{ - LabelManaged: "true", - LabelParentName: "foo", - } - annots := map[string]string{ - podAnnotationLastSetClusterIP: "1.2.3.4", - } - env := []corev1.EnvVar{{Name: "TS_HOSTNAME", Value: "nginx"}} - userspaceProxySS.Labels = labels - userspaceProxySS.Annotations = annots - userspaceProxySS.Spec.Template.Spec.Containers[0].Image = "tailscale/tailscale:v0.0.1" - userspaceProxySS.Spec.Template.Spec.Containers[0].Env = env - nonUserspaceProxySS.ObjectMeta.Labels = labels - nonUserspaceProxySS.ObjectMeta.Annotations = annots - nonUserspaceProxySS.Spec.Template.Spec.Containers[0].Env = env - nonUserspaceProxySS.Spec.Template.Spec.InitContainers[0].Image = "tailscale/tailscale:v0.0.1" - - // 1. Test that a ProxyClass with all fields set gets correctly applied - // to a Statefulset built from non-userspace proxy template. - wantSS := nonUserspaceProxySS.DeepCopy() - wantSS.ObjectMeta.Labels = mergeMapKeys(wantSS.ObjectMeta.Labels, proxyClassAllOpts.Spec.StatefulSet.Labels) - wantSS.ObjectMeta.Annotations = mergeMapKeys(wantSS.ObjectMeta.Annotations, proxyClassAllOpts.Spec.StatefulSet.Annotations) - wantSS.Spec.Template.Labels = proxyClassAllOpts.Spec.StatefulSet.Pod.Labels - wantSS.Spec.Template.Annotations = proxyClassAllOpts.Spec.StatefulSet.Pod.Annotations - wantSS.Spec.Template.Spec.SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.SecurityContext - wantSS.Spec.Template.Spec.ImagePullSecrets = proxyClassAllOpts.Spec.StatefulSet.Pod.ImagePullSecrets - wantSS.Spec.Template.Spec.NodeName = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeName - wantSS.Spec.Template.Spec.NodeSelector = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeSelector - wantSS.Spec.Template.Spec.Affinity = proxyClassAllOpts.Spec.StatefulSet.Pod.Affinity - wantSS.Spec.Template.Spec.Tolerations = proxyClassAllOpts.Spec.StatefulSet.Pod.Tolerations - wantSS.Spec.Template.Spec.TopologySpreadConstraints = proxyClassAllOpts.Spec.StatefulSet.Pod.TopologySpreadConstraints - wantSS.Spec.Template.Spec.Containers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.SecurityContext - wantSS.Spec.Template.Spec.InitContainers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleInitContainer.SecurityContext - wantSS.Spec.Template.Spec.Containers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.Resources - wantSS.Spec.Template.Spec.InitContainers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleInitContainer.Resources - wantSS.Spec.Template.Spec.InitContainers[0].Env = append(wantSS.Spec.Template.Spec.InitContainers[0].Env, []corev1.EnvVar{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}...) - wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, []corev1.EnvVar{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}...) - wantSS.Spec.Template.Spec.Containers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething" - wantSS.Spec.Template.Spec.Containers[0].ImagePullPolicy = "IfNotPresent" - wantSS.Spec.Template.Spec.InitContainers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething" - wantSS.Spec.Template.Spec.InitContainers[0].ImagePullPolicy = "IfNotPresent" - - gotSS := applyProxyClassToStatefulSet(proxyClassAllOpts, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar()) - if diff := cmp.Diff(gotSS, wantSS); diff != "" { - t.Fatalf("Unexpected result applying ProxyClass with all fields set to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff) - } - - // 2. Test that a ProxyClass with custom labels and annotations for - // StatefulSet and Pod set gets correctly applied to a Statefulset built - // from non-userspace proxy template. - wantSS = nonUserspaceProxySS.DeepCopy() - wantSS.ObjectMeta.Labels = mergeMapKeys(wantSS.ObjectMeta.Labels, proxyClassJustLabels.Spec.StatefulSet.Labels) - wantSS.ObjectMeta.Annotations = mergeMapKeys(wantSS.ObjectMeta.Annotations, proxyClassJustLabels.Spec.StatefulSet.Annotations) - wantSS.Spec.Template.Labels = proxyClassJustLabels.Spec.StatefulSet.Pod.Labels - wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations - gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar()) - if diff := cmp.Diff(gotSS, wantSS); diff != "" { - t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff) - } - - // 3. Test that a ProxyClass with all fields set gets correctly applied - // to a Statefulset built from a userspace proxy template. - wantSS = userspaceProxySS.DeepCopy() - wantSS.ObjectMeta.Labels = mergeMapKeys(wantSS.ObjectMeta.Labels, proxyClassAllOpts.Spec.StatefulSet.Labels) - wantSS.ObjectMeta.Annotations = mergeMapKeys(wantSS.ObjectMeta.Annotations, proxyClassAllOpts.Spec.StatefulSet.Annotations) - wantSS.Spec.Template.Labels = proxyClassAllOpts.Spec.StatefulSet.Pod.Labels - wantSS.Spec.Template.Annotations = proxyClassAllOpts.Spec.StatefulSet.Pod.Annotations - wantSS.Spec.Template.Spec.SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.SecurityContext - wantSS.Spec.Template.Spec.ImagePullSecrets = proxyClassAllOpts.Spec.StatefulSet.Pod.ImagePullSecrets - wantSS.Spec.Template.Spec.NodeName = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeName - wantSS.Spec.Template.Spec.NodeSelector = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeSelector - wantSS.Spec.Template.Spec.Affinity = proxyClassAllOpts.Spec.StatefulSet.Pod.Affinity - wantSS.Spec.Template.Spec.Tolerations = proxyClassAllOpts.Spec.StatefulSet.Pod.Tolerations - wantSS.Spec.Template.Spec.TopologySpreadConstraints = proxyClassAllOpts.Spec.StatefulSet.Pod.TopologySpreadConstraints - wantSS.Spec.Template.Spec.Containers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.SecurityContext - wantSS.Spec.Template.Spec.Containers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.Resources - wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, []corev1.EnvVar{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}...) - wantSS.Spec.Template.Spec.Containers[0].ImagePullPolicy = "IfNotPresent" - wantSS.Spec.Template.Spec.Containers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething" - gotSS = applyProxyClassToStatefulSet(proxyClassAllOpts, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar()) - if diff := cmp.Diff(gotSS, wantSS); diff != "" { - t.Fatalf("Unexpected result applying ProxyClass with all options to a StatefulSet for a userspace proxy (-got +want):\n%s", diff) - } - - // 4. Test that a ProxyClass with custom labels and annotations gets correctly applied - // to a Statefulset built from a userspace proxy template. - wantSS = userspaceProxySS.DeepCopy() - wantSS.ObjectMeta.Labels = mergeMapKeys(wantSS.ObjectMeta.Labels, proxyClassJustLabels.Spec.StatefulSet.Labels) - wantSS.ObjectMeta.Annotations = mergeMapKeys(wantSS.ObjectMeta.Annotations, proxyClassJustLabels.Spec.StatefulSet.Annotations) - wantSS.Spec.Template.Labels = proxyClassJustLabels.Spec.StatefulSet.Pod.Labels - wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations - gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar()) - if diff := cmp.Diff(gotSS, wantSS); diff != "" { - t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for a userspace proxy (-got +want):\n%s", diff) - } - - // 5. Test that a ProxyClass with metrics enabled gets correctly applied to a StatefulSet. - wantSS = nonUserspaceProxySS.DeepCopy() - wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"}) - wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "metrics", Protocol: "TCP", ContainerPort: 9001, HostPort: 9001}} - gotSS = applyProxyClassToStatefulSet(proxyClassMetrics, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar()) - if diff := cmp.Diff(gotSS, wantSS); diff != "" { - t.Fatalf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff) - } -} - -func mergeMapKeys(a, b map[string]string) map[string]string { - for key, val := range b { - a[key] = val - } - return a -} - -func Test_mergeStatefulSetLabelsOrAnnots(t *testing.T) { - tests := []struct { - name string - current map[string]string - custom map[string]string - managed []string - want map[string]string - }{ - { - name: "no custom labels specified and none present in current labels, return current labels", - current: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - want: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - managed: tailscaleManagedLabels, - }, - { - name: "no custom labels specified, but some present in current labels, return tailscale managed labels only from the current labels", - current: map[string]string{"foo": "bar", "something.io/foo": "bar", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - want: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - managed: tailscaleManagedLabels, - }, - { - name: "custom labels specified, current labels only contain tailscale managed labels, return a union of both", - current: map[string]string{LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - custom: map[string]string{"foo": "bar", "something.io/foo": "bar"}, - want: map[string]string{"foo": "bar", "something.io/foo": "bar", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - managed: tailscaleManagedLabels, - }, - { - name: "custom labels specified, current labels contain tailscale managed labels and custom labels, some of which re not present in the new custom labels, return a union of managed labels and the desired custom labels", - current: map[string]string{"foo": "bar", "bar": "baz", "app": "1234", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - custom: map[string]string{"foo": "bar", "something.io/foo": "bar"}, - want: map[string]string{"foo": "bar", "something.io/foo": "bar", "app": "1234", LabelManaged: "true", LabelParentName: "foo", LabelParentType: "svc", LabelParentNamespace: "foo"}, - managed: tailscaleManagedLabels, - }, - { - name: "no current labels present, return custom labels only", - custom: map[string]string{"foo": "bar", "something.io/foo": "bar"}, - want: map[string]string{"foo": "bar", "something.io/foo": "bar"}, - managed: tailscaleManagedLabels, - }, - { - name: "no current labels present, no custom labels specified, return empty map", - want: map[string]string{}, - managed: tailscaleManagedLabels, - }, - { - name: "no custom annots specified and none present in current annots, return current annots", - current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"}, - want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"}, - managed: tailscaleManagedAnnotations, - }, - { - name: "no custom annots specified, but some present in current annots, return tailscale managed annots only from the current annots", - current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"}, - want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"}, - managed: tailscaleManagedAnnotations, - }, - { - name: "custom annots specified, current annots only contain tailscale managed annots, return a union of both", - current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"}, - custom: map[string]string{"foo": "bar", "something.io/foo": "bar"}, - want: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"}, - managed: tailscaleManagedAnnotations, - }, - { - name: "custom annots specified, current annots contain tailscale managed annots and custom annots, some of which are not present in the new custom annots, return a union of managed annots and the desired custom annots", - current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"}, - custom: map[string]string{"something.io/foo": "bar"}, - want: map[string]string{"something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"}, - managed: tailscaleManagedAnnotations, - }, - { - name: "no current annots present, return custom annots only", - custom: map[string]string{"foo": "bar", "something.io/foo": "bar"}, - want: map[string]string{"foo": "bar", "something.io/foo": "bar"}, - managed: tailscaleManagedAnnotations, - }, - { - name: "no current labels present, no custom labels specified, return empty map", - want: map[string]string{}, - managed: tailscaleManagedAnnotations, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := mergeStatefulSetLabelsOrAnnots(tt.current, tt.custom, tt.managed); !reflect.DeepEqual(got, tt.want) { - t.Errorf("mergeStatefulSetLabels() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/cmd/k8s-operator/svc.go b/cmd/k8s-operator/svc.go deleted file mode 100644 index 3c6bc27a95cf0..0000000000000 --- a/cmd/k8s-operator/svc.go +++ /dev/null @@ -1,487 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "errors" - "fmt" - "net/netip" - "slices" - "strings" - "sync" - - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/net/dns/resolvconffile" - "tailscale.com/tstime" - "tailscale.com/util/clientmetric" - "tailscale.com/util/dnsname" - "tailscale.com/util/set" -) - -const ( - resolvConfPath = "/etc/resolv.conf" - defaultClusterDomain = "cluster.local" - - reasonProxyCreated = "ProxyCreated" - reasonProxyInvalid = "ProxyInvalid" - reasonProxyFailed = "ProxyFailed" - reasonProxyPending = "ProxyPending" -) - -type ServiceReconciler struct { - client.Client - ssr *tailscaleSTSReconciler - logger *zap.SugaredLogger - isDefaultLoadBalancer bool - - mu sync.Mutex // protects following - - // managedIngressProxies is a set of all ingress proxies that we're - // currently managing. This is only used for metrics. - managedIngressProxies set.Slice[types.UID] - // managedEgressProxies is a set of all egress proxies that we're currently - // managing. This is only used for metrics. - managedEgressProxies set.Slice[types.UID] - - recorder record.EventRecorder - - tsNamespace string - - clock tstime.Clock - - defaultProxyClass string -} - -var ( - // gaugeEgressProxies tracks the number of egress proxies that we're - // currently managing. - gaugeEgressProxies = clientmetric.NewGauge(kubetypes.MetricEgressProxyCount) - // gaugeIngressProxies tracks the number of ingress proxies that we're - // currently managing. - gaugeIngressProxies = clientmetric.NewGauge(kubetypes.MetricIngressProxyCount) -) - -func childResourceLabels(name, ns, typ string) map[string]string { - // You might wonder why we're using owner references, since they seem to be - // built for exactly this. Unfortunately, Kubernetes does not support - // cross-namespace ownership, by design. This means we cannot make the - // service being exposed the owner of the implementation details of the - // proxying. Instead, we have to do our own filtering and tracking with - // labels. - return map[string]string{ - LabelManaged: "true", - LabelParentName: name, - LabelParentNamespace: ns, - LabelParentType: typ, - } -} - -func (a *ServiceReconciler) isTailscaleService(svc *corev1.Service) bool { - targetIP := tailnetTargetAnnotation(svc) - targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN] - return a.shouldExpose(svc) || targetIP != "" || targetFQDN != "" -} - -func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { - logger := a.logger.With("service-ns", req.Namespace, "service-name", req.Name) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - svc := new(corev1.Service) - err = a.Get(ctx, req.NamespacedName, svc) - if apierrors.IsNotFound(err) { - // Request object not found, could have been deleted after reconcile request. - logger.Debugf("service not found, assuming it was deleted") - return reconcile.Result{}, nil - } else if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err) - } - - if _, ok := svc.Annotations[AnnotationProxyGroup]; ok { - return reconcile.Result{}, nil // this reconciler should not look at Services for ProxyGroup - } - - if !svc.DeletionTimestamp.IsZero() || !a.isTailscaleService(svc) { - logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up") - return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc) - } - - return reconcile.Result{}, a.maybeProvision(ctx, logger, svc) -} - -// maybeCleanup removes any existing resources related to serving svc over tailscale. -// -// This function is responsible for removing the finalizer from the service, -// once all associated resources are gone. -func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (err error) { - oldSvcStatus := svc.Status.DeepCopy() - defer func() { - if !apiequality.Semantic.DeepEqual(oldSvcStatus, svc.Status) { - // An error encountered here should get returned by the Reconcile function. - err = errors.Join(err, a.Client.Status().Update(ctx, svc)) - } - }() - ix := slices.Index(svc.Finalizers, FinalizerName) - if ix < 0 { - logger.Debugf("no finalizer, nothing to do") - a.mu.Lock() - defer a.mu.Unlock() - a.managedIngressProxies.Remove(svc.UID) - a.managedEgressProxies.Remove(svc.UID) - gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len())) - gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len())) - - if !a.isTailscaleService(svc) { - tsoperator.RemoveServiceCondition(svc, tsapi.ProxyReady) - } - return nil - } - - if done, err := a.ssr.Cleanup(ctx, logger, childResourceLabels(svc.Name, svc.Namespace, "svc")); err != nil { - return fmt.Errorf("failed to cleanup: %w", err) - } else if !done { - logger.Debugf("cleanup not done yet, waiting for next reconcile") - return nil - } - - svc.Finalizers = append(svc.Finalizers[:ix], svc.Finalizers[ix+1:]...) - if err := a.Update(ctx, svc); err != nil { - return fmt.Errorf("failed to remove finalizer: %w", err) - } - - // Unlike most log entries in the reconcile loop, this will get printed - // exactly once at the very end of cleanup, because the final step of - // cleanup removes the tailscale finalizer, which will make all future - // reconciles exit early. - logger.Infof("unexposed Service from tailnet") - - a.mu.Lock() - defer a.mu.Unlock() - a.managedIngressProxies.Remove(svc.UID) - a.managedEgressProxies.Remove(svc.UID) - gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len())) - gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len())) - - if !a.isTailscaleService(svc) { - tsoperator.RemoveServiceCondition(svc, tsapi.ProxyReady) - } - return nil -} - -// maybeProvision ensures that svc is exposed over tailscale, taking any actions -// necessary to reach that state. -// -// This function adds a finalizer to svc, ensuring that we can handle orderly -// deprovisioning later. -func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (err error) { - oldSvcStatus := svc.Status.DeepCopy() - defer func() { - if !apiequality.Semantic.DeepEqual(oldSvcStatus, svc.Status) { - // An error encountered here should get returned by the Reconcile function. - err = errors.Join(err, a.Client.Status().Update(ctx, svc)) - } - }() - - // Run for proxy config related validations here as opposed to running - // them earlier. This is to prevent cleanup being blocked on a - // misconfigured proxy param. - if err := a.ssr.validate(); err != nil { - msg := fmt.Sprintf("unable to provision proxy resources: invalid config: %v", err) - a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDCONFIG", msg) - a.logger.Error(msg) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyInvalid, msg, a.clock, logger) - return nil - } - if violations := validateService(svc); len(violations) > 0 { - msg := fmt.Sprintf("unable to provision proxy resources: invalid Service: %s", strings.Join(violations, ", ")) - a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVICE", msg) - a.logger.Error(msg) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyInvalid, msg, a.clock, logger) - return nil - } - - proxyClass := proxyClassForObject(svc, a.defaultProxyClass) - if proxyClass != "" { - if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil { - errMsg := fmt.Errorf("error verifying ProxyClass for Service: %w", err) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger) - return errMsg - } else if !ready { - msg := fmt.Sprintf("ProxyClass %s specified for the Service, but is not (yet) Ready, waiting..", proxyClass) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyPending, msg, a.clock, logger) - logger.Info(msg) - return nil - } - } - - if !slices.Contains(svc.Finalizers, FinalizerName) { - // This log line is printed exactly once during initial provisioning, - // because once the finalizer is in place this block gets skipped. So, - // this is a nice place to tell the operator that the high level, - // multi-reconcile operation is underway. - logger.Infof("exposing service over tailscale") - svc.Finalizers = append(svc.Finalizers, FinalizerName) - if err := a.Update(ctx, svc); err != nil { - errMsg := fmt.Errorf("failed to add finalizer: %w", err) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger) - return errMsg - } - } - crl := childResourceLabels(svc.Name, svc.Namespace, "svc") - var tags []string - if tstr, ok := svc.Annotations[AnnotationTags]; ok { - tags = strings.Split(tstr, ",") - } - - sts := &tailscaleSTSConfig{ - ParentResourceName: svc.Name, - ParentResourceUID: string(svc.UID), - Hostname: nameForService(svc), - Tags: tags, - ChildResourceLabels: crl, - ProxyClassName: proxyClass, - } - - a.mu.Lock() - if a.shouldExposeClusterIP(svc) { - sts.ClusterTargetIP = svc.Spec.ClusterIP - a.managedIngressProxies.Add(svc.UID) - gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len())) - } else if a.shouldExposeDNSName(svc) { - sts.ClusterTargetDNSName = svc.Spec.ExternalName - a.managedIngressProxies.Add(svc.UID) - gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len())) - } else if ip := tailnetTargetAnnotation(svc); ip != "" { - sts.TailnetTargetIP = ip - a.managedEgressProxies.Add(svc.UID) - gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len())) - } else if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" { - fqdn := svc.Annotations[AnnotationTailnetTargetFQDN] - if !strings.HasSuffix(fqdn, ".") { - fqdn = fqdn + "." - } - sts.TailnetTargetFQDN = fqdn - a.managedEgressProxies.Add(svc.UID) - gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len())) - } - a.mu.Unlock() - - var hsvc *corev1.Service - if hsvc, err = a.ssr.Provision(ctx, logger, sts); err != nil { - errMsg := fmt.Errorf("failed to provision: %w", err) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger) - return errMsg - } - - if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" { // if an egress proxy - clusterDomain := retrieveClusterDomain(a.tsNamespace, logger) - headlessSvcName := hsvc.Name + "." + hsvc.Namespace + ".svc." + clusterDomain - if svc.Spec.ExternalName != headlessSvcName || svc.Spec.Type != corev1.ServiceTypeExternalName { - svc.Spec.ExternalName = headlessSvcName - svc.Spec.Selector = nil - svc.Spec.Type = corev1.ServiceTypeExternalName - if err := a.Update(ctx, svc); err != nil { - errMsg := fmt.Errorf("failed to update service: %w", err) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger) - return errMsg - } - } - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger) - return nil - } - - if !isTailscaleLoadBalancerService(svc, a.isDefaultLoadBalancer) { - logger.Debugf("service is not a LoadBalancer, so not updating ingress") - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger) - return nil - } - - _, tsHost, tsIPs, err := a.ssr.DeviceInfo(ctx, crl) - if err != nil { - return fmt.Errorf("failed to get device ID: %w", err) - } - if tsHost == "" { - msg := "no Tailscale hostname known yet, waiting for proxy pod to finish auth" - logger.Debug(msg) - // No hostname yet. Wait for the proxy pod to auth. - svc.Status.LoadBalancer.Ingress = nil - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyPending, msg, a.clock, logger) - return nil - } - - logger.Debugf("setting Service LoadBalancer status to %q, %s", tsHost, strings.Join(tsIPs, ", ")) - ingress := []corev1.LoadBalancerIngress{ - {Hostname: tsHost}, - } - clusterIPAddr, err := netip.ParseAddr(svc.Spec.ClusterIP) - if err != nil { - msg := fmt.Sprintf("failed to parse cluster IP: %v", err) - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, msg, a.clock, logger) - return errors.New(msg) - } - for _, ip := range tsIPs { - addr, err := netip.ParseAddr(ip) - if err != nil { - continue - } - if addr.Is4() == clusterIPAddr.Is4() { // only add addresses of the same family - ingress = append(ingress, corev1.LoadBalancerIngress{IP: ip}) - } - } - svc.Status.LoadBalancer.Ingress = ingress - tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger) - return nil -} - -func validateService(svc *corev1.Service) []string { - violations := make([]string, 0) - if svc.Annotations[AnnotationTailnetTargetFQDN] != "" && svc.Annotations[AnnotationTailnetTargetIP] != "" { - violations = append(violations, fmt.Sprintf("only one of annotations %s and %s can be set", AnnotationTailnetTargetIP, AnnotationTailnetTargetFQDN)) - } - if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" { - if !isMagicDNSName(fqdn) { - violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q does not appear to be a valid MagicDNS name", AnnotationTailnetTargetFQDN, fqdn)) - } - } - if ipStr := svc.Annotations[AnnotationTailnetTargetIP]; ipStr != "" { - ip, err := netip.ParseAddr(ipStr) - if err != nil { - violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q could not be parsed as a valid IP Address, error: %s", AnnotationTailnetTargetIP, ipStr, err)) - } else if !ip.IsValid() { - violations = append(violations, fmt.Sprintf("parsed IP address in annotation %s: %q is not valid", AnnotationTailnetTargetIP, ipStr)) - } - } - - svcName := nameForService(svc) - if err := dnsname.ValidLabel(svcName); err != nil { - if _, ok := svc.Annotations[AnnotationHostname]; ok { - violations = append(violations, fmt.Sprintf("invalid Tailscale hostname specified %q: %s", svcName, err)) - } else { - violations = append(violations, fmt.Sprintf("invalid Tailscale hostname %q, use %q annotation to override: %s", svcName, AnnotationHostname, err)) - } - } - return violations -} - -func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool { - return a.shouldExposeClusterIP(svc) || a.shouldExposeDNSName(svc) -} - -func (a *ServiceReconciler) shouldExposeDNSName(svc *corev1.Service) bool { - return hasExposeAnnotation(svc) && svc.Spec.Type == corev1.ServiceTypeExternalName && svc.Spec.ExternalName != "" -} - -func (a *ServiceReconciler) shouldExposeClusterIP(svc *corev1.Service) bool { - if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" { - return false - } - return isTailscaleLoadBalancerService(svc, a.isDefaultLoadBalancer) || hasExposeAnnotation(svc) -} - -func isTailscaleLoadBalancerService(svc *corev1.Service, isDefaultLoadBalancer bool) bool { - return svc != nil && - svc.Spec.Type == corev1.ServiceTypeLoadBalancer && - (svc.Spec.LoadBalancerClass != nil && *svc.Spec.LoadBalancerClass == "tailscale" || - svc.Spec.LoadBalancerClass == nil && isDefaultLoadBalancer) -} - -// hasExposeAnnotation reports whether Service has the tailscale.com/expose -// annotation set -func hasExposeAnnotation(svc *corev1.Service) bool { - return svc != nil && svc.Annotations[AnnotationExpose] == "true" -} - -// tailnetTargetAnnotation returns the value of tailscale.com/tailnet-ip -// annotation or of the deprecated tailscale.com/ts-tailnet-target-ip -// annotation. If neither is set, it returns an empty string. If both are set, -// it returns the value of the new annotation. -func tailnetTargetAnnotation(svc *corev1.Service) string { - if svc == nil { - return "" - } - if ip := svc.Annotations[AnnotationTailnetTargetIP]; ip != "" { - return ip - } - return svc.Annotations[annotationTailnetTargetIPOld] -} - -// proxyClassForObject returns the proxy class for the given object. If the -// object does not have a proxy class label, it returns the default proxy class -func proxyClassForObject(o client.Object, proxyDefaultClass string) string { - proxyClass, exists := o.GetLabels()[LabelProxyClass] - if !exists { - proxyClass = proxyDefaultClass - } - return proxyClass -} - -func proxyClassIsReady(ctx context.Context, name string, cl client.Client) (bool, error) { - proxyClass := new(tsapi.ProxyClass) - if err := cl.Get(ctx, types.NamespacedName{Name: name}, proxyClass); err != nil { - return false, fmt.Errorf("error getting ProxyClass %s: %w", name, err) - } - return tsoperator.ProxyClassIsReady(proxyClass), nil -} - -// retrieveClusterDomain determines and retrieves cluster domain i.e -// (cluster.local) in which this Pod is running by parsing search domains in -// /etc/resolv.conf. If an error is encountered at any point during the process, -// defaults cluster domain to 'cluster.local'. -func retrieveClusterDomain(namespace string, logger *zap.SugaredLogger) string { - logger.Infof("attempting to retrieve cluster domain..") - conf, err := resolvconffile.ParseFile(resolvConfPath) - if err != nil { - // Vast majority of clusters use the cluster.local domain, so it - // is probably better to fall back to that than error out. - logger.Infof("[unexpected] error parsing /etc/resolv.conf to determine cluster domain, defaulting to 'cluster.local'.") - return defaultClusterDomain - } - return clusterDomainFromResolverConf(conf, namespace, logger) -} - -// clusterDomainFromResolverConf attempts to retrieve cluster domain from the provided resolver config. -// It expects the first three search domains in the resolver config to be be ['.svc., svc., , ...] -// If the first three domains match the expected structure, it returns the third. -// If the domains don't match the expected structure or an error is encountered, it defaults to 'cluster.local' domain. -func clusterDomainFromResolverConf(conf *resolvconffile.Config, namespace string, logger *zap.SugaredLogger) string { - if len(conf.SearchDomains) < 3 { - logger.Infof("[unexpected] resolver config contains only %d search domains, at least three expected.\nDefaulting cluster domain to 'cluster.local'.") - return defaultClusterDomain - } - first := conf.SearchDomains[0] - if !strings.HasPrefix(string(first), namespace+".svc") { - logger.Infof("[unexpected] first search domain in resolver config is %s; expected %s.\nDefaulting cluster domain to 'cluster.local'.", first, namespace+".svc.") - return defaultClusterDomain - } - second := conf.SearchDomains[1] - if !strings.HasPrefix(string(second), "svc") { - logger.Infof("[unexpected] second search domain in resolver config is %s; expected 'svc.'.\nDefaulting cluster domain to 'cluster.local'.", second) - return defaultClusterDomain - } - // Trim the trailing dot for backwards compatibility purposes as the - // cluster domain was previously hardcoded to 'cluster.local' without a - // trailing dot. - probablyClusterDomain := strings.TrimPrefix(second.WithoutTrailingDot(), "svc.") - third := conf.SearchDomains[2] - if !strings.EqualFold(third.WithoutTrailingDot(), probablyClusterDomain) { - logger.Infof("[unexpected] expected resolver config to contain serch domains .svc., svc., ; got %s %s %s\n. Defaulting cluster domain to 'cluster.local'.", first, second, third) - return defaultClusterDomain - } - logger.Infof("Cluster domain %q extracted from resolver config", probablyClusterDomain) - return probablyClusterDomain -} diff --git a/cmd/k8s-operator/testutils_test.go b/cmd/k8s-operator/testutils_test.go deleted file mode 100644 index 084f573e5e45a..0000000000000 --- a/cmd/k8s-operator/testutils_test.go +++ /dev/null @@ -1,686 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "net/netip" - "reflect" - "strings" - "sync" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/types/ptr" - "tailscale.com/util/mak" -) - -// confgOpts contains configuration options for creating cluster resources for -// Tailscale proxies. -type configOpts struct { - stsName string - secretName string - hostname string - namespace string - parentType string - priorityClassName string - firewallMode string - tailnetTargetIP string - tailnetTargetFQDN string - clusterTargetIP string - clusterTargetDNS string - subnetRoutes string - isExitNode bool - isAppConnector bool - confFileHash string - serveConfig *ipn.ServeConfig - shouldEnableForwardingClusterTrafficViaIngress bool - proxyClass string // configuration from the named ProxyClass should be applied to proxy resources - app string - shouldRemoveAuthKey bool - secretExtraData map[string][]byte -} - -func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet { - t.Helper() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tsContainer := corev1.Container{ - Name: "tailscale", - Image: "tailscale/tailscale", - Env: []corev1.EnvVar{ - {Name: "TS_USERSPACE", Value: "false"}, - {Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}}, - {Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.name"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}}, - {Name: "POD_UID", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.uid"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}}, - {Name: "TS_KUBE_SECRET", Value: opts.secretName}, - {Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"}, - }, - SecurityContext: &corev1.SecurityContext{ - Capabilities: &corev1.Capabilities{ - Add: []corev1.Capability{"NET_ADMIN"}, - }, - }, - ImagePullPolicy: "Always", - } - if opts.shouldEnableForwardingClusterTrafficViaIngress { - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", - Value: "true", - }) - } - annots := make(map[string]string) - var volumes []corev1.Volume - volumes = []corev1.Volume{ - { - Name: "tailscaledconfig", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: opts.secretName, - }, - }, - }, - } - tsContainer.VolumeMounts = []corev1.VolumeMount{{ - Name: "tailscaledconfig", - ReadOnly: true, - MountPath: "/etc/tsconfig", - }} - if opts.confFileHash != "" { - annots["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash - } - if opts.firewallMode != "" { - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "TS_DEBUG_FIREWALL_MODE", - Value: opts.firewallMode, - }) - } - if opts.tailnetTargetIP != "" { - annots["tailscale.com/operator-last-set-ts-tailnet-target-ip"] = opts.tailnetTargetIP - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "TS_TAILNET_TARGET_IP", - Value: opts.tailnetTargetIP, - }) - } else if opts.tailnetTargetFQDN != "" { - annots["tailscale.com/operator-last-set-ts-tailnet-target-fqdn"] = opts.tailnetTargetFQDN - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "TS_TAILNET_TARGET_FQDN", - Value: opts.tailnetTargetFQDN, - }) - - } else if opts.clusterTargetIP != "" { - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "TS_DEST_IP", - Value: opts.clusterTargetIP, - }) - annots["tailscale.com/operator-last-set-cluster-ip"] = opts.clusterTargetIP - } else if opts.clusterTargetDNS != "" { - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "TS_EXPERIMENTAL_DEST_DNS_NAME", - Value: opts.clusterTargetDNS, - }) - annots["tailscale.com/operator-last-set-cluster-dns-name"] = opts.clusterTargetDNS - } - if opts.serveConfig != nil { - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "TS_SERVE_CONFIG", - Value: "/etc/tailscaled/serve-config", - }) - volumes = append(volumes, corev1.Volume{Name: "serve-config", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}}}) - tsContainer.VolumeMounts = append(tsContainer.VolumeMounts, corev1.VolumeMount{Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"}) - } - tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{ - Name: "TS_INTERNAL_APP", - Value: opts.app, - }) - ss := &appsv1.StatefulSet{ - TypeMeta: metav1.TypeMeta{ - Kind: "StatefulSet", - APIVersion: "apps/v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: opts.stsName, - Namespace: "operator-ns", - Labels: map[string]string{ - "tailscale.com/managed": "true", - "tailscale.com/parent-resource": "test", - "tailscale.com/parent-resource-ns": opts.namespace, - "tailscale.com/parent-resource-type": opts.parentType, - }, - }, - Spec: appsv1.StatefulSetSpec{ - Replicas: ptr.To[int32](1), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "1234-UID"}, - }, - ServiceName: opts.stsName, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: annots, - DeletionGracePeriodSeconds: ptr.To[int64](10), - Labels: map[string]string{ - "tailscale.com/managed": "true", - "tailscale.com/parent-resource": "test", - "tailscale.com/parent-resource-ns": opts.namespace, - "tailscale.com/parent-resource-type": opts.parentType, - "app": "1234-UID", - }, - }, - Spec: corev1.PodSpec{ - ServiceAccountName: "proxies", - PriorityClassName: opts.priorityClassName, - InitContainers: []corev1.Container{ - { - Name: "sysctler", - Image: "tailscale/tailscale", - Command: []string{"/bin/sh", "-c"}, - Args: []string{"sysctl -w net.ipv4.ip_forward=1 && if sysctl net.ipv6.conf.all.forwarding; then sysctl -w net.ipv6.conf.all.forwarding=1; fi"}, - SecurityContext: &corev1.SecurityContext{ - Privileged: ptr.To(true), - }, - }, - }, - Containers: []corev1.Container{tsContainer}, - Volumes: volumes, - }, - }, - }, - } - // If opts.proxyClass is set, retrieve the ProxyClass and apply - // configuration from that to the StatefulSet. - if opts.proxyClass != "" { - t.Logf("applying configuration from ProxyClass %s", opts.proxyClass) - proxyClass := new(tsapi.ProxyClass) - if err := cl.Get(context.Background(), types.NamespacedName{Name: opts.proxyClass}, proxyClass); err != nil { - t.Fatalf("error getting ProxyClass: %v", err) - } - return applyProxyClassToStatefulSet(proxyClass, ss, new(tailscaleSTSConfig), zl.Sugar()) - } - return ss -} - -func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet { - t.Helper() - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tsContainer := corev1.Container{ - Name: "tailscale", - Image: "tailscale/tailscale", - Env: []corev1.EnvVar{ - {Name: "TS_USERSPACE", Value: "true"}, - {Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}}, - {Name: "POD_NAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.name"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}}, - {Name: "POD_UID", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "metadata.uid"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}}, - {Name: "TS_KUBE_SECRET", Value: opts.secretName}, - {Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"}, - {Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"}, - {Name: "TS_INTERNAL_APP", Value: opts.app}, - }, - ImagePullPolicy: "Always", - VolumeMounts: []corev1.VolumeMount{ - {Name: "tailscaledconfig", ReadOnly: true, MountPath: "/etc/tsconfig"}, - {Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"}, - }, - } - volumes := []corev1.Volume{ - { - Name: "tailscaledconfig", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: opts.secretName, - }, - }, - }, - {Name: "serve-config", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}}}, - } - ss := &appsv1.StatefulSet{ - TypeMeta: metav1.TypeMeta{ - Kind: "StatefulSet", - APIVersion: "apps/v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: opts.stsName, - Namespace: "operator-ns", - Labels: map[string]string{ - "tailscale.com/managed": "true", - "tailscale.com/parent-resource": "test", - "tailscale.com/parent-resource-ns": opts.namespace, - "tailscale.com/parent-resource-type": opts.parentType, - }, - }, - Spec: appsv1.StatefulSetSpec{ - Replicas: ptr.To[int32](1), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "1234-UID"}, - }, - ServiceName: opts.stsName, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - DeletionGracePeriodSeconds: ptr.To[int64](10), - Labels: map[string]string{ - "tailscale.com/managed": "true", - "tailscale.com/parent-resource": "test", - "tailscale.com/parent-resource-ns": opts.namespace, - "tailscale.com/parent-resource-type": opts.parentType, - "app": "1234-UID", - }, - }, - Spec: corev1.PodSpec{ - ServiceAccountName: "proxies", - PriorityClassName: opts.priorityClassName, - Containers: []corev1.Container{tsContainer}, - Volumes: volumes, - }, - }, - }, - } - ss.Spec.Template.Annotations = map[string]string{} - if opts.confFileHash != "" { - ss.Spec.Template.Annotations["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash - } - // If opts.proxyClass is set, retrieve the ProxyClass and apply - // configuration from that to the StatefulSet. - if opts.proxyClass != "" { - t.Logf("applying configuration from ProxyClass %s", opts.proxyClass) - proxyClass := new(tsapi.ProxyClass) - if err := cl.Get(context.Background(), types.NamespacedName{Name: opts.proxyClass}, proxyClass); err != nil { - t.Fatalf("error getting ProxyClass: %v", err) - } - return applyProxyClassToStatefulSet(proxyClass, ss, new(tailscaleSTSConfig), zl.Sugar()) - } - return ss -} - -func expectedHeadlessService(name string, parentType string) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - GenerateName: "ts-test-", - Namespace: "operator-ns", - Labels: map[string]string{ - "tailscale.com/managed": "true", - "tailscale.com/parent-resource": "test", - "tailscale.com/parent-resource-ns": "default", - "tailscale.com/parent-resource-type": parentType, - }, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{ - "app": "1234-UID", - }, - ClusterIP: "None", - IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack), - }, - } -} - -func expectedSecret(t *testing.T, cl client.Client, opts configOpts) *corev1.Secret { - t.Helper() - s := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: opts.secretName, - Namespace: "operator-ns", - }, - } - if opts.serveConfig != nil { - serveConfigBs, err := json.Marshal(opts.serveConfig) - if err != nil { - t.Fatalf("error marshalling serve config: %v", err) - } - mak.Set(&s.StringData, "serve-config", string(serveConfigBs)) - } - conf := &ipn.ConfigVAlpha{ - Version: "alpha0", - AcceptDNS: "false", - Hostname: &opts.hostname, - Locked: "false", - AuthKey: ptr.To("secret-authkey"), - AcceptRoutes: "false", - AppConnector: &ipn.AppConnectorPrefs{Advertise: false}, - } - if opts.proxyClass != "" { - t.Logf("applying configuration from ProxyClass %s", opts.proxyClass) - proxyClass := new(tsapi.ProxyClass) - if err := cl.Get(context.Background(), types.NamespacedName{Name: opts.proxyClass}, proxyClass); err != nil { - t.Fatalf("error getting ProxyClass: %v", err) - } - if proxyClass.Spec.TailscaleConfig != nil && proxyClass.Spec.TailscaleConfig.AcceptRoutes { - conf.AcceptRoutes = "true" - } - } - if opts.shouldRemoveAuthKey { - conf.AuthKey = nil - } - if opts.isAppConnector { - conf.AppConnector = &ipn.AppConnectorPrefs{Advertise: true} - } - var routes []netip.Prefix - if opts.subnetRoutes != "" || opts.isExitNode { - r := opts.subnetRoutes - if opts.isExitNode { - r = "0.0.0.0/0,::/0," + r - } - for _, rr := range strings.Split(r, ",") { - prefix, err := netip.ParsePrefix(rr) - if err != nil { - t.Fatal(err) - } - routes = append(routes, prefix) - } - } - if opts.tailnetTargetFQDN != "" || opts.tailnetTargetIP != "" { - conf.NoStatefulFiltering = "true" - } else { - conf.NoStatefulFiltering = "false" - } - conf.AdvertiseRoutes = routes - bnn, err := json.Marshal(conf) - if err != nil { - t.Fatalf("error marshalling tailscaled config") - } - conf.AppConnector = nil - bn, err := json.Marshal(conf) - if err != nil { - t.Fatalf("error marshalling tailscaled config") - } - mak.Set(&s.StringData, "cap-95.hujson", string(bn)) - mak.Set(&s.StringData, "cap-107.hujson", string(bnn)) - labels := map[string]string{ - "tailscale.com/managed": "true", - "tailscale.com/parent-resource": "test", - "tailscale.com/parent-resource-ns": "default", - "tailscale.com/parent-resource-type": opts.parentType, - } - if opts.parentType == "connector" { - labels["tailscale.com/parent-resource-ns"] = "" // Connector is cluster scoped - } - s.Labels = labels - for key, val := range opts.secretExtraData { - mak.Set(&s.Data, key, val) - } - return s -} - -func findGenName(t *testing.T, client client.Client, ns, name, typ string) (full, noSuffix string) { - t.Helper() - labels := map[string]string{ - LabelManaged: "true", - LabelParentName: name, - LabelParentNamespace: ns, - LabelParentType: typ, - } - s, err := getSingleObject[corev1.Secret](context.Background(), client, "operator-ns", labels) - if err != nil { - t.Fatalf("finding secret for %q: %v", name, err) - } - if s == nil { - t.Fatalf("no secret found for %q %s %+#v", name, ns, labels) - } - return s.GetName(), strings.TrimSuffix(s.GetName(), "-0") -} - -func mustCreate(t *testing.T, client client.Client, obj client.Object) { - t.Helper() - if err := client.Create(context.Background(), obj); err != nil { - t.Fatalf("creating %q: %v", obj.GetName(), err) - } -} - -func mustUpdate[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) { - t.Helper() - obj := O(new(T)) - if err := client.Get(context.Background(), types.NamespacedName{ - Name: name, - Namespace: ns, - }, obj); err != nil { - t.Fatalf("getting %q: %v", name, err) - } - update(obj) - if err := client.Update(context.Background(), obj); err != nil { - t.Fatalf("updating %q: %v", name, err) - } -} - -func mustUpdateStatus[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) { - t.Helper() - obj := O(new(T)) - if err := client.Get(context.Background(), types.NamespacedName{ - Name: name, - Namespace: ns, - }, obj); err != nil { - t.Fatalf("getting %q: %v", name, err) - } - update(obj) - if err := client.Status().Update(context.Background(), obj); err != nil { - t.Fatalf("updating %q: %v", name, err) - } -} - -// expectEqual accepts a Kubernetes object and a Kubernetes client. It tests -// whether an object with equivalent contents can be retrieved by the passed -// client. If you want to NOT test some object fields for equality, use the -// modify func to ensure that they are removed from the cluster object and the -// object passed as 'want'. If no such modifications are needed, you can pass -// nil in place of the modify function. -func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O, modifier func(O)) { - t.Helper() - got := O(new(T)) - if err := client.Get(context.Background(), types.NamespacedName{ - Name: want.GetName(), - Namespace: want.GetNamespace(), - }, got); err != nil { - t.Fatalf("getting %q: %v", want.GetName(), err) - } - // The resource version changes eagerly whenever the operator does even a - // no-op update. Asserting a specific value leads to overly brittle tests, - // so just remove it from both got and want. - got.SetResourceVersion("") - want.SetResourceVersion("") - if modifier != nil { - modifier(want) - modifier(got) - } - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("unexpected %s (-got +want):\n%s", reflect.TypeOf(want).Elem().Name(), diff) - } -} - -func expectMissing[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string) { - t.Helper() - obj := O(new(T)) - if err := client.Get(context.Background(), types.NamespacedName{ - Name: name, - Namespace: ns, - }, obj); !apierrors.IsNotFound(err) { - t.Fatalf("%s %s/%s unexpectedly present, wanted missing", reflect.TypeOf(obj).Elem().Name(), ns, name) - } -} - -func expectReconciled(t *testing.T, sr reconcile.Reconciler, ns, name string) { - t.Helper() - req := reconcile.Request{ - NamespacedName: types.NamespacedName{ - Namespace: ns, - Name: name, - }, - } - res, err := sr.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile: unexpected error: %v", err) - } - if res.Requeue { - t.Fatalf("unexpected immediate requeue") - } - if res.RequeueAfter != 0 { - t.Fatalf("unexpected timed requeue (%v)", res.RequeueAfter) - } -} - -func expectRequeue(t *testing.T, sr reconcile.Reconciler, ns, name string) { - t.Helper() - req := reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: name, - Namespace: ns, - }, - } - res, err := sr.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("Reconcile: unexpected error: %v", err) - } - if res.RequeueAfter == 0 { - t.Fatalf("expected timed requeue, got success") - } -} - -// expectEvents accepts a test recorder and a list of events, tests that expected -// events are sent down the recorder's channel. Waits for 5s for each event. -func expectEvents(t *testing.T, rec *record.FakeRecorder, wantsEvents []string) { - t.Helper() - // Events are not expected to arrive in order. - seenEvents := make([]string, 0) - for range len(wantsEvents) { - timer := time.NewTimer(time.Second * 5) - defer timer.Stop() - select { - case gotEvent := <-rec.Events: - found := false - for _, wantEvent := range wantsEvents { - if wantEvent == gotEvent { - found = true - seenEvents = append(seenEvents, gotEvent) - break - } - } - if !found { - t.Errorf("got unexpected event %q, expected events: %+#v", gotEvent, wantsEvents) - } - case <-timer.C: - t.Errorf("timeout waiting for an event, wants events %#+v, got events %+#v", wantsEvents, seenEvents) - } - } -} - -type fakeTSClient struct { - sync.Mutex - keyRequests []tailscale.KeyCapabilities - deleted []string -} -type fakeTSNetServer struct { - certDomains []string -} - -func (f *fakeTSNetServer) CertDomains() []string { - return f.certDomains -} - -func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error) { - c.Lock() - defer c.Unlock() - c.keyRequests = append(c.keyRequests, caps) - k := &tailscale.Key{ - ID: "key", - Created: time.Now(), - Capabilities: caps, - } - return "secret-authkey", k, nil -} - -func (c *fakeTSClient) Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error) { - return &tailscale.Device{ - DeviceID: deviceID, - Hostname: "hostname-" + deviceID, - Addresses: []string{ - "1.2.3.4", - "::1", - }, - }, nil -} - -func (c *fakeTSClient) DeleteDevice(ctx context.Context, deviceID string) error { - c.Lock() - defer c.Unlock() - c.deleted = append(c.deleted, deviceID) - return nil -} - -func (c *fakeTSClient) KeyRequests() []tailscale.KeyCapabilities { - c.Lock() - defer c.Unlock() - return c.keyRequests -} - -func (c *fakeTSClient) Deleted() []string { - c.Lock() - defer c.Unlock() - return c.deleted -} - -// removeHashAnnotation can be used to remove declarative tailscaled config hash -// annotation from proxy StatefulSets to make the tests more maintainable (so -// that we don't have to change the annotation in each test case after any -// change to the configfile contents). -func removeHashAnnotation(sts *appsv1.StatefulSet) { - delete(sts.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash) -} - -func removeTargetPortsFromSvc(svc *corev1.Service) { - newPorts := make([]corev1.ServicePort, 0) - for _, p := range svc.Spec.Ports { - newPorts = append(newPorts, corev1.ServicePort{Protocol: p.Protocol, Port: p.Port}) - } - svc.Spec.Ports = newPorts -} - -func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) { - return func(secret *corev1.Secret) { - t.Helper() - if len(secret.StringData["cap-95.hujson"]) != 0 { - conf := &ipn.ConfigVAlpha{} - if err := json.Unmarshal([]byte(secret.StringData["cap-95.hujson"]), conf); err != nil { - t.Fatalf("error umarshalling 'cap-95.hujson' contents: %v", err) - } - conf.AuthKey = nil - b, err := json.Marshal(conf) - if err != nil { - t.Fatalf("error marshalling 'cap-95.huson' contents: %v", err) - } - mak.Set(&secret.StringData, "cap-95.hujson", string(b)) - } - if len(secret.StringData["cap-107.hujson"]) != 0 { - conf := &ipn.ConfigVAlpha{} - if err := json.Unmarshal([]byte(secret.StringData["cap-107.hujson"]), conf); err != nil { - t.Fatalf("error umarshalling 'cap-107.hujson' contents: %v", err) - } - conf.AuthKey = nil - b, err := json.Marshal(conf) - if err != nil { - t.Fatalf("error marshalling 'cap-107.huson' contents: %v", err) - } - mak.Set(&secret.StringData, "cap-107.hujson", string(b)) - } - } -} diff --git a/cmd/k8s-operator/tsrecorder.go b/cmd/k8s-operator/tsrecorder.go deleted file mode 100644 index cfe38c50af311..0000000000000 --- a/cmd/k8s-operator/tsrecorder.go +++ /dev/null @@ -1,397 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "slices" - "sync" - - "github.com/pkg/errors" - "go.uber.org/zap" - xslices "golang.org/x/exp/slices" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - apiequality "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "tailscale.com/client/tailscale" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/kube/kubetypes" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/util/clientmetric" - "tailscale.com/util/set" -) - -const ( - reasonRecorderCreationFailed = "RecorderCreationFailed" - reasonRecorderCreated = "RecorderCreated" - reasonRecorderInvalid = "RecorderInvalid" - - currentProfileKey = "_current-profile" -) - -var gaugeRecorderResources = clientmetric.NewGauge(kubetypes.MetricRecorderCount) - -// RecorderReconciler syncs Recorder statefulsets with their definition in -// Recorder CRs. -type RecorderReconciler struct { - client.Client - l *zap.SugaredLogger - recorder record.EventRecorder - clock tstime.Clock - tsNamespace string - tsClient tsClient - - mu sync.Mutex // protects following - recorders set.Slice[types.UID] // for recorders gauge -} - -func (r *RecorderReconciler) logger(name string) *zap.SugaredLogger { - return r.l.With("Recorder", name) -} - -func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) { - logger := r.logger(req.Name) - logger.Debugf("starting reconcile") - defer logger.Debugf("reconcile finished") - - tsr := new(tsapi.Recorder) - err = r.Get(ctx, req.NamespacedName, tsr) - if apierrors.IsNotFound(err) { - logger.Debugf("Recorder not found, assuming it was deleted") - return reconcile.Result{}, nil - } else if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com Recorder: %w", err) - } - if markedForDeletion(tsr) { - logger.Debugf("Recorder is being deleted, cleaning up resources") - ix := xslices.Index(tsr.Finalizers, FinalizerName) - if ix < 0 { - logger.Debugf("no finalizer, nothing to do") - return reconcile.Result{}, nil - } - - if done, err := r.maybeCleanup(ctx, tsr); err != nil { - return reconcile.Result{}, err - } else if !done { - logger.Debugf("Recorder resource cleanup not yet finished, will retry...") - return reconcile.Result{RequeueAfter: shortRequeue}, nil - } - - tsr.Finalizers = slices.Delete(tsr.Finalizers, ix, ix+1) - if err := r.Update(ctx, tsr); err != nil { - return reconcile.Result{}, err - } - return reconcile.Result{}, nil - } - - oldTSRStatus := tsr.Status.DeepCopy() - setStatusReady := func(tsr *tsapi.Recorder, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) { - tsoperator.SetRecorderCondition(tsr, tsapi.RecorderReady, status, reason, message, tsr.Generation, r.clock, logger) - if !apiequality.Semantic.DeepEqual(oldTSRStatus, tsr.Status) { - // An error encountered here should get returned by the Reconcile function. - if updateErr := r.Client.Status().Update(ctx, tsr); updateErr != nil { - err = errors.Wrap(err, updateErr.Error()) - } - } - return reconcile.Result{}, err - } - - if !slices.Contains(tsr.Finalizers, FinalizerName) { - // This log line is printed exactly once during initial provisioning, - // because once the finalizer is in place this block gets skipped. So, - // this is a nice place to log that the high level, multi-reconcile - // operation is underway. - logger.Infof("ensuring Recorder is set up") - tsr.Finalizers = append(tsr.Finalizers, FinalizerName) - if err := r.Update(ctx, tsr); err != nil { - logger.Errorf("error adding finalizer: %w", err) - return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderCreationFailed, reasonRecorderCreationFailed) - } - } - - if err := r.validate(tsr); err != nil { - logger.Errorf("error validating Recorder spec: %w", err) - message := fmt.Sprintf("Recorder is invalid: %s", err) - r.recorder.Eventf(tsr, corev1.EventTypeWarning, reasonRecorderInvalid, message) - return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderInvalid, message) - } - - if err = r.maybeProvision(ctx, tsr); err != nil { - logger.Errorf("error creating Recorder resources: %w", err) - message := fmt.Sprintf("failed creating Recorder: %s", err) - r.recorder.Eventf(tsr, corev1.EventTypeWarning, reasonRecorderCreationFailed, message) - return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderCreationFailed, message) - } - - logger.Info("Recorder resources synced") - return setStatusReady(tsr, metav1.ConditionTrue, reasonRecorderCreated, reasonRecorderCreated) -} - -func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsr *tsapi.Recorder) error { - logger := r.logger(tsr.Name) - - r.mu.Lock() - r.recorders.Add(tsr.UID) - gaugeRecorderResources.Set(int64(r.recorders.Len())) - r.mu.Unlock() - - if err := r.ensureAuthSecretCreated(ctx, tsr); err != nil { - return fmt.Errorf("error creating secrets: %w", err) - } - // State secret is precreated so we can use the Recorder CR as its owner ref. - sec := tsrStateSecret(tsr, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) { - s.ObjectMeta.Labels = sec.ObjectMeta.Labels - s.ObjectMeta.Annotations = sec.ObjectMeta.Annotations - s.ObjectMeta.OwnerReferences = sec.ObjectMeta.OwnerReferences - }); err != nil { - return fmt.Errorf("error creating state Secret: %w", err) - } - sa := tsrServiceAccount(tsr, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sa, func(s *corev1.ServiceAccount) { - s.ObjectMeta.Labels = sa.ObjectMeta.Labels - s.ObjectMeta.Annotations = sa.ObjectMeta.Annotations - s.ObjectMeta.OwnerReferences = sa.ObjectMeta.OwnerReferences - }); err != nil { - return fmt.Errorf("error creating ServiceAccount: %w", err) - } - role := tsrRole(tsr, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, func(r *rbacv1.Role) { - r.ObjectMeta.Labels = role.ObjectMeta.Labels - r.ObjectMeta.Annotations = role.ObjectMeta.Annotations - r.ObjectMeta.OwnerReferences = role.ObjectMeta.OwnerReferences - r.Rules = role.Rules - }); err != nil { - return fmt.Errorf("error creating Role: %w", err) - } - roleBinding := tsrRoleBinding(tsr, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, roleBinding, func(r *rbacv1.RoleBinding) { - r.ObjectMeta.Labels = roleBinding.ObjectMeta.Labels - r.ObjectMeta.Annotations = roleBinding.ObjectMeta.Annotations - r.ObjectMeta.OwnerReferences = roleBinding.ObjectMeta.OwnerReferences - r.RoleRef = roleBinding.RoleRef - r.Subjects = roleBinding.Subjects - }); err != nil { - return fmt.Errorf("error creating RoleBinding: %w", err) - } - ss := tsrStatefulSet(tsr, r.tsNamespace) - if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, ss, func(s *appsv1.StatefulSet) { - s.ObjectMeta.Labels = ss.ObjectMeta.Labels - s.ObjectMeta.Annotations = ss.ObjectMeta.Annotations - s.ObjectMeta.OwnerReferences = ss.ObjectMeta.OwnerReferences - s.Spec = ss.Spec - }); err != nil { - return fmt.Errorf("error creating StatefulSet: %w", err) - } - - var devices []tsapi.RecorderTailnetDevice - - device, ok, err := r.getDeviceInfo(ctx, tsr.Name) - if err != nil { - return fmt.Errorf("failed to get device info: %w", err) - } - if !ok { - logger.Debugf("no Tailscale hostname known yet, waiting for Recorder pod to finish auth") - return nil - } - - devices = append(devices, device) - - tsr.Status.Devices = devices - - return nil -} - -// maybeCleanup just deletes the device from the tailnet. All the kubernetes -// resources linked to a Recorder will get cleaned up via owner references -// (which we can use because they are all in the same namespace). -func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Recorder) (bool, error) { - logger := r.logger(tsr.Name) - - id, _, ok, err := r.getNodeMetadata(ctx, tsr.Name) - if err != nil { - return false, err - } - if !ok { - logger.Debugf("state Secret %s-0 not found or does not contain node ID, continuing cleanup", tsr.Name) - r.mu.Lock() - r.recorders.Remove(tsr.UID) - gaugeRecorderResources.Set(int64(r.recorders.Len())) - r.mu.Unlock() - return true, nil - } - - logger.Debugf("deleting device %s from control", string(id)) - if err := r.tsClient.DeleteDevice(ctx, string(id)); err != nil { - errResp := &tailscale.ErrResponse{} - if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound { - logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id)) - } else { - return false, fmt.Errorf("error deleting device: %w", err) - } - } else { - logger.Debugf("device %s deleted from control", string(id)) - } - - // Unlike most log entries in the reconcile loop, this will get printed - // exactly once at the very end of cleanup, because the final step of - // cleanup removes the tailscale finalizer, which will make all future - // reconciles exit early. - logger.Infof("cleaned up Recorder resources") - r.mu.Lock() - r.recorders.Remove(tsr.UID) - gaugeRecorderResources.Set(int64(r.recorders.Len())) - r.mu.Unlock() - return true, nil -} - -func (r *RecorderReconciler) ensureAuthSecretCreated(ctx context.Context, tsr *tsapi.Recorder) error { - logger := r.logger(tsr.Name) - key := types.NamespacedName{ - Namespace: r.tsNamespace, - Name: tsr.Name, - } - if err := r.Get(ctx, key, &corev1.Secret{}); err == nil { - // No updates, already created the auth key. - logger.Debugf("auth Secret %s already exists", key.Name) - return nil - } else if !apierrors.IsNotFound(err) { - return err - } - - // Create the auth key Secret which is going to be used by the StatefulSet - // to authenticate with Tailscale. - logger.Debugf("creating authkey for new Recorder") - tags := tsr.Spec.Tags - if len(tags) == 0 { - tags = tsapi.Tags{"tag:k8s"} - } - authKey, err := newAuthKey(ctx, r.tsClient, tags.Stringify()) - if err != nil { - return err - } - - logger.Debug("creating a new Secret for the Recorder") - if err := r.Create(ctx, tsrAuthSecret(tsr, r.tsNamespace, authKey)); err != nil { - return err - } - - return nil -} - -func (r *RecorderReconciler) validate(tsr *tsapi.Recorder) error { - if !tsr.Spec.EnableUI && tsr.Spec.Storage.S3 == nil { - return errors.New("must either enable UI or use S3 storage to ensure recordings are accessible") - } - - return nil -} - -func (r *RecorderReconciler) getStateSecret(ctx context.Context, tsrName string) (*corev1.Secret, error) { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: r.tsNamespace, - Name: fmt.Sprintf("%s-0", tsrName), - }, - } - if err := r.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { - if apierrors.IsNotFound(err) { - return nil, nil - } - - return nil, fmt.Errorf("error getting state Secret: %w", err) - } - - return secret, nil -} - -func (r *RecorderReconciler) getNodeMetadata(ctx context.Context, tsrName string) (id tailcfg.StableNodeID, dnsName string, ok bool, err error) { - secret, err := r.getStateSecret(ctx, tsrName) - if err != nil || secret == nil { - return "", "", false, err - } - - return getNodeMetadata(ctx, secret) -} - -// getNodeMetadata returns 'ok == true' iff the node ID is found. The dnsName -// is expected to always be non-empty if the node ID is, but not required. -func getNodeMetadata(ctx context.Context, secret *corev1.Secret) (id tailcfg.StableNodeID, dnsName string, ok bool, err error) { - // TODO(tomhjp): Should maybe use ipn to parse the following info instead. - currentProfile, ok := secret.Data[currentProfileKey] - if !ok { - return "", "", false, nil - } - profileBytes, ok := secret.Data[string(currentProfile)] - if !ok { - return "", "", false, nil - } - var profile profile - if err := json.Unmarshal(profileBytes, &profile); err != nil { - return "", "", false, fmt.Errorf("failed to extract node profile info from state Secret %s: %w", secret.Name, err) - } - - ok = profile.Config.NodeID != "" - return tailcfg.StableNodeID(profile.Config.NodeID), profile.Config.UserProfile.LoginName, ok, nil -} - -func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string) (d tsapi.RecorderTailnetDevice, ok bool, err error) { - secret, err := r.getStateSecret(ctx, tsrName) - if err != nil || secret == nil { - return tsapi.RecorderTailnetDevice{}, false, err - } - - return getDeviceInfo(ctx, r.tsClient, secret) -} - -func getDeviceInfo(ctx context.Context, tsClient tsClient, secret *corev1.Secret) (d tsapi.RecorderTailnetDevice, ok bool, err error) { - nodeID, dnsName, ok, err := getNodeMetadata(ctx, secret) - if !ok || err != nil { - return tsapi.RecorderTailnetDevice{}, false, err - } - - // TODO(tomhjp): The profile info doesn't include addresses, which is why we - // need the API. Should we instead update the profile to include addresses? - device, err := tsClient.Device(ctx, string(nodeID), nil) - if err != nil { - return tsapi.RecorderTailnetDevice{}, false, fmt.Errorf("failed to get device info from API: %w", err) - } - - d = tsapi.RecorderTailnetDevice{ - Hostname: device.Hostname, - TailnetIPs: device.Addresses, - } - if dnsName != "" { - d.URL = fmt.Sprintf("https://%s", dnsName) - } - - return d, true, nil -} - -type profile struct { - Config struct { - NodeID string `json:"NodeID"` - UserProfile struct { - LoginName string `json:"LoginName"` - } `json:"UserProfile"` - } `json:"Config"` -} - -func markedForDeletion(obj metav1.Object) bool { - return !obj.GetDeletionTimestamp().IsZero() -} diff --git a/cmd/k8s-operator/tsrecorder_specs.go b/cmd/k8s-operator/tsrecorder_specs.go deleted file mode 100644 index 4a7bf988773a6..0000000000000 --- a/cmd/k8s-operator/tsrecorder_specs.go +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "fmt" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/types/ptr" - "tailscale.com/version" -) - -func tsrStatefulSet(tsr *tsapi.Recorder, namespace string) *appsv1.StatefulSet { - return &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: tsr.Name, - Namespace: namespace, - Labels: labels("recorder", tsr.Name, tsr.Spec.StatefulSet.Labels), - OwnerReferences: tsrOwnerReference(tsr), - Annotations: tsr.Spec.StatefulSet.Annotations, - }, - Spec: appsv1.StatefulSetSpec{ - Replicas: ptr.To[int32](1), - Selector: &metav1.LabelSelector{ - MatchLabels: labels("recorder", tsr.Name, tsr.Spec.StatefulSet.Pod.Labels), - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Name: tsr.Name, - Namespace: namespace, - Labels: labels("recorder", tsr.Name, tsr.Spec.StatefulSet.Pod.Labels), - Annotations: tsr.Spec.StatefulSet.Pod.Annotations, - }, - Spec: corev1.PodSpec{ - ServiceAccountName: tsr.Name, - Affinity: tsr.Spec.StatefulSet.Pod.Affinity, - SecurityContext: tsr.Spec.StatefulSet.Pod.SecurityContext, - ImagePullSecrets: tsr.Spec.StatefulSet.Pod.ImagePullSecrets, - NodeSelector: tsr.Spec.StatefulSet.Pod.NodeSelector, - Tolerations: tsr.Spec.StatefulSet.Pod.Tolerations, - Containers: []corev1.Container{ - { - Name: "recorder", - Image: func() string { - image := tsr.Spec.StatefulSet.Pod.Container.Image - if image == "" { - image = fmt.Sprintf("tailscale/tsrecorder:%s", selfVersionImageTag()) - } - - return image - }(), - ImagePullPolicy: tsr.Spec.StatefulSet.Pod.Container.ImagePullPolicy, - Resources: tsr.Spec.StatefulSet.Pod.Container.Resources, - SecurityContext: tsr.Spec.StatefulSet.Pod.Container.SecurityContext, - Env: env(tsr), - EnvFrom: func() []corev1.EnvFromSource { - if tsr.Spec.Storage.S3 == nil || tsr.Spec.Storage.S3.Credentials.Secret.Name == "" { - return nil - } - - return []corev1.EnvFromSource{{ - SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: tsr.Spec.Storage.S3.Credentials.Secret.Name, - }, - }, - }} - }(), - Command: []string{"/tsrecorder"}, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "data", - MountPath: "/data", - ReadOnly: false, - }, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: "data", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }, - }, - }, - }, - }, - } -} - -func tsrServiceAccount(tsr *tsapi.Recorder, namespace string) *corev1.ServiceAccount { - return &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: tsr.Name, - Namespace: namespace, - Labels: labels("recorder", tsr.Name, nil), - OwnerReferences: tsrOwnerReference(tsr), - }, - } -} - -func tsrRole(tsr *tsapi.Recorder, namespace string) *rbacv1.Role { - return &rbacv1.Role{ - ObjectMeta: metav1.ObjectMeta{ - Name: tsr.Name, - Namespace: namespace, - Labels: labels("recorder", tsr.Name, nil), - OwnerReferences: tsrOwnerReference(tsr), - }, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{ - "get", - "patch", - "update", - }, - ResourceNames: []string{ - tsr.Name, // Contains the auth key. - fmt.Sprintf("%s-0", tsr.Name), // Contains the node state. - }, - }, - { - APIGroups: []string{""}, - Resources: []string{"events"}, - Verbs: []string{ - "get", - "create", - "patch", - }, - }, - }, - } -} - -func tsrRoleBinding(tsr *tsapi.Recorder, namespace string) *rbacv1.RoleBinding { - return &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: tsr.Name, - Namespace: namespace, - Labels: labels("recorder", tsr.Name, nil), - OwnerReferences: tsrOwnerReference(tsr), - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: tsr.Name, - Namespace: namespace, - }, - }, - RoleRef: rbacv1.RoleRef{ - Kind: "Role", - Name: tsr.Name, - }, - } -} - -func tsrAuthSecret(tsr *tsapi.Recorder, namespace string, authKey string) *corev1.Secret { - return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: tsr.Name, - Labels: labels("recorder", tsr.Name, nil), - OwnerReferences: tsrOwnerReference(tsr), - }, - StringData: map[string]string{ - "authkey": authKey, - }, - } -} - -func tsrStateSecret(tsr *tsapi.Recorder, namespace string) *corev1.Secret { - return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-0", tsr.Name), - Namespace: namespace, - Labels: labels("recorder", tsr.Name, nil), - OwnerReferences: tsrOwnerReference(tsr), - }, - } -} - -func env(tsr *tsapi.Recorder) []corev1.EnvVar { - envs := []corev1.EnvVar{ - { - Name: "TS_AUTHKEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: tsr.Name, - }, - Key: "authkey", - }, - }, - }, - { - Name: "POD_NAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - // Secret is named after the pod. - FieldPath: "metadata.name", - }, - }, - }, - { - Name: "POD_UID", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - FieldPath: "metadata.uid", - }, - }, - }, - { - Name: "TS_STATE", - Value: "kube:$(POD_NAME)", - }, - { - Name: "TSRECORDER_HOSTNAME", - Value: "$(POD_NAME)", - }, - } - - for _, env := range tsr.Spec.StatefulSet.Pod.Container.Env { - envs = append(envs, corev1.EnvVar{ - Name: string(env.Name), - Value: env.Value, - }) - } - - if tsr.Spec.Storage.S3 != nil { - envs = append(envs, - corev1.EnvVar{ - Name: "TSRECORDER_DST", - Value: fmt.Sprintf("s3://%s", tsr.Spec.Storage.S3.Endpoint), - }, - corev1.EnvVar{ - Name: "TSRECORDER_BUCKET", - Value: tsr.Spec.Storage.S3.Bucket, - }, - ) - } else { - envs = append(envs, corev1.EnvVar{ - Name: "TSRECORDER_DST", - Value: "/data/recordings", - }) - } - - if tsr.Spec.EnableUI { - envs = append(envs, corev1.EnvVar{ - Name: "TSRECORDER_UI", - Value: "true", - }) - } - - return envs -} - -func labels(app, instance string, customLabels map[string]string) map[string]string { - l := make(map[string]string, len(customLabels)+3) - for k, v := range customLabels { - l[k] = v - } - - // ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ - l["app.kubernetes.io/name"] = app - l["app.kubernetes.io/instance"] = instance - l["app.kubernetes.io/managed-by"] = "tailscale-operator" - - return l -} - -func tsrOwnerReference(owner metav1.Object) []metav1.OwnerReference { - return []metav1.OwnerReference{*metav1.NewControllerRef(owner, tsapi.SchemeGroupVersion.WithKind("Recorder"))} -} - -// selfVersionImageTag returns the container image tag of the running operator -// build. -func selfVersionImageTag() string { - meta := version.GetMeta() - var versionPrefix string - if meta.UnstableBranch { - versionPrefix = "unstable-" - } - return fmt.Sprintf("%sv%s", versionPrefix, meta.MajorMinorPatch) -} diff --git a/cmd/k8s-operator/tsrecorder_specs_test.go b/cmd/k8s-operator/tsrecorder_specs_test.go deleted file mode 100644 index 94a8a816c69f5..0000000000000 --- a/cmd/k8s-operator/tsrecorder_specs_test.go +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/types/ptr" -) - -func TestRecorderSpecs(t *testing.T) { - t.Run("ensure spec fields are passed through correctly", func(t *testing.T) { - tsr := &tsapi.Recorder{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - }, - Spec: tsapi.RecorderSpec{ - StatefulSet: tsapi.RecorderStatefulSet{ - Labels: map[string]string{ - "ss-label-key": "ss-label-value", - }, - Annotations: map[string]string{ - "ss-annotation-key": "ss-annotation-value", - }, - Pod: tsapi.RecorderPod{ - Labels: map[string]string{ - "pod-label-key": "pod-label-value", - }, - Annotations: map[string]string{ - "pod-annotation-key": "pod-annotation-value", - }, - Affinity: &corev1.Affinity{ - PodAffinity: &corev1.PodAffinity{ - RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{{ - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "match-label": "match-value", - }, - }}, - }, - }, - }, - SecurityContext: &corev1.PodSecurityContext{ - RunAsUser: ptr.To[int64](1000), - }, - ImagePullSecrets: []corev1.LocalObjectReference{{ - Name: "img-pull", - }}, - NodeSelector: map[string]string{ - "some-node": "selector", - }, - Tolerations: []corev1.Toleration{{ - Key: "key", - Value: "value", - TolerationSeconds: ptr.To[int64](60), - }}, - Container: tsapi.RecorderContainer{ - Env: []tsapi.Env{{ - Name: "some_env", - Value: "env_value", - }}, - Image: "custom-image", - ImagePullPolicy: corev1.PullAlways, - SecurityContext: &corev1.SecurityContext{ - Capabilities: &corev1.Capabilities{ - Add: []corev1.Capability{ - "NET_ADMIN", - }, - }, - }, - Resources: corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("100m"), - }, - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("50m"), - }, - }, - }, - }, - }, - }, - } - - ss := tsrStatefulSet(tsr, tsNamespace) - - // StatefulSet-level. - if diff := cmp.Diff(ss.Annotations, tsr.Spec.StatefulSet.Annotations); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Annotations, tsr.Spec.StatefulSet.Pod.Annotations); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - - // Pod-level. - if diff := cmp.Diff(ss.Labels, labels("recorder", "test", tsr.Spec.StatefulSet.Labels)); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Labels, labels("recorder", "test", tsr.Spec.StatefulSet.Pod.Labels)); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.Affinity, tsr.Spec.StatefulSet.Pod.Affinity); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.SecurityContext, tsr.Spec.StatefulSet.Pod.SecurityContext); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.ImagePullSecrets, tsr.Spec.StatefulSet.Pod.ImagePullSecrets); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.NodeSelector, tsr.Spec.StatefulSet.Pod.NodeSelector); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.Tolerations, tsr.Spec.StatefulSet.Pod.Tolerations); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - - // Container-level. - if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].Env, env(tsr)); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].Image, tsr.Spec.StatefulSet.Pod.Container.Image); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].ImagePullPolicy, tsr.Spec.StatefulSet.Pod.Container.ImagePullPolicy); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].SecurityContext, tsr.Spec.StatefulSet.Pod.Container.SecurityContext); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - if diff := cmp.Diff(ss.Spec.Template.Spec.Containers[0].Resources, tsr.Spec.StatefulSet.Pod.Container.Resources); diff != "" { - t.Errorf("(-got +want):\n%s", diff) - } - }) -} diff --git a/cmd/k8s-operator/tsrecorder_test.go b/cmd/k8s-operator/tsrecorder_test.go deleted file mode 100644 index bd73e8fb9ec26..0000000000000 --- a/cmd/k8s-operator/tsrecorder_test.go +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "context" - "encoding/json" - "testing" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/record" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - tsoperator "tailscale.com/k8s-operator" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstest" -) - -const tsNamespace = "tailscale" - -func TestRecorder(t *testing.T) { - tsr := &tsapi.Recorder{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Finalizers: []string{"tailscale.com/finalizer"}, - }, - } - - fc := fake.NewClientBuilder(). - WithScheme(tsapi.GlobalScheme). - WithObjects(tsr). - WithStatusSubresource(tsr). - Build() - tsClient := &fakeTSClient{} - zl, _ := zap.NewDevelopment() - fr := record.NewFakeRecorder(1) - cl := tstest.NewClock(tstest.ClockOpts{}) - reconciler := &RecorderReconciler{ - tsNamespace: tsNamespace, - Client: fc, - tsClient: tsClient, - recorder: fr, - l: zl.Sugar(), - clock: cl, - } - - t.Run("invalid spec gives an error condition", func(t *testing.T) { - expectReconciled(t, reconciler, "", tsr.Name) - - msg := "Recorder is invalid: must either enable UI or use S3 storage to ensure recordings are accessible" - tsoperator.SetRecorderCondition(tsr, tsapi.RecorderReady, metav1.ConditionFalse, reasonRecorderInvalid, msg, 0, cl, zl.Sugar()) - expectEqual(t, fc, tsr, nil) - if expected := 0; reconciler.recorders.Len() != expected { - t.Fatalf("expected %d recorders, got %d", expected, reconciler.recorders.Len()) - } - expectRecorderResources(t, fc, tsr, false) - - expectedEvent := "Warning RecorderInvalid Recorder is invalid: must either enable UI or use S3 storage to ensure recordings are accessible" - expectEvents(t, fr, []string{expectedEvent}) - }) - - t.Run("observe Ready=true status condition for a valid spec", func(t *testing.T) { - tsr.Spec.EnableUI = true - mustUpdate(t, fc, "", "test", func(t *tsapi.Recorder) { - t.Spec = tsr.Spec - }) - - expectReconciled(t, reconciler, "", tsr.Name) - - tsoperator.SetRecorderCondition(tsr, tsapi.RecorderReady, metav1.ConditionTrue, reasonRecorderCreated, reasonRecorderCreated, 0, cl, zl.Sugar()) - expectEqual(t, fc, tsr, nil) - if expected := 1; reconciler.recorders.Len() != expected { - t.Fatalf("expected %d recorders, got %d", expected, reconciler.recorders.Len()) - } - expectRecorderResources(t, fc, tsr, true) - }) - - t.Run("populate node info in state secret, and see it appear in status", func(t *testing.T) { - bytes, err := json.Marshal(map[string]any{ - "Config": map[string]any{ - "NodeID": "nodeid-123", - "UserProfile": map[string]any{ - "LoginName": "test-0.example.ts.net", - }, - }, - }) - if err != nil { - t.Fatal(err) - } - - const key = "profile-abc" - mustUpdate(t, fc, tsNamespace, "test-0", func(s *corev1.Secret) { - s.Data = map[string][]byte{ - currentProfileKey: []byte(key), - key: bytes, - } - }) - - expectReconciled(t, reconciler, "", tsr.Name) - tsr.Status.Devices = []tsapi.RecorderTailnetDevice{ - { - Hostname: "hostname-nodeid-123", - TailnetIPs: []string{"1.2.3.4", "::1"}, - URL: "https://test-0.example.ts.net", - }, - } - expectEqual(t, fc, tsr, nil) - }) - - t.Run("delete the Recorder and observe cleanup", func(t *testing.T) { - if err := fc.Delete(context.Background(), tsr); err != nil { - t.Fatal(err) - } - - expectReconciled(t, reconciler, "", tsr.Name) - - expectMissing[tsapi.Recorder](t, fc, "", tsr.Name) - if expected := 0; reconciler.recorders.Len() != expected { - t.Fatalf("expected %d recorders, got %d", expected, reconciler.recorders.Len()) - } - if diff := cmp.Diff(tsClient.deleted, []string{"nodeid-123"}); diff != "" { - t.Fatalf("unexpected deleted devices (-got +want):\n%s", diff) - } - // The fake client does not clean up objects whose owner has been - // deleted, so we can't test for the owned resources getting deleted. - }) -} - -func expectRecorderResources(t *testing.T, fc client.WithWatch, tsr *tsapi.Recorder, shouldExist bool) { - t.Helper() - - auth := tsrAuthSecret(tsr, tsNamespace, "secret-authkey") - state := tsrStateSecret(tsr, tsNamespace) - role := tsrRole(tsr, tsNamespace) - roleBinding := tsrRoleBinding(tsr, tsNamespace) - serviceAccount := tsrServiceAccount(tsr, tsNamespace) - statefulSet := tsrStatefulSet(tsr, tsNamespace) - - if shouldExist { - expectEqual(t, fc, auth, nil) - expectEqual(t, fc, state, nil) - expectEqual(t, fc, role, nil) - expectEqual(t, fc, roleBinding, nil) - expectEqual(t, fc, serviceAccount, nil) - expectEqual(t, fc, statefulSet, nil) - } else { - expectMissing[corev1.Secret](t, fc, auth.Namespace, auth.Name) - expectMissing[corev1.Secret](t, fc, state.Namespace, state.Name) - expectMissing[rbacv1.Role](t, fc, role.Namespace, role.Name) - expectMissing[rbacv1.RoleBinding](t, fc, roleBinding.Namespace, roleBinding.Name) - expectMissing[corev1.ServiceAccount](t, fc, serviceAccount.Namespace, serviceAccount.Name) - expectMissing[appsv1.StatefulSet](t, fc, statefulSet.Namespace, statefulSet.Name) - } -} diff --git a/cmd/mkmanifest/main.go b/cmd/mkmanifest/main.go deleted file mode 100644 index fb3c729f12d21..0000000000000 --- a/cmd/mkmanifest/main.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The mkmanifest command is a simple helper utility to create a '.syso' file -// that contains a Windows manifest file. -package main - -import ( - "log" - "os" - - "github.com/tc-hib/winres" -) - -func main() { - if len(os.Args) != 4 { - log.Fatalf("usage: %s arch manifest.xml output.syso", os.Args[0]) - } - - arch := winres.Arch(os.Args[1]) - switch arch { - case winres.ArchAMD64, winres.ArchARM64, winres.ArchI386: - default: - log.Fatalf("unsupported arch: %s", arch) - } - - manifest, err := os.ReadFile(os.Args[2]) - if err != nil { - log.Fatalf("error reading manifest file %q: %v", os.Args[2], err) - } - - out := os.Args[3] - - // Start by creating an empty resource set - rs := winres.ResourceSet{} - - // Add resources - rs.Set(winres.RT_MANIFEST, winres.ID(1), 0, manifest) - - // Compile to a COFF object file - f, err := os.Create(out) - if err != nil { - log.Fatalf("error creating output file %q: %v", out, err) - } - if err := rs.WriteObject(f, arch); err != nil { - log.Fatalf("error writing object: %v", err) - } - if err := f.Close(); err != nil { - log.Fatalf("error writing output file %q: %v", out, err) - } -} diff --git a/cmd/mkpkg/main.go b/cmd/mkpkg/main.go deleted file mode 100644 index 5e26b07f8f9f8..0000000000000 --- a/cmd/mkpkg/main.go +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// mkpkg builds the Tailscale rpm and deb packages. -package main - -import ( - "flag" - "fmt" - "log" - "os" - "strings" - - "github.com/goreleaser/nfpm/v2" - _ "github.com/goreleaser/nfpm/v2/deb" - "github.com/goreleaser/nfpm/v2/files" - _ "github.com/goreleaser/nfpm/v2/rpm" -) - -// parseFiles parses a comma-separated list of colon-separated pairs -// into files.Contents format. -func parseFiles(s string, typ string) (files.Contents, error) { - if len(s) == 0 { - return nil, nil - } - var contents files.Contents - for _, f := range strings.Split(s, ",") { - fs := strings.Split(f, ":") - if len(fs) != 2 { - return nil, fmt.Errorf("unparseable file field %q", f) - } - contents = append(contents, &files.Content{Type: files.TypeFile, Source: fs[0], Destination: fs[1]}) - } - return contents, nil -} - -func parseEmptyDirs(s string) files.Contents { - // strings.Split("", ",") would return []string{""}, which is not suitable: - // this would create an empty dir record with path "", breaking the package - if s == "" { - return nil - } - var contents files.Contents - for _, d := range strings.Split(s, ",") { - contents = append(contents, &files.Content{Type: files.TypeDir, Destination: d}) - } - return contents -} - -func main() { - out := flag.String("out", "", "output file to write") - name := flag.String("name", "tailscale", "package name") - description := flag.String("description", "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", "package description") - goarch := flag.String("arch", "amd64", "GOARCH this package is for") - pkgType := flag.String("type", "deb", "type of package to build (deb or rpm)") - regularFiles := flag.String("files", "", "comma-separated list of files in src:dst form") - configFiles := flag.String("configs", "", "like --files, but for files marked as user-editable config files") - emptyDirs := flag.String("emptydirs", "", "comma-separated list of empty directories") - version := flag.String("version", "0.0.0", "version of the package") - postinst := flag.String("postinst", "", "debian postinst script path") - prerm := flag.String("prerm", "", "debian prerm script path") - postrm := flag.String("postrm", "", "debian postrm script path") - replaces := flag.String("replaces", "", "package which this package replaces, if any") - depends := flag.String("depends", "", "comma-separated list of packages this package depends on") - recommends := flag.String("recommends", "", "comma-separated list of packages this package recommends") - flag.Parse() - - filesList, err := parseFiles(*regularFiles, files.TypeFile) - if err != nil { - log.Fatalf("Parsing --files: %v", err) - } - configsList, err := parseFiles(*configFiles, files.TypeConfig) - if err != nil { - log.Fatalf("Parsing --configs: %v", err) - } - emptyDirList := parseEmptyDirs(*emptyDirs) - contents := append(filesList, append(configsList, emptyDirList...)...) - contents, err = files.PrepareForPackager(contents, 0, *pkgType, false) - if err != nil { - log.Fatalf("Building package contents: %v", err) - } - info := nfpm.WithDefaults(&nfpm.Info{ - Name: *name, - Arch: *goarch, - Platform: "linux", - Version: *version, - Maintainer: "Tailscale Inc ", - Description: *description, - Homepage: "https://www.tailscale.com", - License: "MIT", - Overridables: nfpm.Overridables{ - Contents: contents, - Scripts: nfpm.Scripts{ - PostInstall: *postinst, - PreRemove: *prerm, - PostRemove: *postrm, - }, - }, - }) - - if len(*depends) != 0 { - info.Overridables.Depends = strings.Split(*depends, ",") - } - if len(*recommends) != 0 { - info.Overridables.Recommends = strings.Split(*recommends, ",") - } - if *replaces != "" { - info.Overridables.Replaces = []string{*replaces} - info.Overridables.Conflicts = []string{*replaces} - } - - switch *pkgType { - case "deb": - info.Section = "net" - info.Priority = "extra" - case "rpm": - info.Overridables.RPM.Group = "Network" - } - - pkg, err := nfpm.Get(*pkgType) - if err != nil { - log.Fatalf("Getting packager for %q: %v", *pkgType, err) - } - - f, err := os.Create(*out) - if err != nil { - log.Fatalf("Creating output file %q: %v", *out, err) - } - defer f.Close() - - if err := pkg.Package(info, f); err != nil { - log.Fatalf("Creating package %q: %v", *out, err) - } -} diff --git a/cmd/mkversion/mkversion.go b/cmd/mkversion/mkversion.go deleted file mode 100644 index c8c8bf17930f6..0000000000000 --- a/cmd/mkversion/mkversion.go +++ /dev/null @@ -1,44 +0,0 @@ -// 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/nardump/README.md b/cmd/nardump/README.md deleted file mode 100644 index 6fa7fc2f1d345..0000000000000 --- a/cmd/nardump/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# nardump - -nardump is like nix-store --dump, but in Go, writing a NAR file (tar-like, -but focused on being reproducible) to stdout or to a hash with the --sri flag. - -It lets us calculate the Nix sha256 in shell.nix without the person running -git-pull-oss.sh having Nix available. diff --git a/cmd/nardump/nardump.go b/cmd/nardump/nardump.go deleted file mode 100644 index 05be7b65a7e37..0000000000000 --- a/cmd/nardump/nardump.go +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// nardump is like nix-store --dump, but in Go, writing a NAR -// file (tar-like, but focused on being reproducible) to stdout -// or to a hash with the --sri flag. -// -// It lets us calculate a Nix sha256 without the person running -// git-pull-oss.sh having Nix available. -package main - -// For the format, see: -// See https://gist.github.com/jbeda/5c79d2b1434f0018d693 - -import ( - "bufio" - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "flag" - "fmt" - "io" - "io/fs" - "log" - "os" - "path" - "sort" -) - -var sri = flag.Bool("sri", false, "print SRI") - -func main() { - flag.Parse() - if flag.NArg() != 1 { - log.Fatal("usage: nardump ") - } - arg := flag.Arg(0) - if err := os.Chdir(arg); err != nil { - log.Fatal(err) - } - if *sri { - hash := sha256.New() - if err := writeNAR(hash, os.DirFS(".")); err != nil { - log.Fatal(err) - } - fmt.Printf("sha256-%s\n", base64.StdEncoding.EncodeToString(hash.Sum(nil))) - return - } - bw := bufio.NewWriter(os.Stdout) - if err := writeNAR(bw, os.DirFS(".")); err != nil { - log.Fatal(err) - } - bw.Flush() -} - -// writeNARError is a sentinel panic type that's recovered by writeNAR -// and converted into the wrapped error. -type writeNARError struct{ err error } - -// narWriter writes NAR files. -type narWriter struct { - w io.Writer - fs fs.FS -} - -// writeNAR writes a NAR file to w from the root of fs. -func writeNAR(w io.Writer, fs fs.FS) (err error) { - defer func() { - if e := recover(); e != nil { - if we, ok := e.(writeNARError); ok { - err = we.err - return - } - panic(e) - } - }() - nw := &narWriter{w: w, fs: fs} - nw.str("nix-archive-1") - return nw.writeDir(".") -} - -func (nw *narWriter) writeDir(dirPath string) error { - ents, err := fs.ReadDir(nw.fs, dirPath) - if err != nil { - return err - } - sort.Slice(ents, func(i, j int) bool { - return ents[i].Name() < ents[j].Name() - }) - nw.str("(") - nw.str("type") - nw.str("directory") - for _, ent := range ents { - nw.str("entry") - nw.str("(") - nw.str("name") - nw.str(ent.Name()) - nw.str("node") - mode := ent.Type() - sub := path.Join(dirPath, ent.Name()) - var err error - switch { - case mode.IsRegular(): - err = nw.writeRegular(sub) - case mode.IsDir(): - err = nw.writeDir(sub) - default: - // TODO(bradfitz): symlink, but requires fighting io/fs a bit - // to get at Readlink or the osFS via fs. But for now - // we don't need symlinks because they're not in Go's archive. - return fmt.Errorf("unsupported file type %v at %q", sub, mode) - } - if err != nil { - return err - } - nw.str(")") - } - nw.str(")") - return nil -} - -func (nw *narWriter) writeRegular(path string) error { - nw.str("(") - nw.str("type") - nw.str("regular") - fi, err := fs.Stat(nw.fs, path) - if err != nil { - return err - } - if fi.Mode()&0111 != 0 { - nw.str("executable") - nw.str("") - } - contents, err := fs.ReadFile(nw.fs, path) - if err != nil { - return err - } - nw.str("contents") - if err := writeBytes(nw.w, contents); err != nil { - return err - } - nw.str(")") - return nil -} - -func (nw *narWriter) str(s string) { - if err := writeString(nw.w, s); err != nil { - panic(writeNARError{err}) - } -} - -func writeString(w io.Writer, s string) error { - var buf [8]byte - binary.LittleEndian.PutUint64(buf[:], uint64(len(s))) - if _, err := w.Write(buf[:]); err != nil { - return err - } - if _, err := io.WriteString(w, s); err != nil { - return err - } - return writePad(w, len(s)) -} - -func writeBytes(w io.Writer, b []byte) error { - var buf [8]byte - binary.LittleEndian.PutUint64(buf[:], uint64(len(b))) - if _, err := w.Write(buf[:]); err != nil { - return err - } - if _, err := w.Write(b); err != nil { - return err - } - return writePad(w, len(b)) -} - -func writePad(w io.Writer, n int) error { - pad := n % 8 - if pad == 0 { - return nil - } - var zeroes [8]byte - _, err := w.Write(zeroes[:8-pad]) - return err -} diff --git a/cmd/natc/natc.go b/cmd/natc/natc.go deleted file mode 100644 index d94523c6e4161..0000000000000 --- a/cmd/natc/natc.go +++ /dev/null @@ -1,575 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The natc command is a work-in-progress implementation of a NAT based -// connector for Tailscale. It is intended to be used to route traffic to a -// specific domain through a specific node. -package main - -import ( - "context" - "encoding/binary" - "errors" - "flag" - "fmt" - "log" - "math/rand/v2" - "net" - "net/http" - "net/netip" - "os" - "strings" - "sync" - "time" - - "github.com/gaissmai/bart" - "github.com/inetaf/tcpproxy" - "github.com/peterbourgon/ff/v3" - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/client/tailscale" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/net/netutil" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" - "tailscale.com/tsweb" - "tailscale.com/util/dnsname" - "tailscale.com/util/mak" -) - -func main() { - hostinfo.SetApp("natc") - if !envknob.UseWIPCode() { - log.Fatal("cmd/natc is a work in progress and has not been security reviewed;\nits use requires TAILSCALE_USE_WIP_CODE=1 be set in the environment for now.") - } - - // Parse flags - fs := flag.NewFlagSet("natc", flag.ExitOnError) - var ( - debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint") - hostname = fs.String("hostname", "", "Hostname to register the service under") - siteID = fs.Uint("site-id", 1, "an integer site ID to use for the ULA prefix which allows for multiple proxies to act in a HA configuration") - v4PfxStr = fs.String("v4-pfx", "100.64.1.0/24", "comma-separated list of IPv4 prefixes to advertise") - verboseTSNet = fs.Bool("verbose-tsnet", false, "enable verbose logging in tsnet") - printULA = fs.Bool("print-ula", false, "print the ULA prefix and exit") - ignoreDstPfxStr = fs.String("ignore-destinations", "", "comma-separated list of prefixes to ignore") - wgPort = fs.Uint("wg-port", 0, "udp port for wireguard and peer to peer traffic") - ) - ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_NATC")) - - if *printULA { - fmt.Println(ula(uint16(*siteID))) - return - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if *siteID == 0 { - log.Fatalf("site-id must be set") - } else if *siteID > 0xffff { - log.Fatalf("site-id must be in the range [0, 65535]") - } - - var ignoreDstTable *bart.Table[bool] - for _, s := range strings.Split(*ignoreDstPfxStr, ",") { - s := strings.TrimSpace(s) - if s == "" { - continue - } - if ignoreDstTable == nil { - ignoreDstTable = &bart.Table[bool]{} - } - pfx, err := netip.ParsePrefix(s) - if err != nil { - log.Fatalf("unable to parse prefix: %v", err) - } - if pfx.Masked() != pfx { - log.Fatalf("prefix %v is not normalized (bits are set outside the mask)", pfx) - } - ignoreDstTable.Insert(pfx, true) - } - var v4Prefixes []netip.Prefix - for _, s := range strings.Split(*v4PfxStr, ",") { - p := netip.MustParsePrefix(strings.TrimSpace(s)) - if p.Masked() != p { - log.Fatalf("v4 prefix %v is not a masked prefix", p) - } - v4Prefixes = append(v4Prefixes, p) - } - if len(v4Prefixes) == 0 { - log.Fatalf("no v4 prefixes specified") - } - dnsAddr := v4Prefixes[0].Addr() - ts := &tsnet.Server{ - Hostname: *hostname, - } - if *wgPort != 0 { - if *wgPort >= 1<<16 { - log.Fatalf("wg-port must be in the range [0, 65535]") - } - ts.Port = uint16(*wgPort) - } - defer ts.Close() - if *verboseTSNet { - ts.Logf = log.Printf - } - - // Start special-purpose listeners: dns, http promotion, debug server - if *debugPort != 0 { - mux := http.NewServeMux() - tsweb.Debugger(mux) - dln, err := ts.Listen("tcp", fmt.Sprintf(":%d", *debugPort)) - if err != nil { - log.Fatalf("failed listening on debug port: %v", err) - } - defer dln.Close() - go func() { - log.Fatalf("debug serve: %v", http.Serve(dln, mux)) - }() - } - lc, err := ts.LocalClient() - if err != nil { - log.Fatalf("LocalClient() failed: %v", err) - } - if _, err := ts.Up(ctx); err != nil { - log.Fatalf("ts.Up: %v", err) - } - - c := &connector{ - ts: ts, - lc: lc, - dnsAddr: dnsAddr, - v4Ranges: v4Prefixes, - v6ULA: ula(uint16(*siteID)), - ignoreDsts: ignoreDstTable, - } - c.run(ctx) -} - -type connector struct { - // ts is the tsnet.Server used to host the connector. - ts *tsnet.Server - // lc is the LocalClient used to interact with the tsnet.Server hosting this - // connector. - lc *tailscale.LocalClient - - // dnsAddr is the IPv4 address to listen on for DNS requests. It is used to - // prevent the app connector from assigning it to a domain. - dnsAddr netip.Addr - - // v4Ranges is the list of IPv4 ranges to advertise and assign addresses from. - // These are masked prefixes. - v4Ranges []netip.Prefix - // v6ULA is the ULA prefix used by the app connector to assign IPv6 addresses. - v6ULA netip.Prefix - - perPeerMap syncs.Map[tailcfg.NodeID, *perPeerState] - - // ignoreDsts is initialized at start up with the contents of --ignore-destinations (if none it is nil) - // It is never mutated, only used for lookups. - // Users who want to natc a DNS wildcard but not every address record in that domain can supply the - // exceptions in --ignore-destinations. When we receive a dns request we will look up the fqdn - // and if any of the ip addresses in response to the lookup match any 'ignore destinations' prefix we will - // return a dns response that contains the ip addresses we discovered with the lookup (ie not the - // natc behavior, which would return a dummy ip address pointing at natc). - ignoreDsts *bart.Table[bool] -} - -// v6ULA is the ULA prefix used by the app connector to assign IPv6 addresses. -// The 8th and 9th bytes are used to encode the site ID which allows for -// multiple proxies to act in a HA configuration. -// mnemonic: a99c = appc -var v6ULA = netip.MustParsePrefix("fd7a:115c:a1e0:a99c::/64") - -func ula(siteID uint16) netip.Prefix { - as16 := v6ULA.Addr().As16() - as16[8] = byte(siteID >> 8) - as16[9] = byte(siteID) - return netip.PrefixFrom(netip.AddrFrom16(as16), 64+16) -} - -// run runs the connector. -// -// The passed in context is only used for the initial setup. The connector runs -// forever. -func (c *connector) run(ctx context.Context) { - if _, err := c.lc.EditPrefs(ctx, &ipn.MaskedPrefs{ - AdvertiseRoutesSet: true, - Prefs: ipn.Prefs{ - AdvertiseRoutes: append(c.v4Ranges, c.v6ULA), - }, - }); err != nil { - log.Fatalf("failed to advertise routes: %v", err) - } - c.ts.RegisterFallbackTCPHandler(c.handleTCPFlow) - c.serveDNS() -} - -func (c *connector) serveDNS() { - pc, err := c.ts.ListenPacket("udp", net.JoinHostPort(c.dnsAddr.String(), "53")) - if err != nil { - log.Fatalf("failed listening on port 53: %v", err) - } - defer pc.Close() - log.Printf("Listening for DNS on %s", pc.LocalAddr().String()) - for { - buf := make([]byte, 1500) - n, addr, err := pc.ReadFrom(buf) - if err != nil { - if errors.Is(err, net.ErrClosed) { - return - } - log.Printf("serveDNS.ReadFrom failed: %v", err) - continue - } - go c.handleDNS(pc, buf[:n], addr.(*net.UDPAddr)) - } -} - -func lookupDestinationIP(domain string) ([]netip.Addr, error) { - netIPs, err := net.LookupIP(domain) - if err != nil { - var dnsError *net.DNSError - if errors.As(err, &dnsError) && dnsError.IsNotFound { - return nil, nil - } else { - return nil, err - } - } - var addrs []netip.Addr - for _, ip := range netIPs { - a, ok := netip.AddrFromSlice(ip) - if ok { - addrs = append(addrs, a) - } - } - return addrs, nil -} - -// handleDNS handles a DNS request to the app connector. -// It generates a response based on the request and the node that sent it. -// -// Each node is assigned a unique pair of IP addresses for each domain it -// queries. This assignment is done lazily and is not persisted across restarts. -// A per-peer assignment allows the connector to reuse a limited number of IP -// addresses across multiple nodes and domains. It also allows for clear -// failover behavior when an app connector is restarted. -// -// This assignment later allows the connector to determine where to forward -// traffic based on the destination IP address. -func (c *connector) handleDNS(pc net.PacketConn, buf []byte, remoteAddr *net.UDPAddr) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - who, err := c.lc.WhoIs(ctx, remoteAddr.String()) - if err != nil { - log.Printf("HandleDNS: WhoIs failed: %v\n", err) - return - } - - var msg dnsmessage.Message - err = msg.Unpack(buf) - if err != nil { - log.Printf("HandleDNS: dnsmessage unpack failed: %v\n ", err) - return - } - - // If there are destination ips that we don't want to route, we - // have to do a dns lookup here to find the destination ip. - if c.ignoreDsts != nil { - if len(msg.Questions) > 0 { - q := msg.Questions[0] - switch q.Type { - case dnsmessage.TypeAAAA, dnsmessage.TypeA: - dstAddrs, err := lookupDestinationIP(q.Name.String()) - if err != nil { - log.Printf("HandleDNS: lookup destination failed: %v\n ", err) - return - } - if c.ignoreDestination(dstAddrs) { - bs, err := dnsResponse(&msg, dstAddrs) - // TODO (fran): treat as SERVFAIL - if err != nil { - log.Printf("HandleDNS: generate ignore response failed: %v\n", err) - return - } - _, err = pc.WriteTo(bs, remoteAddr) - if err != nil { - log.Printf("HandleDNS: write failed: %v\n", err) - } - return - } - } - } - } - // None of the destination IP addresses match an ignore destination prefix, do - // the natc thing. - - resp, err := c.generateDNSResponse(&msg, who.Node.ID) - // TODO (fran): treat as SERVFAIL - if err != nil { - log.Printf("HandleDNS: connector handling failed: %v\n", err) - return - } - // TODO (fran): treat as NXDOMAIN - if len(resp) == 0 { - return - } - // This connector handled the DNS request - _, err = pc.WriteTo(resp, remoteAddr) - if err != nil { - log.Printf("HandleDNS: write failed: %v\n", err) - } -} - -// tsMBox is the mailbox used in SOA records. -// The convention is to replace the @ symbol with a dot. -// So in this case, the mailbox is support.tailscale.com. with the trailing dot -// to indicate that it is a fully qualified domain name. -var tsMBox = dnsmessage.MustNewName("support.tailscale.com.") - -// generateDNSResponse generates a DNS response for the given request. The from -// argument is the NodeID of the node that sent the request. -func (c *connector) generateDNSResponse(req *dnsmessage.Message, from tailcfg.NodeID) ([]byte, error) { - pm, _ := c.perPeerMap.LoadOrStore(from, &perPeerState{c: c}) - var addrs []netip.Addr - if len(req.Questions) > 0 { - switch req.Questions[0].Type { - case dnsmessage.TypeAAAA, dnsmessage.TypeA: - var err error - addrs, err = pm.ipForDomain(req.Questions[0].Name.String()) - if err != nil { - return nil, err - } - } - } - return dnsResponse(req, addrs) -} - -// dnsResponse makes a DNS response for the natc. If the dnsmessage is requesting TypeAAAA -// or TypeA the provided addrs of the requested type will be used. -func dnsResponse(req *dnsmessage.Message, addrs []netip.Addr) ([]byte, error) { - b := dnsmessage.NewBuilder(nil, - dnsmessage.Header{ - ID: req.Header.ID, - Response: true, - Authoritative: true, - }) - b.EnableCompression() - - if len(req.Questions) == 0 { - return b.Finish() - } - q := req.Questions[0] - if err := b.StartQuestions(); err != nil { - return nil, err - } - if err := b.Question(q); err != nil { - return nil, err - } - if err := b.StartAnswers(); err != nil { - return nil, err - } - switch q.Type { - case dnsmessage.TypeAAAA, dnsmessage.TypeA: - want6 := q.Type == dnsmessage.TypeAAAA - for _, ip := range addrs { - if want6 != ip.Is6() { - continue - } - if want6 { - if err := b.AAAAResource( - dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 5}, - dnsmessage.AAAAResource{AAAA: ip.As16()}, - ); err != nil { - return nil, err - } - } else { - if err := b.AResource( - dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 5}, - dnsmessage.AResource{A: ip.As4()}, - ); err != nil { - return nil, err - } - } - } - case dnsmessage.TypeSOA: - if err := b.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}, - ); err != nil { - return nil, err - } - case dnsmessage.TypeNS: - if err := b.NSResource( - dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120}, - dnsmessage.NSResource{NS: tsMBox}, - ); err != nil { - return nil, err - } - } - return b.Finish() -} - -// handleTCPFlow handles a TCP flow from the given source to the given -// destination. It uses the source address to determine the node that sent the -// request and the destination address to determine the domain that the request -// is for based on the IP address assigned to the destination in the DNS -// response. -func (c *connector) handleTCPFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - who, err := c.lc.WhoIs(ctx, src.Addr().String()) - cancel() - if err != nil { - log.Printf("HandleTCPFlow: WhoIs failed: %v\n", err) - return nil, false - } - - from := who.Node.ID - ps, ok := c.perPeerMap.Load(from) - if !ok { - log.Printf("handleTCPFlow: no perPeerState for %v", from) - return nil, false - } - domain, ok := ps.domainForIP(dst.Addr()) - if !ok { - log.Printf("handleTCPFlow: no domain for IP %v\n", dst.Addr()) - return nil, false - } - return func(conn net.Conn) { - proxyTCPConn(conn, domain) - }, true -} - -// ignoreDestination reports whether any of the provided dstAddrs match the prefixes configured -// in --ignore-destinations -func (c *connector) ignoreDestination(dstAddrs []netip.Addr) bool { - for _, a := range dstAddrs { - if _, ok := c.ignoreDsts.Lookup(a); ok { - return true - } - } - return false -} - -func proxyTCPConn(c net.Conn, dest string) { - if c.RemoteAddr() == nil { - log.Printf("proxyTCPConn: nil RemoteAddr") - c.Close() - return - } - addrPortStr := c.LocalAddr().String() - _, port, err := net.SplitHostPort(addrPortStr) - if err != nil { - log.Printf("tcpRoundRobinHandler.Handle: bogus addrPort %q", addrPortStr) - c.Close() - return - } - - p := &tcpproxy.Proxy{ - ListenFunc: func(net, laddr string) (net.Listener, error) { - return netutil.NewOneConnListener(c, nil), nil - }, - } - p.AddRoute(addrPortStr, &tcpproxy.DialProxy{ - Addr: fmt.Sprintf("%s:%s", dest, port), - }) - p.Start() -} - -// perPeerState holds the state for a single peer. -type perPeerState struct { - c *connector - - mu sync.Mutex - domainToAddr map[string][]netip.Addr - addrToDomain *bart.Table[string] -} - -// domainForIP returns the domain name assigned to the given IP address and -// whether it was found. -func (ps *perPeerState) domainForIP(ip netip.Addr) (_ string, ok bool) { - ps.mu.Lock() - defer ps.mu.Unlock() - if ps.addrToDomain == nil { - return "", false - } - return ps.addrToDomain.Lookup(ip) -} - -// ipForDomain assigns a pair of unique IP addresses for the given domain and -// returns them. The first address is an IPv4 address and the second is an IPv6 -// address. If the domain already has assigned addresses, it returns them. -func (ps *perPeerState) ipForDomain(domain string) ([]netip.Addr, error) { - fqdn, err := dnsname.ToFQDN(domain) - if err != nil { - return nil, err - } - domain = fqdn.WithoutTrailingDot() - - ps.mu.Lock() - defer ps.mu.Unlock() - if addrs, ok := ps.domainToAddr[domain]; ok { - return addrs, nil - } - addrs := ps.assignAddrsLocked(domain) - return addrs, nil -} - -// isIPUsedLocked reports whether the given IP address is already assigned to a -// domain. -// ps.mu must be held. -func (ps *perPeerState) isIPUsedLocked(ip netip.Addr) bool { - _, ok := ps.addrToDomain.Lookup(ip) - return ok -} - -// unusedIPv4Locked returns an unused IPv4 address from the available ranges. -func (ps *perPeerState) unusedIPv4Locked() netip.Addr { - // TODO: skip ranges that have been exhausted - for _, r := range ps.c.v4Ranges { - ip := randV4(r) - for r.Contains(ip) { - if !ps.isIPUsedLocked(ip) && ip != ps.c.dnsAddr { - return ip - } - ip = ip.Next() - } - } - return netip.Addr{} -} - -// randV4 returns a random IPv4 address within the given prefix. -func randV4(maskedPfx netip.Prefix) netip.Addr { - bits := 32 - maskedPfx.Bits() - randBits := rand.Uint32N(1 << uint(bits)) - - ip4 := maskedPfx.Addr().As4() - pn := binary.BigEndian.Uint32(ip4[:]) - binary.BigEndian.PutUint32(ip4[:], randBits|pn) - return netip.AddrFrom4(ip4) -} - -// assignAddrsLocked assigns a pair of unique IP addresses for the given domain -// and returns them. The first address is an IPv4 address and the second is an -// IPv6 address. It does not check if the domain already has assigned addresses. -// ps.mu must be held. -func (ps *perPeerState) assignAddrsLocked(domain string) []netip.Addr { - if ps.addrToDomain == nil { - ps.addrToDomain = &bart.Table[string]{} - } - v4 := ps.unusedIPv4Locked() - as16 := ps.c.v6ULA.Addr().As16() - as4 := v4.As4() - copy(as16[12:], as4[:]) - v6 := netip.AddrFrom16(as16) - addrs := []netip.Addr{v4, v6} - mak.Set(&ps.domainToAddr, domain, addrs) - for _, a := range addrs { - ps.addrToDomain.Insert(netip.PrefixFrom(a, a.BitLen()), domain) - } - return addrs -} diff --git a/cmd/netlogfmt/main.go b/cmd/netlogfmt/main.go deleted file mode 100644 index 65e87098fec5e..0000000000000 --- a/cmd/netlogfmt/main.go +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// netlogfmt parses a stream of JSON log messages from stdin and -// formats the network traffic logs produced by "tailscale.com/wgengine/netlog" -// according to the schema in "tailscale.com/types/netlogtype.Message" -// in a more humanly readable format. -// -// Example usage: -// -// $ cat netlog.json | go run tailscale.com/cmd/netlogfmt -// ========================================================================================= -// NodeID: n123456CNTRL -// Logged: 2022-10-13T20:23:10.165Z -// Window: 2022-10-13T20:23:09.644Z (5s) -// --------------------------------------------------- Tx[P/s] Tx[B/s] Rx[P/s] Rx[B/s] -// VirtualTraffic: 16.80 1.64Ki 11.20 1.03Ki -// TCP: 100.109.51.95:22 -> 100.85.80.41:42912 16.00 1.59Ki 10.40 1008.84 -// TCP: 100.109.51.95:21291 -> 100.107.177.2:53133 0.40 27.60 0.40 24.20 -// TCP: 100.109.51.95:21291 -> 100.107.177.2:53134 0.40 23.40 0.40 24.20 -// PhysicalTraffic: 16.80 2.32Ki 11.20 1.48Ki -// 100.85.80.41 -> 192.168.0.101:41641 16.00 2.23Ki 10.40 1.40Ki -// 100.107.177.2 -> 192.168.0.100:41641 0.80 83.20 0.80 83.20 -// ========================================================================================= -package main - -import ( - "cmp" - "encoding/base64" - "encoding/json" - "flag" - "fmt" - "io" - "log" - "math" - "net/http" - "net/netip" - "os" - "slices" - "strconv" - "strings" - "time" - - "github.com/dsnet/try" - jsonv2 "github.com/go-json-experiment/json" - "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/logid" - "tailscale.com/types/netlogtype" - "tailscale.com/util/must" -) - -var ( - resolveNames = flag.Bool("resolve-names", false, "convert tailscale IP addresses to hostnames; must also specify --api-key and --tailnet-id") - apiKey = flag.String("api-key", "", "API key to query the Tailscale API with; see https://login.tailscale.com/admin/settings/keys") - tailnetName = flag.String("tailnet-name", "", "tailnet domain name to lookup devices in; see https://login.tailscale.com/admin/settings/general") -) - -var namesByAddr map[netip.Addr]string - -func main() { - flag.Parse() - if *resolveNames { - namesByAddr = mustMakeNamesByAddr() - } - - // The logic handles a stream of arbitrary JSON. - // So long as a JSON object seems like a network log message, - // then this will unmarshal and print it. - if err := processStream(os.Stdin); err != nil { - if err == io.EOF { - return - } - log.Fatalf("processStream: %v", err) - } -} - -func processStream(r io.Reader) (err error) { - defer try.Handle(&err) - dec := jsontext.NewDecoder(os.Stdin) - for { - processValue(dec) - } -} - -func processValue(dec *jsontext.Decoder) { - switch dec.PeekKind() { - case '[': - processArray(dec) - case '{': - processObject(dec) - default: - try.E(dec.SkipValue()) - } -} - -func processArray(dec *jsontext.Decoder) { - try.E1(dec.ReadToken()) // parse '[' - for dec.PeekKind() != ']' { - processValue(dec) - } - try.E1(dec.ReadToken()) // parse ']' -} - -func processObject(dec *jsontext.Decoder) { - var hasTraffic bool - var rawMsg []byte - try.E1(dec.ReadToken()) // parse '{' - for dec.PeekKind() != '}' { - // Capture any members that could belong to a network log message. - switch name := try.E1(dec.ReadToken()); name.String() { - case "virtualTraffic", "subnetTraffic", "exitTraffic", "physicalTraffic": - hasTraffic = true - fallthrough - case "logtail", "nodeId", "logged", "start", "end": - if len(rawMsg) == 0 { - rawMsg = append(rawMsg, '{') - } else { - rawMsg = append(rawMsg[:len(rawMsg)-1], ',') - } - rawMsg = append(append(append(rawMsg, '"'), name.String()...), '"') - rawMsg = append(rawMsg, ':') - rawMsg = append(rawMsg, try.E1(dec.ReadValue())...) - rawMsg = append(rawMsg, '}') - default: - processValue(dec) - } - } - try.E1(dec.ReadToken()) // parse '}' - - // If this appears to be a network log message, then unmarshal and print it. - if hasTraffic { - var msg message - try.E(jsonv2.Unmarshal(rawMsg, &msg)) - printMessage(msg) - } -} - -type message struct { - Logtail struct { - ID logid.PublicID `json:"id"` - Logged time.Time `json:"server_time"` - } `json:"logtail"` - Logged time.Time `json:"logged"` - netlogtype.Message -} - -func printMessage(msg message) { - // Construct a table of network traffic per connection. - rows := [][7]string{{3: "Tx[P/s]", 4: "Tx[B/s]", 5: "Rx[P/s]", 6: "Rx[B/s]"}} - duration := msg.End.Sub(msg.Start) - addRows := func(heading string, traffic []netlogtype.ConnectionCounts) { - if len(traffic) == 0 { - return - } - slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) int { - nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes - ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes - return cmp.Compare(ny, nx) - }) - var sum netlogtype.Counts - for _, cc := range traffic { - sum = sum.Add(cc.Counts) - } - rows = append(rows, [7]string{ - 0: heading + ":", - 3: formatSI(float64(sum.TxPackets) / duration.Seconds()), - 4: formatIEC(float64(sum.TxBytes) / duration.Seconds()), - 5: formatSI(float64(sum.RxPackets) / duration.Seconds()), - 6: formatIEC(float64(sum.RxBytes) / duration.Seconds()), - }) - if len(traffic) == 1 && traffic[0].Connection.IsZero() { - return // this is already a summary counts - } - formatAddrPort := func(a netip.AddrPort) string { - if !a.IsValid() { - return "" - } - if name, ok := namesByAddr[a.Addr()]; ok { - if a.Port() == 0 { - return name - } - return name + ":" + strconv.Itoa(int(a.Port())) - } - if a.Port() == 0 { - return a.Addr().String() - } - return a.String() - } - for _, cc := range traffic { - row := [7]string{ - 0: " ", - 1: formatAddrPort(cc.Src), - 2: formatAddrPort(cc.Dst), - 3: formatSI(float64(cc.TxPackets) / duration.Seconds()), - 4: formatIEC(float64(cc.TxBytes) / duration.Seconds()), - 5: formatSI(float64(cc.RxPackets) / duration.Seconds()), - 6: formatIEC(float64(cc.RxBytes) / duration.Seconds()), - } - if cc.Proto > 0 { - row[0] += cc.Proto.String() + ":" - } - rows = append(rows, row) - } - } - addRows("VirtualTraffic", msg.VirtualTraffic) - addRows("SubnetTraffic", msg.SubnetTraffic) - addRows("ExitTraffic", msg.ExitTraffic) - addRows("PhysicalTraffic", msg.PhysicalTraffic) - - // Compute the maximum width of each field. - var maxWidths [7]int - for _, row := range rows { - for i, col := range row { - if maxWidths[i] < len(col) && !(i == 0 && !strings.HasPrefix(col, " ")) { - maxWidths[i] = len(col) - } - } - } - var maxSum int - for _, n := range maxWidths { - maxSum += n - } - - // Output a table of network traffic per connection. - line := make([]byte, 0, maxSum+len(" ")+len(" -> ")+4*len(" ")) - line = appendRepeatByte(line, '=', cap(line)) - fmt.Println(string(line)) - if !msg.Logtail.ID.IsZero() { - fmt.Printf("LogID: %s\n", msg.Logtail.ID) - } - if msg.NodeID != "" { - fmt.Printf("NodeID: %s\n", msg.NodeID) - } - formatTime := func(t time.Time) string { - return t.In(time.Local).Format("2006-01-02 15:04:05.000") - } - switch { - case !msg.Logged.IsZero(): - fmt.Printf("Logged: %s\n", formatTime(msg.Logged)) - case !msg.Logtail.Logged.IsZero(): - fmt.Printf("Logged: %s\n", formatTime(msg.Logtail.Logged)) - } - fmt.Printf("Window: %s (%0.3fs)\n", formatTime(msg.Start), duration.Seconds()) - for i, row := range rows { - line = line[:0] - isHeading := !strings.HasPrefix(row[0], " ") - for j, col := range row { - if isHeading && j == 0 { - col = "" // headings will be printed later - } - switch j { - case 0, 2: // left justified - line = append(line, col...) - line = appendRepeatByte(line, ' ', maxWidths[j]-len(col)) - case 1, 3, 4, 5, 6: // right justified - line = appendRepeatByte(line, ' ', maxWidths[j]-len(col)) - line = append(line, col...) - } - switch j { - case 0: - line = append(line, " "...) - case 1: - if row[1] == "" && row[2] == "" { - line = append(line, " "...) - } else { - line = append(line, " -> "...) - } - case 2, 3, 4, 5: - line = append(line, " "...) - } - } - switch { - case i == 0: // print dashed-line table heading - line = appendRepeatByte(line[:0], '-', maxWidths[0]+len(" ")+maxWidths[1]+len(" -> ")+maxWidths[2])[:cap(line)] - case isHeading: - copy(line[:], row[0]) - } - fmt.Println(string(line)) - } -} - -func mustMakeNamesByAddr() map[netip.Addr]string { - switch { - case *apiKey == "": - log.Fatalf("--api-key must be specified with --resolve-names") - case *tailnetName == "": - log.Fatalf("--tailnet must be specified with --resolve-names") - } - - // Query the Tailscale API for a list of devices in the tailnet. - const apiURL = "https://api.tailscale.com/api/v2" - req := must.Get(http.NewRequest("GET", apiURL+"/tailnet/"+*tailnetName+"/devices", nil)) - req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(*apiKey+":"))) - resp := must.Get(http.DefaultClient.Do(req)) - defer resp.Body.Close() - b := must.Get(io.ReadAll(resp.Body)) - if resp.StatusCode != 200 { - log.Fatalf("http: %v: %s", http.StatusText(resp.StatusCode), b) - } - - // Unmarshal the API response. - var m struct { - Devices []struct { - Name string `json:"name"` - Addrs []netip.Addr `json:"addresses"` - } `json:"devices"` - } - must.Do(json.Unmarshal(b, &m)) - - // Construct a unique mapping of Tailscale IP addresses to hostnames. - // For brevity, we start with the first segment of the name and - // use more segments until we find the shortest prefix that is unique - // for all names in the tailnet. - seen := make(map[string]bool) - namesByAddr := make(map[netip.Addr]string) -retry: - for i := range 10 { - clear(seen) - clear(namesByAddr) - for _, d := range m.Devices { - name := fieldPrefix(d.Name, i) - if seen[name] { - continue retry - } - seen[name] = true - for _, a := range d.Addrs { - namesByAddr[a] = name - } - } - return namesByAddr - } - panic("unable to produce unique mapping of address to names") -} - -// fieldPrefix returns the first n number of dot-separated segments. -// -// Example: -// -// fieldPrefix("foo.bar.baz", 0) returns "" -// fieldPrefix("foo.bar.baz", 1) returns "foo" -// fieldPrefix("foo.bar.baz", 2) returns "foo.bar" -// fieldPrefix("foo.bar.baz", 3) returns "foo.bar.baz" -// fieldPrefix("foo.bar.baz", 4) returns "foo.bar.baz" -func fieldPrefix(s string, n int) string { - s0 := s - for i := 0; i < n && len(s) > 0; i++ { - if j := strings.IndexByte(s, '.'); j >= 0 { - s = s[j+1:] - } else { - s = "" - } - } - return strings.TrimSuffix(s0[:len(s0)-len(s)], ".") -} - -func appendRepeatByte(b []byte, c byte, n int) []byte { - for range n { - b = append(b, c) - } - return b -} - -func formatSI(n float64) string { - switch n := math.Abs(n); { - case n < 1e3: - return fmt.Sprintf("%0.2f ", n/(1e0)) - case n < 1e6: - return fmt.Sprintf("%0.2fk", n/(1e3)) - case n < 1e9: - return fmt.Sprintf("%0.2fM", n/(1e6)) - default: - return fmt.Sprintf("%0.2fG", n/(1e9)) - } -} - -func formatIEC(n float64) string { - switch n := math.Abs(n); { - case n < 1<<10: - return fmt.Sprintf("%0.2f ", n/(1<<0)) - case n < 1<<20: - return fmt.Sprintf("%0.2fKi", n/(1<<10)) - case n < 1<<30: - return fmt.Sprintf("%0.2fMi", n/(1<<20)) - default: - return fmt.Sprintf("%0.2fGi", n/(1<<30)) - } -} diff --git a/cmd/nginx-auth/.gitignore b/cmd/nginx-auth/.gitignore deleted file mode 100644 index 3c608aeb1eede..0000000000000 --- a/cmd/nginx-auth/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -nga.sock -*.deb -*.rpm -tailscale.nginx-auth diff --git a/cmd/nginx-auth/README.md b/cmd/nginx-auth/README.md deleted file mode 100644 index 858f9ab81a83e..0000000000000 --- a/cmd/nginx-auth/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# nginx-auth - -[![status: experimental](https://img.shields.io/badge/status-experimental-blue)](https://tailscale.com/kb/1167/release-stages/#experimental) - -This is a tool that allows users to use Tailscale Whois authentication with -NGINX as a reverse proxy. This allows users that already have a bunch of -services hosted on an internal NGINX server to point those domains to the -Tailscale IP of the NGINX server and then seamlessly use Tailscale for -authentication. - -Many thanks to [@zrail](https://twitter.com/zrail/status/1511788463586222087) on -Twitter for introducing the basic idea and offering some sample code. This -program is based on that sample code with security enhancements. Namely: - -* This listens over a UNIX socket instead of a TCP socket, to prevent - leakage to the network -* This uses systemd socket activation so that systemd owns the socket - and can then lock down the service to the bare minimum required to do - its job without having to worry about dropping permissions -* This provides additional information in HTTP response headers that can - be useful for integrating with various services - -## Configuration - -In order to protect a service with this tool, do the following in the respective -`server` block: - -Create an authentication location with the `internal` flag set: - -```nginx -location /auth { - internal; - - proxy_pass http://unix:/run/tailscale.nginx-auth.sock; - proxy_pass_request_body off; - - proxy_set_header Host $http_host; - proxy_set_header Remote-Addr $remote_addr; - proxy_set_header Remote-Port $remote_port; - proxy_set_header Original-URI $request_uri; -} -``` - -Then add the following to the `location /` block: - -``` -auth_request /auth; -auth_request_set $auth_user $upstream_http_tailscale_user; -auth_request_set $auth_name $upstream_http_tailscale_name; -auth_request_set $auth_login $upstream_http_tailscale_login; -auth_request_set $auth_tailnet $upstream_http_tailscale_tailnet; -auth_request_set $auth_profile_picture $upstream_http_tailscale_profile_picture; - -proxy_set_header X-Webauth-User "$auth_user"; -proxy_set_header X-Webauth-Name "$auth_name"; -proxy_set_header X-Webauth-Login "$auth_login"; -proxy_set_header X-Webauth-Tailnet "$auth_tailnet"; -proxy_set_header X-Webauth-Profile-Picture "$auth_profile_picture"; -``` - -When this configuration is used with a Go HTTP handler such as this: - -```go -http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { - e := json.NewEncoder(w) - e.SetIndent("", " ") - e.Encode(r.Header) -}) -``` - -You will get output like this: - -```json -{ - "Accept": [ - "*/*" - ], - "Connection": [ - "upgrade" - ], - "User-Agent": [ - "curl/7.82.0" - ], - "X-Webauth-Login": [ - "Xe" - ], - "X-Webauth-Name": [ - "Xe Iaso" - ], - "X-Webauth-Profile-Picture": [ - "https://avatars.githubusercontent.com/u/529003?v=4" - ], - "X-Webauth-Tailnet": [ - "cetacean.org.github" - ] - "X-Webauth-User": [ - "Xe@github" - ] -} -``` - -## Headers - -The authentication service provides the following headers to decorate your -proxied requests: - -| Header | Example Value | Description | -| :------ | :-------------- | :---------- | -| `Tailscale-User` | `azurediamond@hunter2.net` | The Tailscale username the remote machine is logged in as in user@host form | -| `Tailscale-Login` | `azurediamond` | The user portion of the Tailscale username the remote machine is logged in as | -| `Tailscale-Name` | `Azure Diamond` | The "real name" of the Tailscale user the machine is logged in as | -| `Tailscale-Profile-Picture` | `https://i.kym-cdn.com/photos/images/newsfeed/001/065/963/ae0.png` | The profile picture provided by the Identity Provider your tailnet uses | -| `Tailscale-Tailnet` | `hunter2.net` | The tailnet name | - -Most of the time you can set `X-Webauth-User` to the contents of the -`Tailscale-User` header, but some services may not accept a username with an `@` -symbol in it. If this is the case, set `X-Webauth-User` to the `Tailscale-Login` -header. - -The `Tailscale-Tailnet` header can help you identify which tailnet the session -is coming from. If you are using node sharing, this can help you make sure that -you aren't giving administrative access to people outside your tailnet. - -### Allow Requests From Only One Tailnet - -If you want to prevent node sharing from allowing users to access a service, add -the `Expected-Tailnet` header to your auth request: - -```nginx -location /auth { - # ... - proxy_set_header Expected-Tailnet "tailnet012345.ts.net"; -} -``` - -If a user from a different tailnet tries to use that service, this will return a -generic "forbidden" error page: - -```html - -403 Forbidden - -

403 Forbidden

-
nginx/1.18.0 (Ubuntu)
- - -``` - -You can get the tailnet name from [the admin panel](https://login.tailscale.com/admin/dns). - -## Building - -Install `cmd/mkpkg`: - -``` -cd .. && go install ./mkpkg -``` - -Then run `./mkdeb.sh`. It will emit a `.deb` and `.rpm` package for amd64 -machines (Linux uname flag: `x86_64`). You can add these to your deployment -methods as you see fit. diff --git a/cmd/nginx-auth/deb/postinst.sh b/cmd/nginx-auth/deb/postinst.sh deleted file mode 100755 index d352a84885403..0000000000000 --- a/cmd/nginx-auth/deb/postinst.sh +++ /dev/null @@ -1,14 +0,0 @@ -if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then - deb-systemd-helper unmask 'tailscale.nginx-auth.socket' >/dev/null || true - if deb-systemd-helper --quiet was-enabled 'tailscale.nginx-auth.socket'; then - deb-systemd-helper enable 'tailscale.nginx-auth.socket' >/dev/null || true - else - deb-systemd-helper update-state 'tailscale.nginx-auth.socket' >/dev/null || true - fi - - if systemctl is-active tailscale.nginx-auth.socket >/dev/null; then - systemctl --system daemon-reload >/dev/null || true - deb-systemd-invoke stop 'tailscale.nginx-auth.service' >/dev/null || true - deb-systemd-invoke restart 'tailscale.nginx-auth.socket' >/dev/null || true - fi -fi diff --git a/cmd/nginx-auth/deb/postrm.sh b/cmd/nginx-auth/deb/postrm.sh deleted file mode 100755 index 4bce86139c6c2..0000000000000 --- a/cmd/nginx-auth/deb/postrm.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -set -e -if [ -d /run/systemd/system ] ; then - systemctl --system daemon-reload >/dev/null || true -fi - -if [ -x "/usr/bin/deb-systemd-helper" ]; then - if [ "$1" = "remove" ]; then - deb-systemd-helper mask 'tailscale.nginx-auth.socket' >/dev/null || true - deb-systemd-helper mask 'tailscale.nginx-auth.service' >/dev/null || true - fi - - if [ "$1" = "purge" ]; then - deb-systemd-helper purge 'tailscale.nginx-auth.socket' >/dev/null || true - deb-systemd-helper unmask 'tailscale.nginx-auth.socket' >/dev/null || true - deb-systemd-helper purge 'tailscale.nginx-auth.service' >/dev/null || true - deb-systemd-helper unmask 'tailscale.nginx-auth.service' >/dev/null || true - fi -fi diff --git a/cmd/nginx-auth/deb/prerm.sh b/cmd/nginx-auth/deb/prerm.sh deleted file mode 100755 index e4becd17039ba..0000000000000 --- a/cmd/nginx-auth/deb/prerm.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -e -if [ "$1" = "remove" ]; then - if [ -d /run/systemd/system ]; then - deb-systemd-invoke stop 'tailscale.nginx-auth.service' >/dev/null || true - deb-systemd-invoke stop 'tailscale.nginx-auth.socket' >/dev/null || true - fi -fi diff --git a/cmd/nginx-auth/mkdeb.sh b/cmd/nginx-auth/mkdeb.sh deleted file mode 100755 index 59f43230d0817..0000000000000 --- a/cmd/nginx-auth/mkdeb.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash - -set -e - -VERSION=0.1.3 -for ARCH in amd64 arm64; do - CGO_ENABLED=0 GOARCH=${ARCH} GOOS=linux go build -o tailscale.nginx-auth . - - 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}-${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 deleted file mode 100644 index 09da74da1d3c8..0000000000000 --- a/cmd/nginx-auth/nginx-auth.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -// Command nginx-auth is a tool that allows users to use Tailscale Whois -// authentication with NGINX as a reverse proxy. This allows users that -// already have a bunch of services hosted on an internal NGINX server -// to point those domains to the Tailscale IP of the NGINX server and -// then seamlessly use Tailscale for authentication. -package main - -import ( - "flag" - "log" - "net" - "net/http" - "net/netip" - "net/url" - "os" - "strings" - - "github.com/coreos/go-systemd/activation" - "tailscale.com/client/tailscale" -) - -var ( - sockPath = flag.String("sockpath", "", "the filesystem path for the unix socket this service exposes") -) - -func main() { - flag.Parse() - - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - remoteHost := r.Header.Get("Remote-Addr") - remotePort := r.Header.Get("Remote-Port") - if remoteHost == "" || remotePort == "" { - w.WriteHeader(http.StatusBadRequest) - log.Println("set Remote-Addr to $remote_addr and Remote-Port to $remote_port in your nginx config") - return - } - - remoteAddrStr := net.JoinHostPort(remoteHost, remotePort) - remoteAddr, err := netip.ParseAddrPort(remoteAddrStr) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - log.Printf("remote address and port are not valid: %v", err) - return - } - - info, err := tailscale.WhoIs(r.Context(), remoteAddr.String()) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - log.Printf("can't look up %s: %v", remoteAddr, err) - return - } - - if info.Node.IsTagged() { - w.WriteHeader(http.StatusForbidden) - log.Printf("node %s is tagged", info.Node.Hostinfo.Hostname()) - return - } - - // tailnet of connected node. When accessing shared nodes, this - // will be empty because the tailnet of the sharee is not exposed. - var tailnet string - - if !info.Node.Hostinfo.ShareeNode() { - var ok bool - _, tailnet, ok = strings.Cut(info.Node.Name, info.Node.ComputedName+".") - if !ok { - w.WriteHeader(http.StatusUnauthorized) - log.Printf("can't extract tailnet name from hostname %q", info.Node.Name) - return - } - tailnet = strings.TrimSuffix(tailnet, ".beta.tailscale.net") - } - - if expectedTailnet := r.Header.Get("Expected-Tailnet"); expectedTailnet != "" && expectedTailnet != tailnet { - w.WriteHeader(http.StatusForbidden) - log.Printf("user is part of tailnet %s, wanted: %s", tailnet, url.QueryEscape(expectedTailnet)) - return - } - - h := w.Header() - h.Set("Tailscale-Login", strings.Split(info.UserProfile.LoginName, "@")[0]) - h.Set("Tailscale-User", info.UserProfile.LoginName) - h.Set("Tailscale-Name", info.UserProfile.DisplayName) - h.Set("Tailscale-Profile-Picture", info.UserProfile.ProfilePicURL) - h.Set("Tailscale-Tailnet", tailnet) - w.WriteHeader(http.StatusNoContent) - }) - - if *sockPath != "" { - _ = os.Remove(*sockPath) // ignore error, this file may not already exist - ln, err := net.Listen("unix", *sockPath) - if err != nil { - log.Fatalf("can't listen on %s: %v", *sockPath, err) - } - defer ln.Close() - - log.Printf("listening on %s", *sockPath) - log.Fatal(http.Serve(ln, mux)) - } - - listeners, err := activation.Listeners() - if err != nil { - log.Fatalf("no sockets passed to this service with systemd: %v", err) - } - - // NOTE(Xe): normally you'd want to make a waitgroup here and then register - // each listener with it. In this case I want this to blow up horribly if - // any of the listeners stop working. systemd will restart it due to the - // socket activation at play. - // - // TL;DR: Let it crash, it will come back - for _, ln := range listeners { - go func(ln net.Listener) { - log.Printf("listening on %s", ln.Addr()) - log.Fatal(http.Serve(ln, mux)) - }(ln) - } - - for { - select {} - } -} diff --git a/cmd/nginx-auth/rpm/postinst.sh b/cmd/nginx-auth/rpm/postinst.sh deleted file mode 100755 index e69de29bb2d1d..0000000000000 diff --git a/cmd/nginx-auth/rpm/postrm.sh b/cmd/nginx-auth/rpm/postrm.sh deleted file mode 100755 index 3d0abfb199137..0000000000000 --- a/cmd/nginx-auth/rpm/postrm.sh +++ /dev/null @@ -1,9 +0,0 @@ -# $1 == 0 for uninstallation. -# $1 == 1 for removing old package during upgrade. - -systemctl daemon-reload >/dev/null 2>&1 || : -if [ $1 -ge 1 ] ; then - # Package upgrade, not uninstall - systemctl stop tailscale.nginx-auth.service >/dev/null 2>&1 || : - systemctl try-restart tailscale.nginx-auth.socket >/dev/null 2>&1 || : -fi diff --git a/cmd/nginx-auth/rpm/prerm.sh b/cmd/nginx-auth/rpm/prerm.sh deleted file mode 100755 index 1f198d8292bc5..0000000000000 --- a/cmd/nginx-auth/rpm/prerm.sh +++ /dev/null @@ -1,9 +0,0 @@ -# $1 == 0 for uninstallation. -# $1 == 1 for removing old package during upgrade. - -if [ $1 -eq 0 ] ; then - # Package removal, not upgrade - systemctl --no-reload disable tailscale.nginx-auth.socket > /dev/null 2>&1 || : - systemctl stop tailscale.nginx-auth.socket > /dev/null 2>&1 || : - systemctl stop tailscale.nginx-auth.service > /dev/null 2>&1 || : -fi diff --git a/cmd/nginx-auth/tailscale.nginx-auth.service b/cmd/nginx-auth/tailscale.nginx-auth.service deleted file mode 100644 index 086f6c7741d88..0000000000000 --- a/cmd/nginx-auth/tailscale.nginx-auth.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Tailscale NGINX Authentication service -After=nginx.service -Wants=nginx.service - -[Service] -ExecStart=/usr/sbin/tailscale.nginx-auth -DynamicUser=yes - -[Install] -WantedBy=default.target diff --git a/cmd/nginx-auth/tailscale.nginx-auth.socket b/cmd/nginx-auth/tailscale.nginx-auth.socket deleted file mode 100644 index 7e5641ff3a2f5..0000000000000 --- a/cmd/nginx-auth/tailscale.nginx-auth.socket +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Tailscale NGINX Authentication socket -PartOf=tailscale.nginx-auth.service - -[Socket] -ListenStream=/var/run/tailscale.nginx-auth.sock - -[Install] -WantedBy=sockets.target \ No newline at end of file diff --git a/cmd/pgproxy/README.md b/cmd/pgproxy/README.md deleted file mode 100644 index 2e013072a1900..0000000000000 --- a/cmd/pgproxy/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# pgproxy - -The pgproxy server is a proxy for the Postgres wire protocol. [Read -more in our blog -post](https://tailscale.com/blog/introducing-pgproxy/) about it! - -The proxy runs an in-process Tailscale instance, accepts postgres -client connections over Tailscale only, and proxies them to the -configured upstream postgres server. - -This proxy exists because postgres clients default to very insecure -connection settings: either they "prefer" but do not require TLS; or -they set sslmode=require, which merely requires that a TLS handshake -took place, but don't verify the server's TLS certificate or the -presented TLS hostname. In other words, sslmode=require enforces that -a TLS session is created, but that session can trivially be -machine-in-the-middled to steal credentials, data, inject malicious -queries, and so forth. - -Because this flaw is in the client's validation of the TLS session, -you have no way of reliably detecting the misconfiguration -server-side. You could fix the configuration of all the clients you -know of, but the default makes it very easy to accidentally regress. - -Instead of trying to verify client configuration over time, this proxy -removes the need for postgres clients to be configured correctly: the -upstream database is configured to only accept connections from the -proxy, and the proxy is only available to clients over Tailscale. - -Therefore, clients must use the proxy to connect to the database. The -client<>proxy connection is secured end-to-end by Tailscale, which the -proxy enforces by verifying that the connecting client is a known -current Tailscale peer. The proxy<>server connection is established by -the proxy itself, using strict TLS verification settings, and the -client is only allowed to communicate with the server once we've -established that the upstream connection is safe to use. - -A couple side benefits: because clients can only connect via -Tailscale, you can use Tailscale ACLs as an extra layer of defense on -top of the postgres user/password authentication. And, the proxy can -maintain an audit log of who connected to the database, complete with -the strongly authenticated Tailscale identity of the client. diff --git a/cmd/pgproxy/pgproxy.go b/cmd/pgproxy/pgproxy.go deleted file mode 100644 index 468649ee2bc5f..0000000000000 --- a/cmd/pgproxy/pgproxy.go +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The pgproxy server is a proxy for the Postgres wire protocol. -package main - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - crand "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "expvar" - "flag" - "fmt" - "io" - "log" - "math/big" - "net" - "net/http" - "os" - "strings" - "time" - - "tailscale.com/client/tailscale" - "tailscale.com/metrics" - "tailscale.com/tsnet" - "tailscale.com/tsweb" -) - -var ( - hostname = flag.String("hostname", "", "Tailscale hostname to serve on") - port = flag.Int("port", 5432, "Listening port for client connections") - debugPort = flag.Int("debug-port", 80, "Listening port for debug/metrics endpoint") - upstreamAddr = flag.String("upstream-addr", "", "Address of the upstream Postgres server, in host:port format") - upstreamCA = flag.String("upstream-ca-file", "", "File containing the PEM-encoded CA certificate for the upstream server") - tailscaleDir = flag.String("state-dir", "", "Directory in which to store the Tailscale auth state") -) - -func main() { - flag.Parse() - if *hostname == "" { - log.Fatal("missing --hostname") - } - if *upstreamAddr == "" { - log.Fatal("missing --upstream-addr") - } - if *upstreamCA == "" { - log.Fatal("missing --upstream-ca-file") - } - if *tailscaleDir == "" { - log.Fatal("missing --state-dir") - } - - ts := &tsnet.Server{ - Dir: *tailscaleDir, - Hostname: *hostname, - } - - if os.Getenv("TS_AUTHKEY") == "" { - log.Print("Note: you need to run this with TS_AUTHKEY=... the first time, to join your tailnet of choice.") - } - - tsclient, err := ts.LocalClient() - if err != nil { - log.Fatalf("getting tsnet API client: %v", err) - } - - p, err := newProxy(*upstreamAddr, *upstreamCA, tsclient) - if err != nil { - log.Fatal(err) - } - expvar.Publish("pgproxy", p.Expvar()) - - if *debugPort != 0 { - mux := http.NewServeMux() - tsweb.Debugger(mux) - srv := &http.Server{ - Handler: mux, - } - dln, err := ts.Listen("tcp", fmt.Sprintf(":%d", *debugPort)) - if err != nil { - log.Fatal(err) - } - go func() { - log.Fatal(srv.Serve(dln)) - }() - } - - ln, err := ts.Listen("tcp", fmt.Sprintf(":%d", *port)) - if err != nil { - log.Fatal(err) - } - log.Printf("serving access to %s on port %d", *upstreamAddr, *port) - log.Fatal(p.Serve(ln)) -} - -// proxy is a postgres wire protocol proxy, which strictly enforces -// the security of the TLS connection to its upstream regardless of -// what the client's TLS configuration is. -type proxy struct { - upstreamAddr string // "my.database.com:5432" - upstreamHost string // "my.database.com" - upstreamCertPool *x509.CertPool - downstreamCert []tls.Certificate - client *tailscale.LocalClient - - activeSessions expvar.Int - startedSessions expvar.Int - errors metrics.LabelMap -} - -// newProxy returns a proxy that forwards connections to -// upstreamAddr. The upstream's TLS session is verified using the CA -// cert(s) in upstreamCAPath. -func newProxy(upstreamAddr, upstreamCAPath string, client *tailscale.LocalClient) (*proxy, error) { - bs, err := os.ReadFile(upstreamCAPath) - if err != nil { - return nil, err - } - upstreamCertPool := x509.NewCertPool() - if !upstreamCertPool.AppendCertsFromPEM(bs) { - return nil, fmt.Errorf("invalid CA cert in %q", upstreamCAPath) - } - - h, _, err := net.SplitHostPort(upstreamAddr) - if err != nil { - return nil, err - } - downstreamCert, err := mkSelfSigned(h) - if err != nil { - return nil, err - } - - return &proxy{ - upstreamAddr: upstreamAddr, - upstreamHost: h, - upstreamCertPool: upstreamCertPool, - downstreamCert: []tls.Certificate{downstreamCert}, - client: client, - errors: metrics.LabelMap{Label: "kind"}, - }, nil -} - -// Expvar returns p's monitoring metrics. -func (p *proxy) Expvar() expvar.Var { - ret := &metrics.Set{} - ret.Set("sessions_active", &p.activeSessions) - ret.Set("sessions_started", &p.startedSessions) - ret.Set("session_errors", &p.errors) - return ret -} - -// Serve accepts postgres client connections on ln and proxies them to -// the configured upstream. ln can be any net.Listener, but all client -// connections must originate from tailscale IPs that can be verified -// with WhoIs. -func (p *proxy) Serve(ln net.Listener) error { - var lastSessionID int64 - for { - c, err := ln.Accept() - if err != nil { - return err - } - id := time.Now().UnixNano() - if id == lastSessionID { - // Bluntly enforce SID uniqueness, even if collisions are - // fantastically unlikely (but OSes vary in how much timer - // precision they expose to the OS, so id might be rounded - // e.g. to the same millisecond) - id++ - } - lastSessionID = id - go func(sessionID int64) { - if err := p.serve(sessionID, c); err != nil { - log.Printf("%d: session ended with error: %v", sessionID, err) - } - }(id) - } -} - -var ( - // sslStart is the magic bytes that postgres clients use to indicate - // that they want to do a TLS handshake. Servers should respond with - // the single byte "S" before starting a normal TLS handshake. - sslStart = [8]byte{0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f} - // plaintextStart is the magic bytes that postgres clients use to - // indicate that they're starting a plaintext authentication - // handshake. - plaintextStart = [8]byte{0, 0, 0, 86, 0, 3, 0, 0} -) - -// serve proxies the postgres client on c to the proxy's upstream, -// enforcing strict TLS to the upstream. -func (p *proxy) serve(sessionID int64, c net.Conn) error { - defer c.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - whois, err := p.client.WhoIs(ctx, c.RemoteAddr().String()) - if err != nil { - p.errors.Add("whois-failed", 1) - return fmt.Errorf("getting client identity: %v", err) - } - - // Before anything else, log the connection attempt. - user, machine := "", "" - if whois.Node != nil { - if whois.Node.Hostinfo.ShareeNode() { - machine = "external-device" - } else { - machine = strings.TrimSuffix(whois.Node.Name, ".") - } - } - if whois.UserProfile != nil { - user = whois.UserProfile.LoginName - if user == "tagged-devices" && whois.Node != nil { - user = strings.Join(whois.Node.Tags, ",") - } - } - if user == "" || machine == "" { - p.errors.Add("no-ts-identity", 1) - return fmt.Errorf("couldn't identify source user and machine (user %q, machine %q)", user, machine) - } - log.Printf("%d: session start, from %s (machine %s, user %s)", sessionID, c.RemoteAddr(), machine, user) - start := time.Now() - defer func() { - elapsed := time.Since(start) - log.Printf("%d: session end, from %s (machine %s, user %s), lasted %s", sessionID, c.RemoteAddr(), machine, user, elapsed.Round(time.Millisecond)) - }() - - // Read the client's opening message, to figure out if it's trying - // to TLS or not. - var buf [8]byte - if _, err := io.ReadFull(c, buf[:len(sslStart)]); err != nil { - p.errors.Add("network-error", 1) - return fmt.Errorf("initial magic read: %v", err) - } - var clientIsTLS bool - switch { - case buf == sslStart: - clientIsTLS = true - case buf == plaintextStart: - clientIsTLS = false - default: - p.errors.Add("client-bad-protocol", 1) - return fmt.Errorf("unrecognized initial packet = % 02x", buf) - } - - // Dial & verify upstream connection. - var d net.Dialer - d.Timeout = 10 * time.Second - upc, err := d.Dial("tcp", p.upstreamAddr) - if err != nil { - p.errors.Add("network-error", 1) - return fmt.Errorf("upstream dial: %v", err) - } - defer upc.Close() - if _, err := upc.Write(sslStart[:]); err != nil { - p.errors.Add("network-error", 1) - return fmt.Errorf("upstream write of start-ssl magic: %v", err) - } - if _, err := io.ReadFull(upc, buf[:1]); err != nil { - p.errors.Add("network-error", 1) - return fmt.Errorf("reading upstream start-ssl response: %v", err) - } - if buf[0] != 'S' { - p.errors.Add("upstream-bad-protocol", 1) - return fmt.Errorf("upstream didn't acknowledge start-ssl, said %q", buf[0]) - } - tlsConf := &tls.Config{ - ServerName: p.upstreamHost, - RootCAs: p.upstreamCertPool, - MinVersion: tls.VersionTLS12, - } - uptc := tls.Client(upc, tlsConf) - if err = uptc.HandshakeContext(ctx); err != nil { - p.errors.Add("upstream-tls", 1) - return fmt.Errorf("upstream TLS handshake: %v", err) - } - - // Accept the client conn and set it up the way the client wants. - var clientConn net.Conn - if clientIsTLS { - io.WriteString(c, "S") // yeah, we're good to speak TLS - s := tls.Server(c, &tls.Config{ - ServerName: p.upstreamHost, - Certificates: p.downstreamCert, - MinVersion: tls.VersionTLS12, - }) - if err = uptc.HandshakeContext(ctx); err != nil { - p.errors.Add("client-tls", 1) - return fmt.Errorf("client TLS handshake: %v", err) - } - clientConn = s - } else { - // Repeat the header we read earlier up to the server. - if _, err := uptc.Write(plaintextStart[:]); err != nil { - p.errors.Add("network-error", 1) - return fmt.Errorf("sending initial client bytes to upstream: %v", err) - } - clientConn = c - } - - // Finally, proxy the client to the upstream. - errc := make(chan error, 1) - go func() { - _, err := io.Copy(uptc, clientConn) - errc <- err - }() - go func() { - _, err := io.Copy(clientConn, uptc) - errc <- err - }() - if err := <-errc; err != nil { - // Don't increment error counts here, because the most common - // cause of termination is client or server closing the - // connection normally, and it'll obscure "interesting" - // handshake errors. - return fmt.Errorf("session terminated with error: %v", err) - } - return nil -} - -// mkSelfSigned creates and returns a self-signed TLS certificate for -// hostname. -func mkSelfSigned(hostname string) (tls.Certificate, error) { - priv, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader) - if err != nil { - return tls.Certificate{}, err - } - pub := priv.Public() - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"pgproxy"}, - }, - DNSNames: []string{hostname}, - NotBefore: time.Now(), - NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - derBytes, err := x509.CreateCertificate(crand.Reader, &template, &template, pub, priv) - if err != nil { - return tls.Certificate{}, err - } - cert, err := x509.ParseCertificate(derBytes) - if err != nil { - return tls.Certificate{}, err - } - - return tls.Certificate{ - Certificate: [][]byte{derBytes}, - PrivateKey: priv, - Leaf: cert, - }, nil -} diff --git a/cmd/printdep/printdep.go b/cmd/printdep/printdep.go deleted file mode 100644 index 044283209c08c..0000000000000 --- a/cmd/printdep/printdep.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The printdep command is a build system tool for printing out information -// about dependencies. -package main - -import ( - "flag" - "fmt" - "log" - "runtime" - "strings" - - ts "tailscale.com" -) - -var ( - goToolchain = flag.Bool("go", false, "print the supported Go toolchain git hash (a github.com/tailscale/go commit)") - goToolchainURL = flag.Bool("go-url", false, "print the URL to the tarball of the Tailscale Go toolchain") - alpine = flag.Bool("alpine", false, "print the tag of alpine docker image") -) - -func main() { - flag.Parse() - if *alpine { - fmt.Println(strings.TrimSpace(ts.AlpineDockerTag)) - return - } - if *goToolchain { - fmt.Println(strings.TrimSpace(ts.GoToolchainRev)) - } - if *goToolchainURL { - 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, runtime.GOARCH) - } -} diff --git a/cmd/proxy-to-grafana/proxy-to-grafana.go b/cmd/proxy-to-grafana/proxy-to-grafana.go deleted file mode 100644 index f1c67bad5e28e..0000000000000 --- a/cmd/proxy-to-grafana/proxy-to-grafana.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// proxy-to-grafana is a reverse proxy which identifies users based on their -// originating Tailscale identity and maps them to corresponding Grafana -// users, creating them if needed. -// -// It uses Grafana's AuthProxy feature: -// https://grafana.com/docs/grafana/latest/auth/auth-proxy/ -// -// Set the TS_AUTHKEY environment variable to have this server automatically -// join your tailnet, or look for the logged auth link on first start. -// -// Use this Grafana configuration to enable the auth proxy: -// -// [auth.proxy] -// enabled = true -// header_name = X-WEBAUTH-USER -// header_property = username -// auto_sign_up = true -// whitelist = 127.0.0.1 -// headers = Name:X-WEBAUTH-NAME -// enable_login_token = true -package main - -import ( - "context" - "crypto/tls" - "flag" - "fmt" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "strings" - "time" - - "tailscale.com/client/tailscale" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" -) - -var ( - hostname = flag.String("hostname", "", "Tailscale hostname to serve on, used as the base name for MagicDNS or subdomain in your domain alias for HTTPS.") - backendAddr = flag.String("backend-addr", "", "Address of the Grafana server served over HTTP, in host:port format. Typically localhost:nnnn.") - tailscaleDir = flag.String("state-dir", "./", "Alternate directory to use for Tailscale state storage. If empty, a default is used.") - useHTTPS = flag.Bool("use-https", false, "Serve over HTTPS via your *.ts.net subdomain if enabled in Tailscale admin.") - loginServer = flag.String("login-server", "", "URL to alternative control server. If empty, the default Tailscale control is used.") -) - -func main() { - flag.Parse() - if *hostname == "" || strings.Contains(*hostname, ".") { - log.Fatal("missing or invalid --hostname") - } - if *backendAddr == "" { - log.Fatal("missing --backend-addr") - } - ts := &tsnet.Server{ - Dir: *tailscaleDir, - Hostname: *hostname, - ControlURL: *loginServer, - } - - // TODO(bradfitz,maisem): move this to a method on tsnet.Server probably. - if err := ts.Start(); err != nil { - log.Fatalf("Error starting tsnet.Server: %v", err) - } - localClient, _ := ts.LocalClient() - - url, err := url.Parse(fmt.Sprintf("http://%s", *backendAddr)) - if err != nil { - log.Fatalf("couldn't parse backend address: %v", err) - } - - proxy := httputil.NewSingleHostReverseProxy(url) - originalDirector := proxy.Director - proxy.Director = func(req *http.Request) { - originalDirector(req) - modifyRequest(req, localClient) - } - - var ln net.Listener - if *useHTTPS { - ln, err = ts.Listen("tcp", ":443") - ln = tls.NewListener(ln, &tls.Config{ - GetCertificate: localClient.GetCertificate, - }) - - go func() { - // wait for tailscale to start before trying to fetch cert names - for range 60 { - st, err := localClient.Status(context.Background()) - if err != nil { - log.Printf("error retrieving tailscale status; retrying: %v", err) - } else { - log.Printf("tailscale status: %v", st.BackendState) - if st.BackendState == "Running" { - break - } - } - time.Sleep(time.Second) - } - - l80, err := ts.Listen("tcp", ":80") - if err != nil { - log.Fatal(err) - } - name, ok := localClient.ExpandSNIName(context.Background(), *hostname) - if !ok { - log.Fatalf("can't get hostname for https redirect") - } - if err := http.Serve(l80, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, fmt.Sprintf("https://%s", name), http.StatusMovedPermanently) - })); err != nil { - log.Fatal(err) - } - }() - } else { - ln, err = ts.Listen("tcp", ":80") - } - if err != nil { - log.Fatal(err) - } - log.Printf("proxy-to-grafana running at %v, proxying to %v", ln.Addr(), *backendAddr) - log.Fatal(http.Serve(ln, proxy)) -} - -func modifyRequest(req *http.Request, localClient *tailscale.LocalClient) { - // with enable_login_token set to true, we get a cookie that handles - // auth for paths that are not /login - if req.URL.Path != "/login" { - return - } - - user, err := getTailscaleUser(req.Context(), localClient, req.RemoteAddr) - if err != nil { - log.Printf("error getting Tailscale user: %v", err) - return - } - - req.Header.Set("X-Webauth-User", user.LoginName) - req.Header.Set("X-Webauth-Name", user.DisplayName) -} - -func getTailscaleUser(ctx context.Context, localClient *tailscale.LocalClient, ipPort string) (*tailcfg.UserProfile, error) { - whois, err := localClient.WhoIs(ctx, ipPort) - if err != nil { - return nil, fmt.Errorf("failed to identify remote host: %w", err) - } - if whois.Node.IsTagged() { - return nil, fmt.Errorf("tagged nodes are not users") - } - if whois.UserProfile == nil || whois.UserProfile.LoginName == "" { - return nil, fmt.Errorf("failed to identify remote user") - } - - return whois.UserProfile, nil -} diff --git a/cmd/sniproxy/.gitignore b/cmd/sniproxy/.gitignore deleted file mode 100644 index b1399c88167d4..0000000000000 --- a/cmd/sniproxy/.gitignore +++ /dev/null @@ -1 +0,0 @@ -sniproxy diff --git a/cmd/sniproxy/handlers.go b/cmd/sniproxy/handlers.go deleted file mode 100644 index 102110fe36dc7..0000000000000 --- a/cmd/sniproxy/handlers.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "context" - "fmt" - "log" - "math/rand/v2" - "net" - "net/netip" - "slices" - - "github.com/inetaf/tcpproxy" - "tailscale.com/net/netutil" -) - -type tcpRoundRobinHandler struct { - // To is a list of destination addresses to forward to. - // An entry may be either an IP address or a DNS name. - To []string - - // DialContext is used to make the outgoing TCP connection. - DialContext func(ctx context.Context, network, address string) (net.Conn, error) - - // ReachableIPs enumerates the IP addresses this handler is reachable on. - ReachableIPs []netip.Addr -} - -// ReachableOn returns the IP addresses this handler is reachable on. -func (h *tcpRoundRobinHandler) ReachableOn() []netip.Addr { - return h.ReachableIPs -} - -func (h *tcpRoundRobinHandler) Handle(c net.Conn) { - addrPortStr := c.LocalAddr().String() - _, port, err := net.SplitHostPort(addrPortStr) - if err != nil { - log.Printf("tcpRoundRobinHandler.Handle: bogus addrPort %q", addrPortStr) - c.Close() - return - } - - var p tcpproxy.Proxy - p.ListenFunc = func(net, laddr string) (net.Listener, error) { - return netutil.NewOneConnListener(c, nil), nil - } - - dest := h.To[rand.IntN(len(h.To))] - dial := &tcpproxy.DialProxy{ - Addr: fmt.Sprintf("%s:%s", dest, port), - DialContext: h.DialContext, - } - - p.AddRoute(addrPortStr, dial) - p.Start() -} - -type tcpSNIHandler struct { - // Allowlist enumerates the FQDNs which may be proxied via SNI. An - // empty slice means all domains are permitted. - Allowlist []string - - // DialContext is used to make the outgoing TCP connection. - DialContext func(ctx context.Context, network, address string) (net.Conn, error) - - // ReachableIPs enumerates the IP addresses this handler is reachable on. - ReachableIPs []netip.Addr -} - -// ReachableOn returns the IP addresses this handler is reachable on. -func (h *tcpSNIHandler) ReachableOn() []netip.Addr { - return h.ReachableIPs -} - -func (h *tcpSNIHandler) Handle(c net.Conn) { - addrPortStr := c.LocalAddr().String() - _, port, err := net.SplitHostPort(addrPortStr) - if err != nil { - log.Printf("tcpSNIHandler.Handle: bogus addrPort %q", addrPortStr) - c.Close() - return - } - - 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) { - if len(h.Allowlist) > 0 { - // TODO(tom): handle subdomains - if slices.Index(h.Allowlist, sniName) < 0 { - return nil, false - } - } - - return &tcpproxy.DialProxy{ - Addr: net.JoinHostPort(sniName, port), - DialContext: h.DialContext, - }, true - }) - p.Start() -} diff --git a/cmd/sniproxy/handlers_test.go b/cmd/sniproxy/handlers_test.go deleted file mode 100644 index 4f9fc6a34b184..0000000000000 --- a/cmd/sniproxy/handlers_test.go +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "bytes" - "context" - "encoding/hex" - "io" - "net" - "net/netip" - "strings" - "testing" - - "tailscale.com/net/memnet" -) - -func echoConnOnce(conn net.Conn) { - defer conn.Close() - - b := make([]byte, 256) - n, err := conn.Read(b) - if err != nil { - return - } - - if _, err := conn.Write(b[:n]); err != nil { - return - } -} - -func TestTCPRoundRobinHandler(t *testing.T) { - h := tcpRoundRobinHandler{ - To: []string{"yeet.com"}, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - if network != "tcp" { - t.Errorf("network = %s, want %s", network, "tcp") - } - if addr != "yeet.com:22" { - t.Errorf("addr = %s, want %s", addr, "yeet.com:22") - } - - c, s := memnet.NewConn("outbound", 1024) - go echoConnOnce(s) - return c, nil - }, - } - - cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:22"), 1024) - h.Handle(sSock) - - // Test data write and read, the other end will echo back - // a single stanza - want := "hello" - if _, err := io.WriteString(cSock, want); err != nil { - t.Fatal(err) - } - got := make([]byte, len(want)) - if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil { - t.Fatal(err) - } - if string(got) != want { - t.Errorf("got %q, want %q", got, want) - } - - // The other end closed the socket after the first echo, so - // any following read should error. - io.WriteString(cSock, "deadass heres some data on god fr") - if _, err := io.ReadAtLeast(cSock, got, len(got)); err == nil { - t.Error("read succeeded on closed socket") - } -} - -// Capture of first TCP data segment for a connection to https://pkgs.tailscale.com -const tlsStart = `45000239ff1840004006f9f5c0a801f2 -c726b5efcf9e01bbe803b21394e3b752 -801801f641dc00000101080ade3474f2 -2fb93ee71603010200010001fc030303 -c3acbd19d2624765bb19af4bce03365e -1d197f5bb939cdadeff26b0f8e7a0620 -295b04127b82bae46aac4ff58cffef25 -eba75a4b7a6de729532c411bd9dd0d2c -00203a3a130113021303c02bc02fc02c -c030cca9cca8c013c014009c009d002f -003501000193caca0000000a000a0008 -1a1a001d001700180010000e000c0268 -3208687474702f312e31002b0007062a -2a03040303ff01000100000d00120010 -04030804040105030805050108060601 -000b00020100002300000033002b0029 -1a1a000100001d0020d3c76bef062979 -a812ce935cfb4dbe6b3a84dc5ba9226f -23b0f34af9d1d03b4a001b0003020002 -00120000446900050003026832000000 -170015000012706b67732e7461696c73 -63616c652e636f6d002d000201010005 -00050100000000001700003a3a000100 -0015002d000000000000000000000000 -00000000000000000000000000000000 -00000000000000000000000000000000 -0000290094006f0069e76f2016f963ad -38c8632d1f240cd75e00e25fdef295d4 -7042b26f3a9a543b1c7dc74939d77803 -20527d423ff996997bda2c6383a14f49 -219eeef8a053e90a32228df37ddbe126 -eccf6b085c93890d08341d819aea6111 -0d909f4cd6b071d9ea40618e74588a33 -90d494bbb5c3002120d5a164a16c9724 -c9ef5e540d8d6f007789a7acf9f5f16f -bf6a1907a6782ed02b` - -func fakeSNIHeader() []byte { - b, err := hex.DecodeString(strings.Replace(tlsStart, "\n", "", -1)) - if err != nil { - panic(err) - } - return b[0x34:] // trim IP + TCP header -} - -func TestTCPSNIHandler(t *testing.T) { - h := tcpSNIHandler{ - Allowlist: []string{"pkgs.tailscale.com"}, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - if network != "tcp" { - t.Errorf("network = %s, want %s", network, "tcp") - } - if addr != "pkgs.tailscale.com:443" { - t.Errorf("addr = %s, want %s", addr, "pkgs.tailscale.com:443") - } - - c, s := memnet.NewConn("outbound", 1024) - go echoConnOnce(s) - return c, nil - }, - } - - cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:443"), 1024) - h.Handle(sSock) - - // Fake a TLS handshake record with an SNI in it. - if _, err := cSock.Write(fakeSNIHeader()); err != nil { - t.Fatal(err) - } - - // Test read, the other end will echo back - // a single stanza, which is at least the beginning of the SNI header. - want := fakeSNIHeader()[:5] - if _, err := cSock.Write(want); err != nil { - t.Fatal(err) - } - got := make([]byte, len(want)) - if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, want) { - t.Errorf("got %q, want %q", got, want) - } -} diff --git a/cmd/sniproxy/server.go b/cmd/sniproxy/server.go deleted file mode 100644 index b322b6f4b1137..0000000000000 --- a/cmd/sniproxy/server.go +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "expvar" - "log" - "net" - "net/netip" - "sync" - "time" - - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/metrics" - "tailscale.com/tailcfg" - "tailscale.com/types/appctype" - "tailscale.com/types/ipproto" - "tailscale.com/types/nettype" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" -) - -var tsMBox = dnsmessage.MustNewName("support.tailscale.com.") - -// target describes the predicates which route some inbound -// traffic to the app connector to a specific handler. -type target struct { - Dest netip.Prefix - Matching tailcfg.ProtoPortRange -} - -// Server implements an App Connector as expressed in sniproxy. -type Server struct { - mu sync.RWMutex // mu guards following fields - connectors map[appctype.ConfigID]connector -} - -type appcMetrics struct { - dnsResponses expvar.Int - dnsFailures expvar.Int - tcpConns expvar.Int - sniConns expvar.Int - unhandledConns expvar.Int -} - -var getMetrics = sync.OnceValue[*appcMetrics](func() *appcMetrics { - m := appcMetrics{} - - stats := new(metrics.Set) - stats.Set("tls_sessions", &m.sniConns) - clientmetric.NewCounterFunc("sniproxy_tls_sessions", m.sniConns.Value) - stats.Set("tcp_sessions", &m.tcpConns) - clientmetric.NewCounterFunc("sniproxy_tcp_sessions", m.tcpConns.Value) - stats.Set("dns_responses", &m.dnsResponses) - clientmetric.NewCounterFunc("sniproxy_dns_responses", m.dnsResponses.Value) - stats.Set("dns_failed", &m.dnsFailures) - clientmetric.NewCounterFunc("sniproxy_dns_failed", m.dnsFailures.Value) - expvar.Publish("sniproxy", stats) - - return &m -}) - -// Configure applies the provided configuration to the app connector. -func (s *Server) Configure(cfg *appctype.AppConnectorConfig) { - s.mu.Lock() - defer s.mu.Unlock() - s.connectors = makeConnectorsFromConfig(cfg) - log.Printf("installed app connector config: %+v", s.connectors) -} - -// HandleTCPFlow implements tsnet.FallbackTCPHandler. -func (s *Server) HandleTCPFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { - m := getMetrics() - s.mu.RLock() - defer s.mu.RUnlock() - - for _, c := range s.connectors { - if handler, intercept := c.handleTCPFlow(src, dst, m); intercept { - return handler, intercept - } - } - - return nil, false -} - -// HandleDNS handles a DNS request to the app connector. -func (s *Server) HandleDNS(c nettype.ConnPacketConn) { - defer c.Close() - c.SetReadDeadline(time.Now().Add(5 * time.Second)) - m := getMetrics() - - buf := make([]byte, 1500) - n, err := c.Read(buf) - if err != nil { - log.Printf("HandleDNS: read failed: %v\n ", err) - m.dnsFailures.Add(1) - return - } - - addrPortStr := c.LocalAddr().String() - host, _, err := net.SplitHostPort(addrPortStr) - if err != nil { - log.Printf("HandleDNS: bogus addrPort %q", addrPortStr) - m.dnsFailures.Add(1) - return - } - localAddr, err := netip.ParseAddr(host) - if err != nil { - log.Printf("HandleDNS: bogus local address %q", host) - m.dnsFailures.Add(1) - return - } - - var msg dnsmessage.Message - err = msg.Unpack(buf[:n]) - if err != nil { - log.Printf("HandleDNS: dnsmessage unpack failed: %v\n ", err) - m.dnsFailures.Add(1) - return - } - - s.mu.RLock() - defer s.mu.RUnlock() - for _, connector := range s.connectors { - resp, err := connector.handleDNS(&msg, localAddr) - if err != nil { - log.Printf("HandleDNS: connector handling failed: %v\n", err) - m.dnsFailures.Add(1) - return - } - if len(resp) > 0 { - // This connector handled the DNS request - _, err = c.Write(resp) - if err != nil { - log.Printf("HandleDNS: write failed: %v\n", err) - m.dnsFailures.Add(1) - return - } - - m.dnsResponses.Add(1) - return - } - } -} - -// connector describes a logical collection of -// services which need to be proxied. -type connector struct { - Handlers map[target]handler -} - -// handleTCPFlow implements tsnet.FallbackTCPHandler. -func (c *connector) handleTCPFlow(src, dst netip.AddrPort, m *appcMetrics) (handler func(net.Conn), intercept bool) { - for t, h := range c.Handlers { - if t.Matching.Proto != 0 && t.Matching.Proto != int(ipproto.TCP) { - continue - } - if !t.Dest.Contains(dst.Addr()) { - continue - } - if !t.Matching.Ports.Contains(dst.Port()) { - continue - } - - switch h.(type) { - case *tcpSNIHandler: - m.sniConns.Add(1) - case *tcpRoundRobinHandler: - m.tcpConns.Add(1) - default: - log.Printf("handleTCPFlow: unhandled handler type %T", h) - } - - return h.Handle, true - } - - m.unhandledConns.Add(1) - return nil, false -} - -// handleDNS returns the DNS response to the given query. If this -// connector is unable to handle the request, nil is returned. -func (c *connector) handleDNS(req *dnsmessage.Message, localAddr netip.Addr) (response []byte, err error) { - for t, h := range c.Handlers { - if t.Dest.Contains(localAddr) { - return makeDNSResponse(req, h.ReachableOn()) - } - } - - // Did not match, signal 'not handled' to caller - return nil, nil -} - -func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (response []byte, err error) { - resp := dnsmessage.NewBuilder(response, - dnsmessage.Header{ - ID: req.Header.ID, - Response: true, - Authoritative: true, - }) - resp.EnableCompression() - - if len(req.Questions) == 0 { - response, _ = resp.Finish() - return response, nil - } - q := req.Questions[0] - err = resp.StartQuestions() - if err != nil { - return - } - resp.Question(q) - - err = resp.StartAnswers() - if err != nil { - return - } - - switch q.Type { - case dnsmessage.TypeAAAA: - for _, ip := range reachableIPs { - if ip.Is6() { - err = resp.AAAAResource( - dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120}, - dnsmessage.AAAAResource{AAAA: ip.As16()}, - ) - } - } - - case dnsmessage.TypeA: - for _, ip := range reachableIPs { - if ip.Is4() { - err = resp.AResource( - dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120}, - dnsmessage.AResource{A: ip.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 nil, err - } - return resp.Finish() -} - -type handler interface { - // Handle handles the given socket. - Handle(c net.Conn) - - // ReachableOn returns the IP addresses this handler is reachable on. - ReachableOn() []netip.Addr -} - -func installDNATHandler(d *appctype.DNATConfig, out *connector) { - // These handlers don't actually do DNAT, they just - // proxy the data over the connection. - var dialer net.Dialer - dialer.Timeout = 5 * time.Second - h := tcpRoundRobinHandler{ - To: d.To, - DialContext: dialer.DialContext, - ReachableIPs: d.Addrs, - } - - for _, addr := range d.Addrs { - for _, protoPort := range d.IP { - t := target{ - Dest: netip.PrefixFrom(addr, addr.BitLen()), - Matching: protoPort, - } - - mak.Set(&out.Handlers, t, handler(&h)) - } - } -} - -func installSNIHandler(c *appctype.SNIProxyConfig, out *connector) { - var dialer net.Dialer - dialer.Timeout = 5 * time.Second - h := tcpSNIHandler{ - Allowlist: c.AllowedDomains, - DialContext: dialer.DialContext, - ReachableIPs: c.Addrs, - } - - for _, addr := range c.Addrs { - for _, protoPort := range c.IP { - t := target{ - Dest: netip.PrefixFrom(addr, addr.BitLen()), - Matching: protoPort, - } - - mak.Set(&out.Handlers, t, handler(&h)) - } - } -} - -func makeConnectorsFromConfig(cfg *appctype.AppConnectorConfig) map[appctype.ConfigID]connector { - var connectors map[appctype.ConfigID]connector - - for cID, d := range cfg.DNAT { - c := connectors[cID] - installDNATHandler(&d, &c) - mak.Set(&connectors, cID, c) - } - for cID, d := range cfg.SNIProxy { - c := connectors[cID] - installSNIHandler(&d, &c) - mak.Set(&connectors, cID, c) - } - - return connectors -} diff --git a/cmd/sniproxy/server_test.go b/cmd/sniproxy/server_test.go deleted file mode 100644 index d56f2aa754f85..0000000000000 --- a/cmd/sniproxy/server_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "net/netip" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/tailcfg" - "tailscale.com/types/appctype" -) - -func TestMakeConnectorsFromConfig(t *testing.T) { - tcs := []struct { - name string - input *appctype.AppConnectorConfig - want map[appctype.ConfigID]connector - }{ - { - "empty", - &appctype.AppConnectorConfig{}, - nil, - }, - { - "DNAT", - &appctype.AppConnectorConfig{ - DNAT: map[appctype.ConfigID]appctype.DNATConfig{ - "swiggity_swooty": { - Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}, - To: []string{"example.org"}, - IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}}, - }, - }, - }, - map[appctype.ConfigID]connector{ - "swiggity_swooty": { - Handlers: map[target]handler{ - { - Dest: netip.MustParsePrefix("100.64.0.1/32"), - Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}, - }: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}}, - { - Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), - Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}, - }: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}}, - }, - }, - }, - }, - { - "SNIProxy", - &appctype.AppConnectorConfig{ - SNIProxy: map[appctype.ConfigID]appctype.SNIProxyConfig{ - "swiggity_swooty": { - Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}, - AllowedDomains: []string{"example.org"}, - IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}}, - }, - }, - }, - map[appctype.ConfigID]connector{ - "swiggity_swooty": { - Handlers: map[target]handler{ - { - Dest: netip.MustParsePrefix("100.64.0.1/32"), - Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}, - }: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}}, - { - Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), - Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}, - }: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}}, - }, - }, - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - connectors := makeConnectorsFromConfig(tc.input) - - if diff := cmp.Diff(connectors, tc.want, - cmpopts.IgnoreFields(tcpRoundRobinHandler{}, "DialContext"), - cmpopts.IgnoreFields(tcpSNIHandler{}, "DialContext"), - cmp.Comparer(func(x, y netip.Addr) bool { - return x == y - })); diff != "" { - t.Fatalf("mismatch (-want +got):\n%s", diff) - } - }) - } -} diff --git a/cmd/sniproxy/sniproxy.go b/cmd/sniproxy/sniproxy.go deleted file mode 100644 index fa83aaf4ab44e..0000000000000 --- a/cmd/sniproxy/sniproxy.go +++ /dev/null @@ -1,291 +0,0 @@ -// 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 can optionally forward one or more -// TCP ports to a specific destination. It only does TCP. -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "log" - "net" - "net/http" - "net/netip" - "os" - "sort" - "strconv" - "strings" - - "github.com/peterbourgon/ff/v3" - "tailscale.com/client/tailscale" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" - "tailscale.com/tsweb" - "tailscale.com/types/appctype" - "tailscale.com/types/ipproto" - "tailscale.com/types/nettype" - "tailscale.com/util/mak" -) - -const configCapKey = "tailscale.com/sniproxy" - -// portForward is the state for a single port forwarding entry, as passed to the --forward flag. -type portForward struct { - Port int - Proto string - Destination string -} - -// parseForward takes a proto/port/destination tuple as an input, as would be passed -// to the --forward command line flag, and returns a *portForward struct of those parameters. -func parseForward(value string) (*portForward, error) { - parts := strings.Split(value, "/") - if len(parts) != 3 { - return nil, errors.New("cannot parse: " + value) - } - - proto := parts[0] - if proto != "tcp" { - return nil, errors.New("unsupported forwarding protocol: " + proto) - } - port, err := strconv.ParseUint(parts[1], 10, 16) - if err != nil { - return nil, errors.New("bad forwarding port: " + parts[1]) - } - host := parts[2] - if host == "" { - return nil, errors.New("bad destination: " + value) - } - - return &portForward{Port: int(port), Proto: proto, Destination: host}, nil -} - -func main() { - // Parse flags - fs := flag.NewFlagSet("sniproxy", flag.ContinueOnError) - var ( - ports = fs.String("ports", "443", "comma-separated list of ports to proxy") - forwards = fs.String("forwards", "", "comma-separated list of ports to transparently forward, protocol/number/destination. For example, --forwards=tcp/22/github.com,tcp/5432/sql.example.com") - wgPort = fs.Int("wg-listen-port", 0, "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select") - promoteHTTPS = fs.Bool("promote-https", true, "promote HTTP to HTTPS") - debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint") - hostname = fs.String("hostname", "", "Hostname to register the service under") - ) - err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_APPC")) - if err != nil { - log.Fatal("ff.Parse") - } - - var ts tsnet.Server - defer ts.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - run(ctx, &ts, *wgPort, *hostname, *promoteHTTPS, *debugPort, *ports, *forwards) -} - -// run actually runs the sniproxy. Its separate from main() to assist in testing. -func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, promoteHTTPS bool, debugPort int, ports, forwards string) { - // Wire up Tailscale node + app connector server - hostinfo.SetApp("sniproxy") - var s sniproxy - s.ts = ts - - s.ts.Port = uint16(wgPort) - s.ts.Hostname = hostname - - lc, err := s.ts.LocalClient() - if err != nil { - log.Fatalf("LocalClient() failed: %v", err) - } - s.lc = lc - s.ts.RegisterFallbackTCPHandler(s.srv.HandleTCPFlow) - - // Start special-purpose listeners: dns, http promotion, debug server - ln, err := s.ts.Listen("udp", ":53") - if err != nil { - log.Fatalf("failed listening on port 53: %v", err) - } - defer ln.Close() - go s.serveDNS(ln) - if promoteHTTPS { - ln, err := s.ts.Listen("tcp", ":80") - if err != nil { - log.Fatalf("failed listening on port 80: %v", err) - } - defer ln.Close() - log.Printf("Promoting HTTP to HTTPS ...") - go s.promoteHTTPS(ln) - } - if debugPort != 0 { - mux := http.NewServeMux() - tsweb.Debugger(mux) - dln, err := s.ts.Listen("tcp", fmt.Sprintf(":%d", debugPort)) - if err != nil { - log.Fatalf("failed listening on debug port: %v", err) - } - defer dln.Close() - go func() { - log.Fatalf("debug serve: %v", http.Serve(dln, mux)) - }() - } - - // Finally, start mainloop to configure app connector based on information - // in the netmap. - // We set the NotifyInitialNetMap flag so we will always get woken with the - // current netmap, before only being woken on changes. - bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap|ipn.NotifyNoPrivateKeys) - if err != nil { - log.Fatalf("watching IPN bus: %v", err) - } - defer bus.Close() - for { - msg, err := bus.Next() - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - log.Fatalf("reading IPN bus: %v", err) - } - - // NetMap contains app-connector configuration - if nm := msg.NetMap; nm != nil && nm.SelfNode.Valid() { - sn := nm.SelfNode.AsStruct() - - var c appctype.AppConnectorConfig - nmConf, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorConfig](sn.CapMap, configCapKey) - if err != nil { - log.Printf("failed to read app connector configuration from coordination server: %v", err) - } else if len(nmConf) > 0 { - c = nmConf[0] - } - - if c.AdvertiseRoutes { - if err := s.advertiseRoutesFromConfig(ctx, &c); err != nil { - log.Printf("failed to advertise routes: %v", err) - } - } - - // Backwards compatibility: combine any configuration from control with flags specified - // on the command line. This is intentionally done after we advertise any routes - // because its never correct to advertise the nodes native IP addresses. - s.mergeConfigFromFlags(&c, ports, forwards) - s.srv.Configure(&c) - } - } -} - -type sniproxy struct { - srv Server - ts *tsnet.Server - lc *tailscale.LocalClient -} - -func (s *sniproxy) advertiseRoutesFromConfig(ctx context.Context, c *appctype.AppConnectorConfig) error { - // Collect the set of addresses to advertise, using a map - // to avoid duplicate entries. - addrs := map[netip.Addr]struct{}{} - for _, c := range c.SNIProxy { - for _, ip := range c.Addrs { - addrs[ip] = struct{}{} - } - } - for _, c := range c.DNAT { - for _, ip := range c.Addrs { - addrs[ip] = struct{}{} - } - } - - var routes []netip.Prefix - for a := range addrs { - routes = append(routes, netip.PrefixFrom(a, a.BitLen())) - } - sort.SliceStable(routes, func(i, j int) bool { - return routes[i].Addr().Less(routes[j].Addr()) // determinism r us - }) - - _, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - AdvertiseRoutes: routes, - }, - AdvertiseRoutesSet: true, - }) - return err -} - -func (s *sniproxy) mergeConfigFromFlags(out *appctype.AppConnectorConfig, ports, forwards string) { - ip4, ip6 := s.ts.TailscaleIPs() - - sniConfigFromFlags := appctype.SNIProxyConfig{ - Addrs: []netip.Addr{ip4, ip6}, - } - if ports != "" { - for _, portStr := range strings.Split(ports, ",") { - port, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - log.Fatalf("invalid port: %s", portStr) - } - sniConfigFromFlags.IP = append(sniConfigFromFlags.IP, tailcfg.ProtoPortRange{ - Proto: int(ipproto.TCP), - Ports: tailcfg.PortRange{First: uint16(port), Last: uint16(port)}, - }) - } - } - - var forwardConfigFromFlags []appctype.DNATConfig - for _, forwStr := range strings.Split(forwards, ",") { - if forwStr == "" { - continue - } - forw, err := parseForward(forwStr) - if err != nil { - log.Printf("invalid forwarding spec: %v", err) - continue - } - - forwardConfigFromFlags = append(forwardConfigFromFlags, appctype.DNATConfig{ - Addrs: []netip.Addr{ip4, ip6}, - To: []string{forw.Destination}, - IP: []tailcfg.ProtoPortRange{ - { - Proto: int(ipproto.TCP), - Ports: tailcfg.PortRange{First: uint16(forw.Port), Last: uint16(forw.Port)}, - }, - }, - }) - } - - if len(forwardConfigFromFlags) == 0 && len(sniConfigFromFlags.IP) == 0 { - return // no config specified on the command line - } - - mak.Set(&out.SNIProxy, "flags", sniConfigFromFlags) - for i, forward := range forwardConfigFromFlags { - mak.Set(&out.DNAT, appctype.ConfigID(fmt.Sprintf("flags_%d", i)), forward) - } -} - -func (s *sniproxy) serveDNS(ln net.Listener) { - for { - c, err := ln.Accept() - if err != nil { - log.Printf("serveDNS accept: %v", err) - return - } - go s.srv.HandleDNS(c.(nettype.ConnPacketConn)) - } -} - -func (s *sniproxy) 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/sniproxy/sniproxy_test.go b/cmd/sniproxy/sniproxy_test.go deleted file mode 100644 index cd2e070bd336f..0000000000000 --- a/cmd/sniproxy/sniproxy_test.go +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "log" - "net" - "net/http/httptest" - "net/netip" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "tailscale.com/ipn/store/mem" - "tailscale.com/net/netns" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" - "tailscale.com/tstest/integration" - "tailscale.com/tstest/integration/testcontrol" - "tailscale.com/tstest/nettest" - "tailscale.com/types/appctype" - "tailscale.com/types/ipproto" - "tailscale.com/types/key" - "tailscale.com/types/logger" -) - -func TestPortForwardingArguments(t *testing.T) { - tests := []struct { - in string - wanterr string - want *portForward - }{ - {"", "", nil}, - {"bad port specifier", "cannot parse", nil}, - {"tcp/xyz/example.com", "bad forwarding port", nil}, - {"tcp//example.com", "bad forwarding port", nil}, - {"tcp/2112/", "bad destination", nil}, - {"udp/53/example.com", "unsupported forwarding protocol", nil}, - {"tcp/22/github.com", "", &portForward{Proto: "tcp", Port: 22, Destination: "github.com"}}, - } - for _, tt := range tests { - got, goterr := parseForward(tt.in) - if tt.wanterr != "" { - if !strings.Contains(goterr.Error(), tt.wanterr) { - t.Errorf("f(%q).err = %v; want %v", tt.in, goterr, tt.wanterr) - } - } else if diff := cmp.Diff(got, tt.want); diff != "" { - t.Errorf("Parsed forward (-got, +want):\n%s", diff) - } - } -} - -var verboseDERP = flag.Bool("verbose-derp", false, "if set, print DERP and STUN logs") -var verboseNodes = flag.Bool("verbose-nodes", false, "if set, print tsnet.Server logs") - -func startControl(t *testing.T) (control *testcontrol.Server, controlURL string) { - // Corp#4520: don't use netns for tests. - netns.SetEnabled(false) - t.Cleanup(func() { - netns.SetEnabled(true) - }) - - derpLogf := logger.Discard - if *verboseDERP { - derpLogf = t.Logf - } - derpMap := integration.RunDERPAndSTUN(t, derpLogf, "127.0.0.1") - control = &testcontrol.Server{ - DERPMap: derpMap, - DNSConfig: &tailcfg.DNSConfig{ - Proxied: true, - }, - MagicDNSDomain: "tail-scale.ts.net", - } - control.HTTPTestServer = httptest.NewUnstartedServer(control) - control.HTTPTestServer.Start() - t.Cleanup(control.HTTPTestServer.Close) - controlURL = control.HTTPTestServer.URL - t.Logf("testcontrol listening on %s", controlURL) - return control, controlURL -} - -func startNode(t *testing.T, ctx context.Context, controlURL, hostname string) (*tsnet.Server, key.NodePublic, netip.Addr) { - t.Helper() - - tmp := filepath.Join(t.TempDir(), hostname) - os.MkdirAll(tmp, 0755) - s := &tsnet.Server{ - Dir: tmp, - ControlURL: controlURL, - Hostname: hostname, - Store: new(mem.Store), - Ephemeral: true, - } - if *verboseNodes { - s.Logf = log.Printf - } - t.Cleanup(func() { s.Close() }) - - status, err := s.Up(ctx) - if err != nil { - t.Fatal(err) - } - return s, status.Self.PublicKey, status.TailscaleIPs[0] -} - -func TestSNIProxyWithNetmapConfig(t *testing.T) { - nettest.SkipIfNoNetwork(t) - c, controlURL := startControl(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Create a listener to proxy connections to. - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - - // Start sniproxy - sni, nodeKey, ip := startNode(t, ctx, controlURL, "snitest") - go run(ctx, sni, 0, sni.Hostname, false, 0, "", "") - - // Configure the mock coordination server to send down app connector config. - config := &appctype.AppConnectorConfig{ - DNAT: map[appctype.ConfigID]appctype.DNATConfig{ - "nic_test": { - Addrs: []netip.Addr{ip}, - To: []string{"127.0.0.1"}, - IP: []tailcfg.ProtoPortRange{ - { - Proto: int(ipproto.TCP), - Ports: tailcfg.PortRange{First: uint16(ln.Addr().(*net.TCPAddr).Port), Last: uint16(ln.Addr().(*net.TCPAddr).Port)}, - }, - }, - }, - }, - } - b, err := json.Marshal(config) - if err != nil { - t.Fatal(err) - } - c.SetNodeCapMap(nodeKey, tailcfg.NodeCapMap{ - configCapKey: []tailcfg.RawMessage{tailcfg.RawMessage(b)}, - }) - - // Lets spin up a second node (to represent the client). - client, _, _ := startNode(t, ctx, controlURL, "client") - - // Make sure that the sni node has received its config. - l, err := sni.LocalClient() - if err != nil { - t.Fatal(err) - } - gotConfigured := false - for range 100 { - s, err := l.StatusWithoutPeers(ctx) - if err != nil { - t.Fatal(err) - } - if len(s.Self.CapMap) > 0 { - gotConfigured = true - break // we got it - } - time.Sleep(10 * time.Millisecond) - } - if !gotConfigured { - t.Error("sni node never received its configuration from the coordination server!") - } - - // Lets make the client open a connection to the sniproxy node, and - // make sure it results in a connection to our test listener. - w, err := client.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", ip, ln.Addr().(*net.TCPAddr).Port)) - if err != nil { - t.Fatal(err) - } - defer w.Close() - - r, err := ln.Accept() - if err != nil { - t.Fatal(err) - } - r.Close() -} - -func TestSNIProxyWithFlagConfig(t *testing.T) { - nettest.SkipIfNoNetwork(t) - _, controlURL := startControl(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Create a listener to proxy connections to. - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - - // Start sniproxy - sni, _, ip := startNode(t, ctx, controlURL, "snitest") - go run(ctx, sni, 0, sni.Hostname, false, 0, "", fmt.Sprintf("tcp/%d/localhost", ln.Addr().(*net.TCPAddr).Port)) - - // Lets spin up a second node (to represent the client). - client, _, _ := startNode(t, ctx, controlURL, "client") - - // Lets make the client open a connection to the sniproxy node, and - // make sure it results in a connection to our test listener. - w, err := client.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", ip, ln.Addr().(*net.TCPAddr).Port)) - if err != nil { - t.Fatal(err) - } - defer w.Close() - - r, err := ln.Accept() - if err != nil { - t.Fatal(err) - } - r.Close() -} diff --git a/cmd/speedtest/speedtest.go b/cmd/speedtest/speedtest.go deleted file mode 100644 index 9a457ed6c7486..0000000000000 --- a/cmd/speedtest/speedtest.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Program speedtest provides the speedtest command. The reason to keep it separate from -// the normal tailscale cli is because it is not yet ready to go in the tailscale binary. -// It will be included in the tailscale cli after it has been added to tailscaled. - -// Example usage for client command: go run cmd/speedtest -host 127.0.0.1:20333 -t 5s -// This will connect to the server on 127.0.0.1:20333 and start a 5 second download speedtest. -// Example usage for server command: go run cmd/speedtest -s -host :20333 -// This will start a speedtest server on port 20333. -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "net" - "os" - "strconv" - "text/tabwriter" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/net/speedtest" -) - -// Runs the speedtest command as a commandline program -func main() { - args := os.Args[1:] - if err := speedtestCmd.Parse(args); err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) - } - - err := speedtestCmd.Run(context.Background()) - if errors.Is(err, flag.ErrHelp) { - fmt.Fprintln(os.Stderr, speedtestCmd.ShortUsage) - os.Exit(2) - } - if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) - } -} - -// speedtestCmd is the root command. It runs either the server or client depending on the -// flags passed to it. -var speedtestCmd = &ffcli.Command{ - Name: "speedtest", - ShortUsage: "speedtest [-host ] [-s] [-r] [-t ]", - ShortHelp: "Run a speed test", - FlagSet: (func() *flag.FlagSet { - fs := flag.NewFlagSet("speedtest", flag.ExitOnError) - fs.StringVar(&speedtestArgs.host, "host", ":20333", "host:port pair to connect to or listen on") - fs.DurationVar(&speedtestArgs.testDuration, "t", speedtest.DefaultDuration, "duration of the speed test") - fs.BoolVar(&speedtestArgs.runServer, "s", false, "run a speedtest server") - fs.BoolVar(&speedtestArgs.reverse, "r", false, "run in reverse mode (server sends, client receives)") - return fs - })(), - Exec: runSpeedtest, -} - -var speedtestArgs struct { - host string - testDuration time.Duration - runServer bool - reverse bool -} - -func runSpeedtest(ctx context.Context, args []string) error { - - if _, _, err := net.SplitHostPort(speedtestArgs.host); err != nil { - var addrErr *net.AddrError - if errors.As(err, &addrErr) && addrErr.Err == "missing port in address" { - // if no port is provided, append the default port - speedtestArgs.host = net.JoinHostPort(speedtestArgs.host, strconv.Itoa(speedtest.DefaultPort)) - } - } - - if speedtestArgs.runServer { - listener, err := net.Listen("tcp", speedtestArgs.host) - if err != nil { - return err - } - - fmt.Printf("listening on %v\n", listener.Addr()) - - return speedtest.Serve(listener) - } - - // Ensure the duration is within the allowed range - if speedtestArgs.testDuration < speedtest.MinDuration || speedtestArgs.testDuration > speedtest.MaxDuration { - return fmt.Errorf("test duration must be within %v and %v", speedtest.MinDuration, speedtest.MaxDuration) - } - - dir := speedtest.Download - if speedtestArgs.reverse { - dir = speedtest.Upload - } - - fmt.Printf("Starting a %s test with %s\n", dir, speedtestArgs.host) - results, err := speedtest.RunClient(dir, speedtestArgs.testDuration, speedtestArgs.host) - if err != nil { - return err - } - - w := tabwriter.NewWriter(os.Stdout, 12, 0, 0, ' ', tabwriter.TabIndent) - fmt.Println("Results:") - fmt.Fprintln(w, "Interval\t\tTransfer\t\tBandwidth\t\t") - startTime := results[0].IntervalStart - for _, r := range results { - if r.Total { - fmt.Fprintln(w, "-------------------------------------------------------------------------") - } - fmt.Fprintf(w, "%.2f-%.2f\tsec\t%.4f\tMBits\t%.4f\tMbits/sec\t\n", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds(), r.MegaBits(), r.MBitsPerSecond()) - } - w.Flush() - return nil -} diff --git a/cmd/ssh-auth-none-demo/ssh-auth-none-demo.go b/cmd/ssh-auth-none-demo/ssh-auth-none-demo.go deleted file mode 100644 index ee929299a4273..0000000000000 --- a/cmd/ssh-auth-none-demo/ssh-auth-none-demo.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// ssh-auth-none-demo is a demo SSH server that's meant to run on the -// public internet (at 188.166.70.128 port 2222) and -// highlight the unique parts of the Tailscale SSH server so SSH -// client authors can hit it easily and fix their SSH clients without -// needing to set up Tailscale and Tailscale SSH. -package main - -import ( - "crypto/ecdsa" - "crypto/ed25519" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "flag" - "fmt" - "io" - "log" - "os" - "path/filepath" - "time" - - gossh "github.com/tailscale/golang-x-crypto/ssh" - "tailscale.com/tempfork/gliderlabs/ssh" -) - -// keyTypes are the SSH key types that we either try to read from the -// system's OpenSSH keys. -var keyTypes = []string{"rsa", "ecdsa", "ed25519"} - -var ( - addr = flag.String("addr", ":2222", "address to listen on") -) - -func main() { - flag.Parse() - - cacheDir, err := os.UserCacheDir() - if err != nil { - log.Fatal(err) - } - dir := filepath.Join(cacheDir, "ssh-auth-none-demo") - if err := os.MkdirAll(dir, 0700); err != nil { - log.Fatal(err) - } - - keys, err := getHostKeys(dir) - if err != nil { - log.Fatal(err) - } - if len(keys) == 0 { - log.Fatal("no host keys") - } - - srv := &ssh.Server{ - Addr: *addr, - Version: "Tailscale", - Handler: handleSessionPostSSHAuth, - ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig { - start := time.Now() - return &gossh.ServerConfig{ - NextAuthMethodCallback: func(conn gossh.ConnMetadata, prevErrors []error) []string { - return []string{"tailscale"} - }, - NoClientAuth: true, // required for the NoClientAuthCallback to run - NoClientAuthCallback: func(cm gossh.ConnMetadata) (*gossh.Permissions, error) { - cm.SendAuthBanner(fmt.Sprintf("# Banner: doing none auth at %v\r\n", time.Since(start))) - - totalBanners := 2 - if cm.User() == "banners" { - totalBanners = 5 - } - for banner := 2; banner <= totalBanners; banner++ { - time.Sleep(time.Second) - if banner == totalBanners { - cm.SendAuthBanner(fmt.Sprintf("# Banner%d: access granted at %v\r\n", banner, time.Since(start))) - } else { - cm.SendAuthBanner(fmt.Sprintf("# Banner%d at %v\r\n", banner, time.Since(start))) - } - } - return nil, nil - }, - BannerCallback: func(cm gossh.ConnMetadata) string { - log.Printf("Got connection from user %q, %q from %v", cm.User(), cm.ClientVersion(), cm.RemoteAddr()) - return fmt.Sprintf("# Banner for user %q, %q\n", cm.User(), cm.ClientVersion()) - }, - } - }, - } - - for _, signer := range keys { - srv.AddHostKey(signer) - } - - log.Printf("Running on %s ...", srv.Addr) - if err := srv.ListenAndServe(); err != nil { - log.Fatal(err) - } - log.Printf("done") -} - -func handleSessionPostSSHAuth(s ssh.Session) { - log.Printf("Started session from user %q", s.User()) - fmt.Fprintf(s, "Hello user %q, it worked.\n", s.User()) - - // Abort the session on Control-C or Control-D. - go func() { - buf := make([]byte, 1024) - for { - n, err := s.Read(buf) - for _, b := range buf[:n] { - if b <= 4 { // abort on Control-C (3) or Control-D (4) - io.WriteString(s, "bye\n") - s.Exit(1) - } - } - if err != nil { - return - } - } - }() - - for i := 10; i > 0; i-- { - fmt.Fprintf(s, "%v ...\n", i) - time.Sleep(time.Second) - } - s.Exit(0) -} - -func getHostKeys(dir string) (ret []ssh.Signer, err error) { - for _, typ := range keyTypes { - hostKey, err := hostKeyFileOrCreate(dir, typ) - if err != nil { - return nil, err - } - signer, err := gossh.ParsePrivateKey(hostKey) - if err != nil { - return nil, err - } - ret = append(ret, signer) - } - return ret, nil -} - -func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) { - path := filepath.Join(keyDir, "ssh_host_"+typ+"_key") - v, err := os.ReadFile(path) - if err == nil { - return v, nil - } - if !os.IsNotExist(err) { - return nil, err - } - var priv any - switch typ { - default: - return nil, fmt.Errorf("unsupported key type %q", typ) - case "ed25519": - _, priv, err = ed25519.GenerateKey(rand.Reader) - case "ecdsa": - // curve is arbitrary. We pick whatever will at - // least pacify clients as the actual encryption - // doesn't matter: it's all over WireGuard anyway. - curve := elliptic.P256() - priv, err = ecdsa.GenerateKey(curve, rand.Reader) - case "rsa": - // keySize is arbitrary. We pick whatever will at - // least pacify clients as the actual encryption - // doesn't matter: it's all over WireGuard anyway. - const keySize = 2048 - priv, err = rsa.GenerateKey(rand.Reader, keySize) - } - if err != nil { - return nil, err - } - mk, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - return nil, err - } - pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) - err = os.WriteFile(path, pemGen, 0700) - return pemGen, err -} diff --git a/cmd/stunc/stunc.go b/cmd/stunc/stunc.go deleted file mode 100644 index 9743a33007265..0000000000000 --- a/cmd/stunc/stunc.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Command stunc makes a STUN request to a STUN server and prints the result. -package main - -import ( - "log" - "net" - "os" - "strconv" - - "tailscale.com/net/stun" -) - -func main() { - log.SetFlags(0) - - if len(os.Args) < 2 || len(os.Args) > 3 { - log.Fatalf("usage: %s [port]", os.Args[0]) - } - host := os.Args[1] - port := "3478" - if len(os.Args) == 3 { - port = os.Args[2] - } - _, err := strconv.ParseUint(port, 10, 16) - if err != nil { - log.Fatalf("invalid port: %v", err) - } - - uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, port)) - if err != nil { - log.Fatal(err) - } - c, err := net.ListenUDP("udp", nil) - if err != nil { - log.Fatal(err) - } - - txID := stun.NewTxID() - req := stun.Request(txID) - - _, err = c.WriteToUDP(req, uaddr) - if err != nil { - log.Fatal(err) - } - - var buf [1024]byte - n, raddr, err := c.ReadFromUDPAddrPort(buf[:]) - if err != nil { - log.Fatal(err) - } - - tid, saddr, err := stun.ParseResponse(buf[:n]) - if err != nil { - log.Fatal(err) - } - if tid != txID { - log.Fatalf("txid mismatch: got %v, want %v", tid, txID) - } - - log.Printf("sent addr: %v", uaddr) - log.Printf("from addr: %v", raddr) - log.Printf("stun addr: %v", saddr) -} diff --git a/cmd/stund/depaware.txt b/cmd/stund/depaware.txt deleted file mode 100644 index 34a71c43e0010..0000000000000 --- a/cmd/stund/depaware.txt +++ /dev/null @@ -1,200 +0,0 @@ -tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depaware) - - 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/go-json-experiment/json from tailscale.com/types/opt - github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ - 💣 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 - 💣 go4.org/mem from tailscale.com/metrics+ - go4.org/netipx from tailscale.com/net/tsaddr - google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt - google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+ - google.golang.org/protobuf/encoding/protowire from google.golang.org/protobuf/encoding/protodelim+ - 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/editiondefaults from google.golang.org/protobuf/internal/filedesc - 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/prometheus/client_golang/prometheus+ - 💣 google.golang.org/protobuf/reflect/protoreflect from github.com/prometheus/client_model/go+ - google.golang.org/protobuf/reflect/protoregistry from google.golang.org/protobuf/encoding/prototext+ - google.golang.org/protobuf/runtime/protoiface from google.golang.org/protobuf/internal/impl+ - google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+ - google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+ - tailscale.com from tailscale.com/version - tailscale.com/envknob from tailscale.com/tsweb+ - tailscale.com/kube/kubetypes from tailscale.com/envknob - tailscale.com/metrics from tailscale.com/net/stunserver+ - tailscale.com/net/netaddr from tailscale.com/net/tsaddr - tailscale.com/net/stun from tailscale.com/net/stunserver - tailscale.com/net/stunserver from tailscale.com/cmd/stund - tailscale.com/net/tsaddr from tailscale.com/tsweb - tailscale.com/tailcfg from tailscale.com/version - tailscale.com/tsweb from tailscale.com/cmd/stund - 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/ipproto from tailscale.com/tailcfg - tailscale.com/types/key from tailscale.com/tailcfg - tailscale.com/types/lazy from tailscale.com/version+ - tailscale.com/types/logger from tailscale.com/tsweb - tailscale.com/types/opt from tailscale.com/envknob+ - tailscale.com/types/ptr from tailscale.com/tailcfg+ - tailscale.com/types/result from tailscale.com/util/lineiter - tailscale.com/types/structs from tailscale.com/tailcfg+ - tailscale.com/types/tkatype from tailscale.com/tailcfg+ - tailscale.com/types/views from tailscale.com/net/tsaddr+ - tailscale.com/util/ctxkey from tailscale.com/tsweb+ - L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics - tailscale.com/util/dnsname from tailscale.com/tailcfg - tailscale.com/util/lineiter from tailscale.com/version/distro - tailscale.com/util/nocasemaps from tailscale.com/types/ipproto - tailscale.com/util/rands from tailscale.com/tsweb - tailscale.com/util/slicesx from tailscale.com/tailcfg - tailscale.com/util/vizerror from tailscale.com/tailcfg+ - tailscale.com/version from tailscale.com/envknob+ - tailscale.com/version/distro from tailscale.com/envknob - golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box - golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 - golang.org/x/crypto/chacha20poly1305 from crypto/tls+ - golang.org/x/crypto/cryptobyte from crypto/ecdsa+ - golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+ - golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/hkdf from crypto/tls+ - golang.org/x/crypto/nacl/box from tailscale.com/types/key - golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box - golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/sha3 from crypto/internal/mlkem768+ - 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/http2/hpack from net/http - golang.org/x/net/idna from golang.org/x/net/http/httpguts+ - D golang.org/x/net/route from net - golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+ - LD golang.org/x/sys/unix from github.com/prometheus/procfs+ - W golang.org/x/sys/windows from github.com/prometheus/client_golang/prometheus - golang.org/x/text/secure/bidirule from golang.org/x/net/idna - golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ - golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ - golang.org/x/text/unicode/norm from golang.org/x/net/idna - bufio from compress/flate+ - bytes from bufio+ - cmp from slices+ - compress/flate from compress/gzip - compress/gzip from google.golang.org/protobuf/internal/impl+ - container/list from crypto/tls+ - context from crypto/tls+ - crypto from crypto/ecdh+ - crypto/aes from crypto/ecdsa+ - crypto/cipher from crypto/aes+ - crypto/des from crypto/tls+ - crypto/dsa from crypto/x509 - crypto/ecdh from crypto/ecdsa+ - crypto/ecdsa from crypto/tls+ - crypto/ed25519 from crypto/tls+ - crypto/elliptic from crypto/ecdsa+ - crypto/hmac from crypto/tls+ - crypto/md5 from crypto/tls+ - crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls - crypto/rsa from crypto/tls+ - crypto/sha1 from crypto/tls+ - crypto/sha256 from crypto/tls+ - crypto/sha512 from crypto/ecdsa+ - crypto/subtle from crypto/aes+ - crypto/tls from net/http+ - crypto/x509 from crypto/tls - crypto/x509/pkix from crypto/x509 - embed from crypto/internal/nistec+ - encoding from encoding/json+ - encoding/asn1 from crypto/x509+ - encoding/base32 from github.com/go-json-experiment/json - encoding/base64 from encoding/json+ - encoding/binary from compress/gzip+ - encoding/hex from crypto/x509+ - encoding/json from expvar+ - encoding/pem from crypto/tls+ - errors from bufio+ - expvar from github.com/prometheus/client_golang/prometheus+ - flag from tailscale.com/cmd/stund - 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+ - io/fs from crypto/x509+ - io/ioutil from google.golang.org/protobuf/internal/impl - iter from maps+ - log from expvar+ - log/internal from log - maps from tailscale.com/tailcfg+ - math from compress/flate+ - math/big from crypto/dsa+ - math/bits from compress/flate+ - math/rand from math/big+ - math/rand/v2 from internal/concurrent+ - mime from github.com/prometheus/common/expfmt+ - mime/multipart from net/http - mime/quotedprintable from mime/multipart - net from crypto/tls+ - net/http from expvar+ - net/http/httptrace from net/http - net/http/internal from net/http - 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+ - os from crypto/rand+ - os/signal from tailscale.com/cmd/stund - path from github.com/prometheus/client_golang/prometheus/internal+ - path/filepath from crypto/x509+ - reflect from crypto/x509+ - regexp from github.com/prometheus/client_golang/prometheus/internal+ - regexp/syntax from regexp - runtime/debug from github.com/prometheus/client_golang/prometheus+ - runtime/metrics from github.com/prometheus/client_golang/prometheus+ - runtime/pprof from net/http/pprof - runtime/trace from net/http/pprof - slices from tailscale.com/metrics+ - sort from compress/flate+ - strconv from compress/flate+ - strings from bufio+ - sync from compress/flate+ - sync/atomic from context+ - syscall from crypto/rand+ - text/tabwriter from runtime/pprof - time from compress/gzip+ - unicode from bytes+ - unicode/utf16 from crypto/x509+ - unicode/utf8 from bufio+ - unique from net/netip diff --git a/cmd/stund/stund.go b/cmd/stund/stund.go deleted file mode 100644 index c38429169b066..0000000000000 --- a/cmd/stund/stund.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The stund binary is a standalone STUN server. -package main - -import ( - "context" - "flag" - "io" - "log" - "net/http" - "os/signal" - "syscall" - - "tailscale.com/net/stunserver" - "tailscale.com/tsweb" -) - -var ( - stunAddr = flag.String("stun", ":3478", "UDP address on which to start the STUN server") - httpAddr = flag.String("http", ":3479", "address on which to start the debug http server") -) - -func main() { - flag.Parse() - - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - log.Printf("HTTP server listening on %s", *httpAddr) - go http.ListenAndServe(*httpAddr, mux()) - - s := stunserver.New(ctx) - if err := s.ListenAndServe(*stunAddr); err != nil { - log.Fatal(err) - } -} - -func mux() *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - io.WriteString(w, "

stund

/debug") - }) - debug := tsweb.Debugger(mux) - debug.KV("stun_addr", *stunAddr) - return mux -} diff --git a/cmd/stunstamp/stunstamp.go b/cmd/stunstamp/stunstamp.go deleted file mode 100644 index c3842e2e8b3be..0000000000000 --- a/cmd/stunstamp/stunstamp.go +++ /dev/null @@ -1,1111 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The stunstamp binary measures round-trip latency with DERPs. -package main - -import ( - "bytes" - "cmp" - "context" - "crypto/tls" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "math" - "math/rand/v2" - "net" - "net/http" - "net/netip" - "net/url" - "os" - "os/signal" - "runtime" - "slices" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/golang/snappy" - "github.com/prometheus/prometheus/prompb" - "github.com/tcnksm/go-httpstat" - "tailscale.com/logtail/backoff" - "tailscale.com/net/stun" - "tailscale.com/net/tcpinfo" - "tailscale.com/tailcfg" -) - -var ( - flagDERPMap = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map") - flagInterval = flag.Duration("interval", time.Minute, "interval to probe at in time.ParseDuration() format") - flagIPv6 = flag.Bool("ipv6", false, "probe IPv6 addresses") - flagRemoteWriteURL = flag.String("rw-url", "", "prometheus remote write URL") - flagInstance = flag.String("instance", "", "instance label value; defaults to hostname if unspecified") - flagSTUNDstPorts = flag.String("stun-dst-ports", "", "comma-separated list of STUN destination ports to monitor") - flagHTTPSDstPorts = flag.String("https-dst-ports", "", "comma-separated list of HTTPS destination ports to monitor") - flagTCPDstPorts = flag.String("tcp-dst-ports", "", "comma-separated list of TCP destination ports to monitor") - flagICMP = flag.Bool("icmp", false, "probe ICMP") -) - -const ( - // maxTxJitter is the upper bounds for jitter introduced across probes - maxTXJitter = time.Millisecond * 400 - // minInterval is the minimum allowed probe interval/step - minInterval = time.Second * 10 - // txRxTimeout is the timeout value used for kernel timestamping loopback, - // and packet receive operations - txRxTimeout = time.Second * 2 - // maxBufferDuration is the maximum duration (maxBufferDuration / - // *flagInterval steps worth) of buffered data that can be held in memory - // before data loss occurs around prometheus unavailability. - maxBufferDuration = time.Hour -) - -func getDERPMap(ctx context.Context, url string) (*tailcfg.DERPMap, error) { - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, fmt.Errorf("non-200 derp map resp: %d", resp.StatusCode) - } - dm := tailcfg.DERPMap{} - err = json.NewDecoder(resp.Body).Decode(&dm) - if err != nil { - return nil, fmt.Errorf("failed to decode derp map resp: %v", err) - } - return &dm, nil -} - -type timestampSource int - -const ( - timestampSourceUserspace timestampSource = iota - timestampSourceKernel -) - -func (t timestampSource) String() string { - switch t { - case timestampSourceUserspace: - return "userspace" - case timestampSourceKernel: - return "kernel" - default: - return "unknown" - } -} - -type protocol string - -const ( - protocolSTUN protocol = "stun" - protocolICMP protocol = "icmp" - protocolHTTPS protocol = "https" - protocolTCP protocol = "tcp" -) - -// resultKey contains the stable dimensions and their values for a given -// timeseries, i.e. not time and not rtt/timeout. -type resultKey struct { - meta nodeMeta - timestampSource timestampSource - connStability connStability - protocol protocol - dstPort int -} - -type result struct { - key resultKey - at time.Time - rtt *time.Duration // nil signifies failure, e.g. timeout -} - -type lportsPool struct { - sync.Mutex - ports []int -} - -func (l *lportsPool) get() int { - l.Lock() - defer l.Unlock() - ret := l.ports[0] - l.ports = append(l.ports[:0], l.ports[1:]...) - return ret -} - -func (l *lportsPool) put(i int) { - l.Lock() - defer l.Unlock() - l.ports = append(l.ports, int(i)) -} - -var ( - lports *lportsPool -) - -const ( - lportPoolSize = 16000 - lportBase = 2048 -) - -func init() { - lports = &lportsPool{ - ports: make([]int, 0, lportPoolSize), - } - for i := lportBase; i < lportBase+lportPoolSize; i++ { - lports.ports = append(lports.ports, i) - } -} - -// lportForTCPConn satisfies io.ReadWriteCloser, but is really just used to pass -// around a persistent laddr for stableConn purposes. The underlying TCP -// connection is not created until measurement time as in some cases we need to -// measure dial time. -type lportForTCPConn int - -func (l *lportForTCPConn) Close() error { - if *l == 0 { - return nil - } - lports.put(int(*l)) - return nil -} - -func (l *lportForTCPConn) Write([]byte) (int, error) { - return 0, errors.New("unimplemented") -} - -func (l *lportForTCPConn) Read([]byte) (int, error) { - return 0, errors.New("unimplemented") -} - -func addrInUse(err error, lport *lportForTCPConn) bool { - if errors.Is(err, syscall.EADDRINUSE) { - old := int(*lport) - // abandon port, don't return it to pool - *lport = lportForTCPConn(lports.get()) // get a new port - log.Printf("EADDRINUSE: %v old: %d new: %d", err, old, *lport) - return true - } - return false -} - -func tcpDial(ctx context.Context, lport *lportForTCPConn, dst netip.AddrPort) (net.Conn, error) { - for { - var opErr error - dialer := &net.Dialer{ - LocalAddr: &net.TCPAddr{ - Port: int(*lport), - }, - Control: func(network, address string, c syscall.RawConn) error { - return c.Control(func(fd uintptr) { - // we may restart faster than TIME_WAIT can clear - opErr = setSOReuseAddr(fd) - }) - }, - } - if opErr != nil { - panic(opErr) - } - tcpConn, err := dialer.DialContext(ctx, "tcp", dst.String()) - if err != nil { - if addrInUse(err, lport) { - continue - } - return nil, err - } - return tcpConn, nil - } -} - -type tempError struct { - error -} - -func (t tempError) Temporary() bool { - return true -} - -func measureTCPRTT(conn io.ReadWriteCloser, _ string, dst netip.AddrPort) (rtt time.Duration, err error) { - lport, ok := conn.(*lportForTCPConn) - if !ok { - return 0, fmt.Errorf("unexpected conn type: %T", conn) - } - // Set a dial timeout < 1s (TCP_TIMEOUT_INIT on Linux) as a means to avoid - // SYN retries, which can contribute to tcpi->rtt below. This simply limits - // retries from the initiator, but SYN+ACK on the reverse path can also - // time out and be retransmitted. - ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*750) - defer cancel() - tcpConn, err := tcpDial(ctx, lport, dst) - if err != nil { - return 0, tempError{err} - } - defer tcpConn.Close() - // This is an unreliable method to measure TCP RTT. The Linux kernel - // describes it as such in tcp_rtt_estimator(). We take some care in how we - // hold tcp_info->rtt here, e.g. clamping dial timeout, but if we are to - // actually use this elsewhere as an input to some decision it warrants a - // deeper study and consideration for alternative methods. Its usefulness - // here is as a point of comparison against the other methods. - rtt, err = tcpinfo.RTT(tcpConn) - if err != nil { - return 0, tempError{err} - } - return rtt, nil -} - -func measureHTTPSRTT(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) { - lport, ok := conn.(*lportForTCPConn) - if !ok { - return 0, fmt.Errorf("unexpected conn type: %T", conn) - } - var httpResult httpstat.Result - // 5s mirrors net/netcheck.overallProbeTimeout used in net/netcheck.Client.measureHTTPSLatency. - reqCtx, cancel := context.WithTimeout(httpstat.WithHTTPStat(context.Background(), &httpResult), time.Second*5) - defer cancel() - reqURL := "https://" + dst.String() + "/derp/latency-check" - req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil) - if err != nil { - return 0, err - } - client := &http.Client{} - // 1.5s mirrors derp/derphttp.dialnodeTimeout used in derp/derphttp.DialNode(). - dialCtx, dialCancel := context.WithTimeout(reqCtx, time.Millisecond*1500) - defer dialCancel() - tcpConn, err := tcpDial(dialCtx, lport, dst) - if err != nil { - return 0, tempError{err} - } - defer tcpConn.Close() - tlsConn := tls.Client(tcpConn, &tls.Config{ - ServerName: hostname, - }) - // Mirror client/netcheck behavior, which handshakes before handing the - // tlsConn over to the http.Client via http.Transport - err = tlsConn.Handshake() - if err != nil { - return 0, tempError{err} - } - tlsConnCh := make(chan net.Conn, 1) - tlsConnCh <- tlsConn - tr := &http.Transport{ - DialTLSContext: func(ctx context.Context, network string, addr string) (net.Conn, error) { - select { - case tlsConn := <-tlsConnCh: - return tlsConn, nil - default: - return nil, errors.New("unexpected second call of DialTLSContext") - } - }, - } - client.Transport = tr - resp, err := client.Do(req) - if err != nil { - return 0, tempError{err} - } - if resp.StatusCode/100 != 2 { - return 0, tempError{fmt.Errorf("unexpected status code: %d", resp.StatusCode)} - } - defer resp.Body.Close() - _, err = io.Copy(io.Discard, io.LimitReader(resp.Body, 8<<10)) - if err != nil { - return 0, tempError{err} - } - httpResult.End(time.Now()) - return httpResult.ServerProcessing, nil -} - -func measureSTUNRTT(conn io.ReadWriteCloser, _ string, dst netip.AddrPort) (rtt time.Duration, err error) { - uconn, ok := conn.(*net.UDPConn) - if !ok { - return 0, fmt.Errorf("unexpected conn type: %T", conn) - } - err = uconn.SetReadDeadline(time.Now().Add(txRxTimeout)) - if err != nil { - return 0, fmt.Errorf("error setting read deadline: %w", err) - } - txID := stun.NewTxID() - req := stun.Request(txID) - txAt := time.Now() - _, err = uconn.WriteToUDP(req, &net.UDPAddr{ - IP: dst.Addr().AsSlice(), - Port: int(dst.Port()), - }) - if err != nil { - return 0, fmt.Errorf("error writing to udp socket: %w", err) - } - b := make([]byte, 1460) - for { - n, err := uconn.Read(b) - rxAt := time.Now() - if err != nil { - return 0, fmt.Errorf("error reading from udp socket: %w", err) - } - gotTxID, _, err := stun.ParseResponse(b[:n]) - if err != nil || gotTxID != txID { - continue - } - return rxAt.Sub(txAt), nil - } - -} - -func isTemporaryOrTimeoutErr(err error) bool { - if errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { - return true - } - if err, ok := err.(interface{ Temporary() bool }); ok { - return err.Temporary() - } - return false -} - -type nodeMeta struct { - regionID int - regionCode string - hostname string - addr netip.Addr -} - -type measureFn func(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) - -// nodeMetaFromDERPMap parses the provided DERP map in order to update nodeMeta -// in the provided nodeMetaByAddr. It returns a slice of nodeMeta containing -// the nodes that are no longer seen in the DERP map, but were previously held -// in nodeMetaByAddr. -func nodeMetaFromDERPMap(dm *tailcfg.DERPMap, nodeMetaByAddr map[netip.Addr]nodeMeta, ipv6 bool) (stale []nodeMeta, err error) { - // Parse the new derp map before making any state changes in nodeMetaByAddr. - // If parse fails we just stick with the old state. - updated := make(map[netip.Addr]nodeMeta) - for regionID, region := range dm.Regions { - for _, node := range region.Nodes { - v4, err := netip.ParseAddr(node.IPv4) - if err != nil || !v4.Is4() { - return nil, fmt.Errorf("invalid ipv4 addr for node in derp map: %v", node.Name) - } - metas := make([]nodeMeta, 0, 2) - metas = append(metas, nodeMeta{ - regionID: regionID, - regionCode: region.RegionCode, - hostname: node.HostName, - addr: v4, - }) - if ipv6 { - v6, err := netip.ParseAddr(node.IPv6) - if err != nil || !v6.Is6() { - return nil, fmt.Errorf("invalid ipv6 addr for node in derp map: %v", node.Name) - } - metas = append(metas, metas[0]) - metas[1].addr = v6 - } - for _, meta := range metas { - updated[meta.addr] = meta - } - } - } - - // Find nodeMeta that have changed - for addr, updatedMeta := range updated { - previousMeta, ok := nodeMetaByAddr[addr] - if ok { - if previousMeta == updatedMeta { - continue - } - stale = append(stale, previousMeta) - nodeMetaByAddr[addr] = updatedMeta - } else { - nodeMetaByAddr[addr] = updatedMeta - } - } - - // Find nodeMeta that no longer exist - for addr, potentialStale := range nodeMetaByAddr { - _, ok := updated[addr] - if !ok { - stale = append(stale, potentialStale) - } - } - - return stale, nil -} - -type connAndMeasureFn struct { - conn io.ReadWriteCloser - fn measureFn -} - -// newConnAndMeasureFn returns a connAndMeasureFn or an error. It may return -// nil for both if some combination of the supplied timestampSource, protocol, -// or connStability is unsupported. -func newConnAndMeasureFn(forDst netip.Addr, source timestampSource, protocol protocol, stable connStability) (*connAndMeasureFn, error) { - info := getProtocolSupportInfo(protocol) - if !info.stableConn && bool(stable) { - return nil, nil - } - if !info.userspaceTS && source == timestampSourceUserspace { - return nil, nil - } - if !info.kernelTS && source == timestampSourceKernel { - return nil, nil - } - switch protocol { - case protocolSTUN: - if source == timestampSourceKernel { - conn, err := getUDPConnKernelTimestamp() - if err != nil { - return nil, err - } - return &connAndMeasureFn{ - conn: conn, - fn: measureSTUNRTTKernel, - }, nil - } else { - conn, err := net.ListenUDP("udp", &net.UDPAddr{}) - if err != nil { - return nil, err - } - return &connAndMeasureFn{ - conn: conn, - fn: measureSTUNRTT, - }, nil - } - case protocolICMP: - conn, err := getICMPConn(forDst, source) - if err != nil { - return nil, err - } - return &connAndMeasureFn{ - conn: conn, - fn: mkICMPMeasureFn(source), - }, nil - case protocolHTTPS: - localPort := 0 - if stable { - localPort = lports.get() - } - conn := lportForTCPConn(localPort) - return &connAndMeasureFn{ - conn: &conn, - fn: measureHTTPSRTT, - }, nil - case protocolTCP: - localPort := 0 - if stable { - localPort = lports.get() - } - conn := lportForTCPConn(localPort) - return &connAndMeasureFn{ - conn: &conn, - fn: measureTCPRTT, - }, nil - } - return nil, errors.New("unknown protocol") -} - -type stableConnKey struct { - node netip.Addr - protocol protocol - port int -} - -type protocolSupportInfo struct { - kernelTS bool - userspaceTS bool - stableConn bool -} - -func getConns( - stableConns map[stableConnKey][2]*connAndMeasureFn, - addr netip.Addr, - protocol protocol, - dstPort int, -) (stable, unstable [2]*connAndMeasureFn, err error) { - key := stableConnKey{addr, protocol, dstPort} - defer func() { - if err != nil { - for _, source := range []timestampSource{timestampSourceUserspace, timestampSourceKernel} { - c := stable[source] - if c != nil { - c.conn.Close() - } - c = unstable[source] - if c != nil { - c.conn.Close() - } - } - } - }() - - var ok bool - stable, ok = stableConns[key] - if !ok { - for _, source := range []timestampSource{timestampSourceUserspace, timestampSourceKernel} { - var cf *connAndMeasureFn - cf, err = newConnAndMeasureFn(addr, source, protocol, stableConn) - if err != nil { - return - } - stable[source] = cf - } - stableConns[key] = stable - } - - for _, source := range []timestampSource{timestampSourceUserspace, timestampSourceKernel} { - var cf *connAndMeasureFn - cf, err = newConnAndMeasureFn(addr, source, protocol, unstableConn) - if err != nil { - return - } - unstable[source] = cf - } - return stable, unstable, nil -} - -// probeNodes measures the round-trip time for the protocols and ports described -// by portsByProtocol against the DERP nodes described by nodeMetaByAddr. -// stableConns are used to recycle connections across calls to probeNodes. -// probeNodes is also responsible for trimming stableConns based on node -// lifetime in nodeMetaByAddr. It returns the results or an error if one occurs. -func probeNodes(nodeMetaByAddr map[netip.Addr]nodeMeta, stableConns map[stableConnKey][2]*connAndMeasureFn, portsByProtocol map[protocol][]int) ([]result, error) { - wg := sync.WaitGroup{} - results := make([]result, 0) - resultsCh := make(chan result) - errCh := make(chan error) - doneCh := make(chan struct{}) - numProbes := 0 - at := time.Now() - addrsToProbe := make(map[netip.Addr]bool) - - doProbe := func(cf *connAndMeasureFn, meta nodeMeta, source timestampSource, stable connStability, protocol protocol, dstPort int) { - defer wg.Done() - r := result{ - key: resultKey{ - meta: meta, - timestampSource: source, - connStability: stable, - dstPort: dstPort, - protocol: protocol, - }, - at: at, - } - time.Sleep(rand.N(maxTXJitter)) // jitter across tx - addrPort := netip.AddrPortFrom(meta.addr, uint16(dstPort)) - rtt, err := cf.fn(cf.conn, meta.hostname, addrPort) - if err != nil { - if isTemporaryOrTimeoutErr(err) { - r.rtt = nil - log.Printf("%s: temp error measuring RTT to %s(%s): %v", protocol, meta.hostname, addrPort, err) - } else { - select { - case <-doneCh: - return - case errCh <- fmt.Errorf("%s: %v", protocol, err): - return - } - } - } else { - r.rtt = &rtt - } - select { - case <-doneCh: - case resultsCh <- r: - } - } - - for _, meta := range nodeMetaByAddr { - addrsToProbe[meta.addr] = true - for p, ports := range portsByProtocol { - for _, port := range ports { - stable, unstable, err := getConns(stableConns, meta.addr, p, port) - if err != nil { - close(doneCh) - wg.Wait() - return nil, err - } - - for i, cf := range stable { - if cf != nil { - wg.Add(1) - numProbes++ - go doProbe(cf, meta, timestampSource(i), stableConn, p, port) - } - } - - for i, cf := range unstable { - if cf != nil { - wg.Add(1) - numProbes++ - go doProbe(cf, meta, timestampSource(i), unstableConn, p, port) - } - } - } - } - } - - // cleanup conns we no longer need - for k, cf := range stableConns { - if !addrsToProbe[k.node] { - if cf[timestampSourceKernel] != nil { - cf[timestampSourceKernel].conn.Close() - } - cf[timestampSourceUserspace].conn.Close() - delete(stableConns, k) - } - } - - for { - select { - case err := <-errCh: - close(doneCh) - wg.Wait() - return nil, err - case result := <-resultsCh: - results = append(results, result) - if len(results) == numProbes { - return results, nil - } - } - } -} - -type connStability bool - -const ( - unstableConn connStability = false - stableConn connStability = true -) - -const ( - rttMetricName = "stunstamp_derp_rtt_ns" - timeoutsMetricName = "stunstamp_derp_timeouts_total" -) - -func timeSeriesLabels(metricName string, meta nodeMeta, instance string, source timestampSource, stability connStability, protocol protocol, dstPort int) []prompb.Label { - addressFamily := "ipv4" - if meta.addr.Is6() { - addressFamily = "ipv6" - } - labels := make([]prompb.Label, 0) - labels = append(labels, prompb.Label{ - Name: "job", - Value: "stunstamp-rw", - }) - labels = append(labels, prompb.Label{ - Name: "instance", - Value: instance, - }) - labels = append(labels, prompb.Label{ - Name: "region_id", - Value: fmt.Sprintf("%d", meta.regionID), - }) - labels = append(labels, prompb.Label{ - Name: "region_code", - Value: meta.regionCode, - }) - labels = append(labels, prompb.Label{ - Name: "address_family", - Value: addressFamily, - }) - labels = append(labels, prompb.Label{ - Name: "hostname", - Value: meta.hostname, - }) - labels = append(labels, prompb.Label{ - Name: "protocol", - Value: string(protocol), - }) - labels = append(labels, prompb.Label{ - Name: "dst_port", - Value: strconv.Itoa(dstPort), - }) - labels = append(labels, prompb.Label{ - Name: "__name__", - Value: metricName, - }) - labels = append(labels, prompb.Label{ - Name: "timestamp_source", - Value: source.String(), - }) - labels = append(labels, prompb.Label{ - Name: "stable_conn", - Value: fmt.Sprintf("%v", stability), - }) - slices.SortFunc(labels, func(a, b prompb.Label) int { - // prometheus remote-write spec requires lexicographically sorted label names - return cmp.Compare(a.Name, b.Name) - }) - return labels -} - -const ( - // https://prometheus.io/docs/concepts/remote_write_spec/#stale-markers - staleNaN uint64 = 0x7ff0000000000002 -) - -func staleMarkersFromNodeMeta(stale []nodeMeta, instance string, portsByProtocol map[protocol][]int) []prompb.TimeSeries { - staleMarkers := make([]prompb.TimeSeries, 0) - now := time.Now() - - for p, ports := range portsByProtocol { - for _, port := range ports { - for _, s := range stale { - samples := []prompb.Sample{ - { - Timestamp: now.UnixMilli(), - Value: math.Float64frombits(staleNaN), - }, - } - // We send stale markers for all combinations in the interest - // of simplicity. - for _, name := range []string{rttMetricName, timeoutsMetricName} { - for _, source := range []timestampSource{timestampSourceUserspace, timestampSourceKernel} { - for _, stable := range []connStability{unstableConn, stableConn} { - staleMarkers = append(staleMarkers, prompb.TimeSeries{ - Labels: timeSeriesLabels(name, s, instance, source, stable, p, port), - Samples: samples, - }) - } - } - } - } - } - } - - return staleMarkers -} - -// resultsToPromTimeSeries returns a slice of prometheus TimeSeries for the -// provided results and instance. timeouts is updated based on results, i.e. -// all result.key's are added to timeouts if they do not exist, and removed -// from timeouts if they are not present in results. -func resultsToPromTimeSeries(results []result, instance string, timeouts map[resultKey]uint64) []prompb.TimeSeries { - all := make([]prompb.TimeSeries, 0, len(results)*2) - seenKeys := make(map[resultKey]bool) - for _, r := range results { - timeoutsCount := timeouts[r.key] // a non-existent key will return a zero val - seenKeys[r.key] = true - rttLabels := timeSeriesLabels(rttMetricName, r.key.meta, instance, r.key.timestampSource, r.key.connStability, r.key.protocol, r.key.dstPort) - rttSamples := make([]prompb.Sample, 1) - rttSamples[0].Timestamp = r.at.UnixMilli() - if r.rtt != nil { - rttSamples[0].Value = float64(*r.rtt) - } else { - rttSamples[0].Value = math.NaN() - timeoutsCount++ - } - rttTS := prompb.TimeSeries{ - Labels: rttLabels, - Samples: rttSamples, - } - all = append(all, rttTS) - timeouts[r.key] = timeoutsCount - timeoutsLabels := timeSeriesLabels(timeoutsMetricName, r.key.meta, instance, r.key.timestampSource, r.key.connStability, r.key.protocol, r.key.dstPort) - timeoutsSamples := make([]prompb.Sample, 1) - timeoutsSamples[0].Timestamp = r.at.UnixMilli() - timeoutsSamples[0].Value = float64(timeoutsCount) - timeoutsTS := prompb.TimeSeries{ - Labels: timeoutsLabels, - Samples: timeoutsSamples, - } - all = append(all, timeoutsTS) - } - for k := range timeouts { - if !seenKeys[k] { - delete(timeouts, k) - } - } - return all -} - -type remoteWriteClient struct { - c *http.Client - url string -} - -type recoverableErr struct { - error -} - -func newRemoteWriteClient(url string) *remoteWriteClient { - return &remoteWriteClient{ - c: &http.Client{ - Timeout: time.Second * 30, - }, - url: url, - } -} - -func (r *remoteWriteClient) write(ctx context.Context, ts []prompb.TimeSeries) error { - wr := &prompb.WriteRequest{ - Timeseries: ts, - } - b, err := wr.Marshal() - if err != nil { - return fmt.Errorf("unable to marshal write request: %w", err) - } - compressed := snappy.Encode(nil, b) - req, err := http.NewRequestWithContext(ctx, "POST", r.url, bytes.NewReader(compressed)) - if err != nil { - return fmt.Errorf("unable to create write request: %w", err) - } - req.Header.Add("Content-Encoding", "snappy") - req.Header.Set("Content-Type", "application/x-protobuf") - req.Header.Set("User-Agent", "stunstamp") - req.Header.Set("X-Prometheus-Remote-Write-Version", "0.1.0") - resp, err := r.c.Do(req) - if err != nil { - return recoverableErr{fmt.Errorf("error performing write request: %w", err)} - } - if resp.StatusCode/100 != 2 { - err = fmt.Errorf("remote server %s returned HTTP status %d", r.url, resp.StatusCode) - } - if resp.StatusCode/100 == 5 || resp.StatusCode == http.StatusTooManyRequests { - return recoverableErr{err} - } - return err -} - -func remoteWriteTimeSeries(client *remoteWriteClient, tsCh chan []prompb.TimeSeries) { - bo := backoff.NewBackoff("remote-write", log.Printf, time.Second*30) - // writeErr may contribute to bo's backoff schedule across tsCh read ops, - // i.e. if an unrecoverable error occurs for client.write(ctx, A), that - // should be accounted against bo prior to attempting to - // client.write(ctx, B). - var writeErr error - for ts := range tsCh { - for { - bo.BackOff(context.Background(), writeErr) - reqCtx, cancel := context.WithTimeout(context.Background(), time.Second*30) - writeErr = client.write(reqCtx, ts) - cancel() - var re recoverableErr - recoverable := errors.As(writeErr, &re) - if writeErr != nil { - log.Printf("remote write error(recoverable=%v): %v", recoverable, writeErr) - } - if !recoverable { - // a nil err is not recoverable - break - } - } - } -} - -func getPortsFromFlag(f string) ([]int, error) { - if len(f) == 0 { - return nil, nil - } - split := strings.Split(f, ",") - slices.Sort(split) - split = slices.Compact(split) - ports := make([]int, 0) - for _, portStr := range split { - port, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - return nil, err - } - ports = append(ports, int(port)) - } - return ports, nil -} - -func main() { - if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - log.Fatal("unsupported platform") - } - flag.Parse() - - portsByProtocol := make(map[protocol][]int) - stunPorts, err := getPortsFromFlag(*flagSTUNDstPorts) - if err != nil { - log.Fatalf("invalid stun-dst-ports flag value: %v", err) - } - if len(stunPorts) > 0 { - portsByProtocol[protocolSTUN] = stunPorts - } - httpsPorts, err := getPortsFromFlag(*flagHTTPSDstPorts) - if err != nil { - log.Fatalf("invalid https-dst-ports flag value: %v", err) - } - if len(httpsPorts) > 0 { - portsByProtocol[protocolHTTPS] = httpsPorts - } - tcpPorts, err := getPortsFromFlag(*flagTCPDstPorts) - if err != nil { - log.Fatalf("invalid tcp-dst-ports flag value: %v", err) - } - if len(tcpPorts) > 0 { - portsByProtocol[protocolTCP] = tcpPorts - } - if *flagICMP { - portsByProtocol[protocolICMP] = []int{0} - } - if len(portsByProtocol) == 0 { - log.Fatal("nothing to probe") - } - - if len(*flagDERPMap) < 1 { - log.Fatal("derp-map flag is unset") - } - if *flagInterval < minInterval || *flagInterval > maxBufferDuration { - log.Fatalf("interval must be >= %s and <= %s", minInterval, maxBufferDuration) - } - if len(*flagRemoteWriteURL) < 1 { - log.Fatal("rw-url flag is unset") - } - _, err = url.Parse(*flagRemoteWriteURL) - if err != nil { - log.Fatalf("invalid rw-url flag value: %v", err) - } - if len(*flagInstance) < 1 { - hostname, err := os.Hostname() - if err != nil { - log.Fatalf("failed to get hostname: %v", err) - } - *flagInstance = hostname - } - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - dmCh := make(chan *tailcfg.DERPMap) - - go func() { - bo := backoff.NewBackoff("derp-map", log.Printf, time.Second*30) - for { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - dm, err := getDERPMap(ctx, *flagDERPMap) - cancel() - bo.BackOff(context.Background(), err) - if err != nil { - continue - } - dmCh <- dm - return - } - }() - - nodeMetaByAddr := make(map[netip.Addr]nodeMeta) - select { - case <-sigCh: - return - case dm := <-dmCh: - _, err := nodeMetaFromDERPMap(dm, nodeMetaByAddr, *flagIPv6) - if err != nil { - log.Fatalf("error parsing derp map on startup: %v", err) - } - } - - tsCh := make(chan []prompb.TimeSeries, maxBufferDuration / *flagInterval) - remoteWriteDoneCh := make(chan struct{}) - rwc := newRemoteWriteClient(*flagRemoteWriteURL) - go func() { - remoteWriteTimeSeries(rwc, tsCh) - close(remoteWriteDoneCh) - }() - - shutdown := func() { - close(tsCh) - select { - case <-time.After(time.Second * 10): // give goroutine some time to flush - case <-remoteWriteDoneCh: - } - - // send stale markers on shutdown - staleMeta := make([]nodeMeta, 0, len(nodeMetaByAddr)) - for _, v := range nodeMetaByAddr { - staleMeta = append(staleMeta, v) - } - staleMarkers := staleMarkersFromNodeMeta(staleMeta, *flagInstance, portsByProtocol) - if len(staleMarkers) > 0 { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - rwc.write(ctx, staleMarkers) - cancel() - } - - return - } - - log.Println("stunstamp started") - - // Re-using sockets means we get the same 5-tuple across runs. This results - // in a higher probability of the packets traversing the same underlay path. - // Comparison of stable and unstable 5-tuple results can shed light on - // differences between paths where hashing (multipathing/load balancing) - // comes into play. The inner 2 element array index is timestampSource. - stableConns := make(map[stableConnKey][2]*connAndMeasureFn) - - // timeouts holds counts of timeout events. Values are persisted for the - // lifetime of the related node in the DERP map. - timeouts := make(map[resultKey]uint64) - - derpMapTicker := time.NewTicker(time.Minute * 5) - defer derpMapTicker.Stop() - probeTicker := time.NewTicker(*flagInterval) - defer probeTicker.Stop() - - for { - select { - case <-probeTicker.C: - results, err := probeNodes(nodeMetaByAddr, stableConns, portsByProtocol) - if err != nil { - log.Printf("unrecoverable error while probing: %v", err) - shutdown() - return - } - ts := resultsToPromTimeSeries(results, *flagInstance, timeouts) - select { - case tsCh <- ts: - default: - select { - case <-tsCh: - log.Println("prometheus remote-write buffer full, dropped measurements") - default: - tsCh <- ts - } - } - case dm := <-dmCh: - staleMeta, err := nodeMetaFromDERPMap(dm, nodeMetaByAddr, *flagIPv6) - if err != nil { - log.Printf("error parsing DERP map, continuing with stale map: %v", err) - continue - } - staleMarkers := staleMarkersFromNodeMeta(staleMeta, *flagInstance, portsByProtocol) - if len(staleMarkers) < 1 { - continue - } - select { - case tsCh <- staleMarkers: - default: - select { - case <-tsCh: - log.Println("prometheus remote-write buffer full, dropped measurements") - default: - tsCh <- staleMarkers - } - } - case <-derpMapTicker.C: - go func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - updatedDM, err := getDERPMap(ctx, *flagDERPMap) - if err == nil { - dmCh <- updatedDM - } - }() - case <-sigCh: - shutdown() - return - } - } -} diff --git a/cmd/stunstamp/stunstamp_default.go b/cmd/stunstamp/stunstamp_default.go deleted file mode 100644 index a244d9aea6410..0000000000000 --- a/cmd/stunstamp/stunstamp_default.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !linux - -package main - -import ( - "errors" - "io" - "net/netip" - "time" -) - -func getUDPConnKernelTimestamp() (io.ReadWriteCloser, error) { - return nil, errors.New("unimplemented") -} - -func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) { - return 0, errors.New("unimplemented") -} - -func getProtocolSupportInfo(p protocol) protocolSupportInfo { - switch p { - case protocolSTUN: - return protocolSupportInfo{ - kernelTS: false, - userspaceTS: true, - stableConn: true, - } - case protocolHTTPS: - return protocolSupportInfo{ - kernelTS: false, - userspaceTS: true, - stableConn: true, - } - case protocolTCP: - return protocolSupportInfo{ - kernelTS: true, - userspaceTS: false, - stableConn: true, - } - case protocolICMP: - return protocolSupportInfo{ - kernelTS: false, - userspaceTS: false, - stableConn: false, - } - } - return protocolSupportInfo{} -} - -func getICMPConn(forDst netip.Addr, source timestampSource) (io.ReadWriteCloser, error) { - return nil, errors.New("platform unsupported") -} - -func mkICMPMeasureFn(source timestampSource) measureFn { - return func(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) { - return 0, errors.New("platform unsupported") - } -} - -func setSOReuseAddr(fd uintptr) error { - return nil -} diff --git a/cmd/stunstamp/stunstamp_linux.go b/cmd/stunstamp/stunstamp_linux.go deleted file mode 100644 index 387805feff2f1..0000000000000 --- a/cmd/stunstamp/stunstamp_linux.go +++ /dev/null @@ -1,317 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "bytes" - "context" - "encoding/binary" - "errors" - "fmt" - "io" - "math" - "math/rand/v2" - "net/netip" - "syscall" - "time" - - "github.com/mdlayher/socket" - "golang.org/x/net/icmp" - "golang.org/x/net/ipv4" - "golang.org/x/net/ipv6" - "golang.org/x/sys/unix" - "tailscale.com/net/stun" -) - -const ( - timestampingFlags = unix.SOF_TIMESTAMPING_TX_SOFTWARE | // tx timestamp generation in device driver - unix.SOF_TIMESTAMPING_RX_SOFTWARE | // rx timestamp generation in the kernel - unix.SOF_TIMESTAMPING_SOFTWARE // report software timestamps -) - -func getUDPConnKernelTimestamp() (io.ReadWriteCloser, error) { - sconn, err := socket.Socket(unix.AF_INET6, unix.SOCK_DGRAM, unix.IPPROTO_UDP, "udp", nil) - if err != nil { - return nil, err - } - sa := unix.SockaddrInet6{} - err = sconn.Bind(&sa) - if err != nil { - return nil, err - } - err = sconn.SetsockoptInt(unix.SOL_SOCKET, unix.SO_TIMESTAMPING_NEW, timestampingFlags) - if err != nil { - return nil, err - } - return sconn, nil -} - -func parseTimestampFromCmsgs(oob []byte) (time.Time, error) { - msgs, err := unix.ParseSocketControlMessage(oob) - if err != nil { - return time.Time{}, fmt.Errorf("error parsing oob as cmsgs: %w", err) - } - for _, msg := range msgs { - if msg.Header.Level == unix.SOL_SOCKET && msg.Header.Type == unix.SO_TIMESTAMPING_NEW && len(msg.Data) >= 16 { - sec := int64(binary.NativeEndian.Uint64(msg.Data[:8])) - ns := int64(binary.NativeEndian.Uint64(msg.Data[8:16])) - return time.Unix(sec, ns), nil - } - } - return time.Time{}, errors.New("failed to parse timestamp from cmsgs") -} - -func mkICMPMeasureFn(source timestampSource) measureFn { - return func(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) { - return measureICMPRTT(source, conn, hostname, dst) - } -} - -func measureICMPRTT(source timestampSource, conn io.ReadWriteCloser, _ string, dst netip.AddrPort) (rtt time.Duration, err error) { - sconn, ok := conn.(*socket.Conn) - if !ok { - return 0, fmt.Errorf("conn of unexpected type: %T", conn) - } - txBody := &icmp.Echo{ - // The kernel overrides this and routes appropriately so there is no - // point in setting or verifying. - ID: 0, - // Make this sufficiently random so that we do not account a late - // arriving reply in a future probe window. - Seq: int(rand.Int32N(math.MaxUint16)), - // Fingerprint ourselves. - Data: []byte("stunstamp"), - } - txMsg := icmp.Message{ - Body: txBody, - } - var to unix.Sockaddr - if dst.Addr().Is4() { - txMsg.Type = ipv4.ICMPTypeEcho - to = &unix.SockaddrInet4{} - copy(to.(*unix.SockaddrInet4).Addr[:], dst.Addr().AsSlice()) - } else { - txMsg.Type = ipv6.ICMPTypeEchoRequest - to = &unix.SockaddrInet6{} - copy(to.(*unix.SockaddrInet6).Addr[:], dst.Addr().AsSlice()) - } - txBuf, err := txMsg.Marshal(nil) - if err != nil { - return 0, err - } - txAt := time.Now() - err = sconn.Sendto(context.Background(), txBuf, 0, to) - if err != nil { - return 0, fmt.Errorf("sendto error: %v", err) - } - - if source == timestampSourceKernel { - txCtx, txCancel := context.WithTimeout(context.Background(), txRxTimeout) - defer txCancel() - - buf := make([]byte, 1024) - oob := make([]byte, 1024) - - for { - n, oobn, _, _, err := sconn.Recvmsg(txCtx, buf, oob, unix.MSG_ERRQUEUE) - if err != nil { - return 0, fmt.Errorf("recvmsg (MSG_ERRQUEUE) error: %v", err) // don't wrap - } - - buf = buf[:n] - // Spin until we find the message we sent. We get the full packet - // looped including eth header so match against the tail. - if n < len(txBuf) { - continue - } - txLoopedMsg, err := icmp.ParseMessage(txMsg.Type.Protocol(), buf[len(buf)-len(txBuf):]) - if err != nil { - continue - } - txLoopedBody, ok := txLoopedMsg.Body.(*icmp.Echo) - if !ok || txLoopedBody.Seq != txBody.Seq || txLoopedMsg.Code != txMsg.Code || - txLoopedMsg.Type != txLoopedMsg.Type || !bytes.Equal(txLoopedBody.Data, txBody.Data) { - continue - } - txAt, err = parseTimestampFromCmsgs(oob[:oobn]) - if err != nil { - return 0, fmt.Errorf("failed to get tx timestamp: %v", err) // don't wrap - } - break - } - } - - rxCtx, rxCancel := context.WithTimeout(context.Background(), txRxTimeout) - defer rxCancel() - - rxBuf := make([]byte, 1024) - oob := make([]byte, 1024) - for { - n, oobn, _, _, err := sconn.Recvmsg(rxCtx, rxBuf, oob, 0) - if err != nil { - return 0, fmt.Errorf("recvmsg error: %w", err) - } - rxAt := time.Now() - rxMsg, err := icmp.ParseMessage(txMsg.Type.Protocol(), rxBuf[:n]) - if err != nil { - continue - } - if txMsg.Type == ipv4.ICMPTypeEcho { - if rxMsg.Type != ipv4.ICMPTypeEchoReply { - continue - } - } else { - if rxMsg.Type != ipv6.ICMPTypeEchoReply { - continue - } - } - if rxMsg.Code != txMsg.Code { - continue - } - rxBody, ok := rxMsg.Body.(*icmp.Echo) - if !ok || rxBody.Seq != txBody.Seq || !bytes.Equal(rxBody.Data, txBody.Data) { - continue - } - if source == timestampSourceKernel { - rxAt, err = parseTimestampFromCmsgs(oob[:oobn]) - if err != nil { - return 0, fmt.Errorf("failed to get rx timestamp: %v", err) - } - } - return rxAt.Sub(txAt), nil - } -} - -func measureSTUNRTTKernel(conn io.ReadWriteCloser, _ string, dst netip.AddrPort) (rtt time.Duration, err error) { - sconn, ok := conn.(*socket.Conn) - if !ok { - return 0, fmt.Errorf("conn of unexpected type: %T", conn) - } - - var to unix.Sockaddr - if dst.Addr().Is4() { - to = &unix.SockaddrInet4{ - Port: int(dst.Port()), - } - copy(to.(*unix.SockaddrInet4).Addr[:], dst.Addr().AsSlice()) - } else { - to = &unix.SockaddrInet6{ - Port: int(dst.Port()), - } - copy(to.(*unix.SockaddrInet6).Addr[:], dst.Addr().AsSlice()) - } - - txID := stun.NewTxID() - req := stun.Request(txID) - - err = sconn.Sendto(context.Background(), req, 0, to) - if err != nil { - return 0, fmt.Errorf("sendto error: %v", err) // don't wrap - } - - txCtx, txCancel := context.WithTimeout(context.Background(), txRxTimeout) - defer txCancel() - - buf := make([]byte, 1024) - oob := make([]byte, 1024) - var txAt time.Time - - for { - n, oobn, _, _, err := sconn.Recvmsg(txCtx, buf, oob, unix.MSG_ERRQUEUE) - if err != nil { - return 0, fmt.Errorf("recvmsg (MSG_ERRQUEUE) error: %v", err) // don't wrap - } - - buf = buf[:n] - if n < len(req) || !bytes.Equal(req, buf[len(buf)-len(req):]) { - // Spin until we find the message we sent. We get the full packet - // looped including eth header so match against the tail. - continue - } - txAt, err = parseTimestampFromCmsgs(oob[:oobn]) - if err != nil { - return 0, fmt.Errorf("failed to get tx timestamp: %v", err) // don't wrap - } - break - } - - rxCtx, rxCancel := context.WithTimeout(context.Background(), txRxTimeout) - defer rxCancel() - - for { - n, oobn, _, _, err := sconn.Recvmsg(rxCtx, buf, oob, 0) - if err != nil { - return 0, fmt.Errorf("recvmsg error: %w", err) // wrap for timeout-related error unwrapping - } - - gotTxID, _, err := stun.ParseResponse(buf[:n]) - if err != nil || gotTxID != txID { - // Spin until we find the txID we sent. We may end up reading - // extremely late arriving responses from previous intervals. As - // such, we can't be certain if we're parsing the "current" - // response, so spin for parse errors too. - continue - } - - rxAt, err := parseTimestampFromCmsgs(oob[:oobn]) - if err != nil { - return 0, fmt.Errorf("failed to get rx timestamp: %v", err) // don't wrap - } - - return rxAt.Sub(txAt), nil - } - -} - -func getICMPConn(forDst netip.Addr, source timestampSource) (io.ReadWriteCloser, error) { - domain := unix.AF_INET - proto := unix.IPPROTO_ICMP - if forDst.Is6() { - domain = unix.AF_INET6 - proto = unix.IPPROTO_ICMPV6 - } - conn, err := socket.Socket(domain, unix.SOCK_DGRAM, proto, "icmp", nil) - if err != nil { - return nil, err - } - if source == timestampSourceKernel { - err = conn.SetsockoptInt(unix.SOL_SOCKET, unix.SO_TIMESTAMPING_NEW, timestampingFlags) - } - return conn, err -} - -func getProtocolSupportInfo(p protocol) protocolSupportInfo { - switch p { - case protocolSTUN: - return protocolSupportInfo{ - kernelTS: true, - userspaceTS: true, - stableConn: true, - } - case protocolHTTPS: - return protocolSupportInfo{ - kernelTS: false, - userspaceTS: true, - stableConn: true, - } - case protocolTCP: - return protocolSupportInfo{ - kernelTS: true, - userspaceTS: false, - stableConn: true, - } - case protocolICMP: - return protocolSupportInfo{ - kernelTS: true, - userspaceTS: true, - stableConn: false, - } - } - return protocolSupportInfo{} -} - -func setSOReuseAddr(fd uintptr) error { - // we may restart faster than TIME_WAIT can clear - return syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) -} diff --git a/cmd/sync-containers/main.go b/cmd/sync-containers/main.go deleted file mode 100644 index 6317b4943ae82..0000000000000 --- a/cmd/sync-containers/main.go +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// The sync-containers command synchronizes container image tags from one -// registry to another. -// -// It is intended as a workaround for ghcr.io's lack of good push credentials: -// you can either authorize "classic" Personal Access Tokens in your org (which -// are a common vector of very bad compromise), or you can get a short-lived -// credential in a Github action. -// -// Since we publish to both Docker Hub and ghcr.io, we use this program in a -// Github action to effectively rsync from docker hub into ghcr.io, so that we -// can continue to forbid dangerous Personal Access Tokens in the tailscale org. -package main - -import ( - "context" - "flag" - "fmt" - "log" - "sort" - "strings" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/authn/github" - "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/types" -) - -var ( - src = flag.String("src", "", "Source image") - dst = flag.String("dst", "", "Destination image") - max = flag.Int("max", 0, "Maximum number of tags to sync (0 for all tags)") - dryRun = flag.Bool("dry-run", true, "Don't actually sync anything") -) - -func main() { - flag.Parse() - - if *src == "" { - log.Fatalf("--src is required") - } - if *dst == "" { - log.Fatalf("--dst is required") - } - - keychain := authn.NewMultiKeychain(authn.DefaultKeychain, github.Keychain) - opts := []remote.Option{ - remote.WithAuthFromKeychain(keychain), - remote.WithContext(context.Background()), - } - - stags, err := listTags(*src, opts...) - if err != nil { - log.Fatalf("listing source tags: %v", err) - } - dtags, err := listTags(*dst, opts...) - if err != nil { - log.Fatalf("listing destination tags: %v", err) - } - - add, remove := diffTags(stags, dtags) - if l := len(add); l > 0 { - log.Printf("%d tags to push: %s", len(add), strings.Join(add, ", ")) - if *max > 0 && l > *max { - log.Printf("Limiting sync to %d tags", *max) - add = add[:*max] - } - } - for _, tag := range add { - if !*dryRun { - log.Printf("Syncing tag %q", tag) - if err := copyTag(*src, *dst, tag, opts...); err != nil { - log.Printf("Syncing tag %q: progress error: %v", tag, err) - } - } else { - log.Printf("Dry run: would sync tag %q", tag) - } - } - - if len(remove) > 0 { - 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 { - src, err := name.ParseReference(fmt.Sprintf("%s:%s", srcStr, tag)) - if err != nil { - return err - } - dst, err := name.ParseReference(fmt.Sprintf("%s:%s", dstStr, tag)) - if err != nil { - return err - } - - desc, err := remote.Get(src) - if err != nil { - return err - } - - ch := make(chan v1.Update, 10) - opts = append(opts, remote.WithProgress(ch)) - progressDone := make(chan struct{}) - - go func() { - defer close(progressDone) - for p := range ch { - fmt.Printf("Syncing tag %q: %d%% (%d/%d)\n", tag, int(float64(p.Complete)/float64(p.Total)*100), p.Complete, p.Total) - if p.Error != nil { - fmt.Printf("error: %v\n", p.Error) - } - } - }() - - switch desc.MediaType { - case types.OCIManifestSchema1, types.DockerManifestSchema2: - img, err := desc.Image() - if err != nil { - return err - } - if err := remote.Write(dst, img, opts...); err != nil { - return err - } - case types.OCIImageIndex, types.DockerManifestList: - idx, err := desc.ImageIndex() - if err != nil { - return err - } - if err := remote.WriteIndex(dst, idx, opts...); err != nil { - return err - } - } - - <-progressDone - return nil -} - -func listTags(repoStr string, opts ...remote.Option) ([]string, error) { - repo, err := name.NewRepository(repoStr) - if err != nil { - return nil, err - } - - tags, err := remote.List(repo, opts...) - if err != nil { - return nil, err - } - - sort.Strings(tags) - return tags, nil -} - -func diffTags(src, dst []string) (add, remove []string) { - srcd := make(map[string]bool) - for _, tag := range src { - srcd[tag] = true - } - dstd := make(map[string]bool) - for _, tag := range dst { - dstd[tag] = true - } - - for _, tag := range src { - if !dstd[tag] { - add = append(add, tag) - } - } - for _, tag := range dst { - if !srcd[tag] { - remove = append(remove, tag) - } - } - sort.Strings(add) - 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/systray/README.md b/cmd/systray/README.md deleted file mode 100644 index 786434d130a43..0000000000000 --- a/cmd/systray/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# systray - -The systray command is a minimal Tailscale systray application for Linux. -It is designed to provide quick access to common operations like profile switching -and exit node selection. - -## Supported platforms - -The `fyne.io/systray` package we use supports Windows, macOS, Linux, and many BSDs, -so the systray application will likely work for the most part on those platforms. -Notifications currently only work on Linux, as that is the main target. diff --git a/cmd/systray/logo.go b/cmd/systray/logo.go deleted file mode 100644 index cd79c94a02ea4..0000000000000 --- a/cmd/systray/logo.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build cgo || !darwin - -package main - -import ( - "bytes" - "context" - "image/color" - "image/png" - "sync" - "time" - - "fyne.io/systray" - "github.com/fogleman/gg" -) - -// tsLogo represents the state of the 3x3 dot grid in the Tailscale logo. -// A 0 represents a gray dot, any other value is a white dot. -type tsLogo [9]byte - -var ( - // disconnected is all gray dots - disconnected = tsLogo{ - 0, 0, 0, - 0, 0, 0, - 0, 0, 0, - } - - // connected is the normal Tailscale logo - connected = tsLogo{ - 0, 0, 0, - 1, 1, 1, - 0, 1, 0, - } - - // loading is a special tsLogo value that is not meant to be rendered directly, - // but indicates that the loading animation should be shown. - loading = tsLogo{'l', 'o', 'a', 'd', 'i', 'n', 'g'} - - // loadingIcons are shown in sequence as an animated loading icon. - loadingLogos = []tsLogo{ - { - 0, 1, 1, - 1, 0, 1, - 0, 0, 1, - }, - { - 0, 1, 1, - 0, 0, 1, - 0, 1, 0, - }, - { - 0, 1, 1, - 0, 0, 0, - 0, 0, 1, - }, - { - 0, 0, 1, - 0, 1, 0, - 0, 0, 0, - }, - { - 0, 1, 0, - 0, 0, 0, - 0, 0, 0, - }, - { - 0, 0, 0, - 0, 0, 1, - 0, 0, 0, - }, - { - 0, 0, 0, - 0, 0, 0, - 0, 0, 0, - }, - { - 0, 0, 1, - 0, 0, 0, - 0, 0, 0, - }, - { - 0, 0, 0, - 0, 0, 0, - 1, 0, 0, - }, - { - 0, 0, 0, - 0, 0, 0, - 1, 1, 0, - }, - { - 0, 0, 0, - 1, 0, 0, - 1, 1, 0, - }, - { - 0, 0, 0, - 1, 1, 0, - 0, 1, 0, - }, - { - 0, 0, 0, - 1, 1, 0, - 0, 1, 1, - }, - { - 0, 0, 0, - 1, 1, 1, - 0, 0, 1, - }, - { - 0, 1, 0, - 0, 1, 1, - 1, 0, 1, - }, - } -) - -var ( - black = color.NRGBA{0, 0, 0, 255} - white = color.NRGBA{255, 255, 255, 255} - gray = color.NRGBA{255, 255, 255, 102} -) - -// render returns a PNG image of the logo. -func (logo tsLogo) render() *bytes.Buffer { - const radius = 25 - const borderUnits = 1 - dim := radius * (8 + borderUnits*2) - - dc := gg.NewContext(dim, dim) - dc.DrawRectangle(0, 0, float64(dim), float64(dim)) - dc.SetColor(black) - dc.Fill() - - for y := 0; y < 3; y++ { - for x := 0; x < 3; x++ { - px := (borderUnits + 1 + 3*x) * radius - py := (borderUnits + 1 + 3*y) * radius - col := white - if logo[y*3+x] == 0 { - col = gray - } - dc.DrawCircle(float64(px), float64(py), radius) - dc.SetColor(col) - dc.Fill() - } - } - - b := bytes.NewBuffer(nil) - png.Encode(b, dc.Image()) - return b -} - -// setAppIcon renders logo and sets it as the systray icon. -func setAppIcon(icon tsLogo) { - if icon == loading { - startLoadingAnimation() - } else { - stopLoadingAnimation() - systray.SetIcon(icon.render().Bytes()) - } -} - -var ( - loadingMu sync.Mutex // protects loadingCancel - - // loadingCancel stops the loading animation in the systray icon. - // This is nil if the animation is not currently active. - loadingCancel func() -) - -// startLoadingAnimation starts the animated loading icon in the system tray. -// The animation continues until [stopLoadingAnimation] is called. -// If the loading animation is already active, this func does nothing. -func startLoadingAnimation() { - loadingMu.Lock() - defer loadingMu.Unlock() - - if loadingCancel != nil { - // loading icon already displayed - return - } - - ctx := context.Background() - ctx, loadingCancel = context.WithCancel(ctx) - - go func() { - t := time.NewTicker(500 * time.Millisecond) - var i int - for { - select { - case <-ctx.Done(): - return - case <-t.C: - systray.SetIcon(loadingLogos[i].render().Bytes()) - i++ - if i >= len(loadingLogos) { - i = 0 - } - } - } - }() -} - -// stopLoadingAnimation stops the animated loading icon in the system tray. -// If the loading animation is not currently active, this func does nothing. -func stopLoadingAnimation() { - loadingMu.Lock() - defer loadingMu.Unlock() - - if loadingCancel != nil { - loadingCancel() - loadingCancel = nil - } -} diff --git a/cmd/systray/systray.go b/cmd/systray/systray.go deleted file mode 100644 index aca38f627c65a..0000000000000 --- a/cmd/systray/systray.go +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build cgo || !darwin - -// The systray command is a minimal Tailscale systray application for Linux. -package main - -import ( - "context" - "errors" - "fmt" - "io" - "log" - "os" - "strings" - "sync" - "time" - - "fyne.io/systray" - "github.com/atotto/clipboard" - dbus "github.com/godbus/dbus/v5" - "github.com/toqueteos/webbrowser" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" -) - -var ( - localClient tailscale.LocalClient - chState chan ipn.State // tailscale state changes - - appIcon *os.File -) - -func main() { - systray.Run(onReady, onExit) -} - -// Menu represents the systray menu, its items, and the current Tailscale state. -type Menu struct { - mu sync.Mutex // protects the entire Menu - status *ipnstate.Status - - connect *systray.MenuItem - disconnect *systray.MenuItem - - self *systray.MenuItem - more *systray.MenuItem - quit *systray.MenuItem - - eventCancel func() // cancel eventLoop -} - -func onReady() { - log.Printf("starting") - ctx := context.Background() - - setAppIcon(disconnected) - - // dbus wants a file path for notification icons, so copy to a temp file. - appIcon, _ = os.CreateTemp("", "tailscale-systray.png") - io.Copy(appIcon, connected.render()) - - chState = make(chan ipn.State, 1) - - status, err := localClient.Status(ctx) - if err != nil { - log.Print(err) - } - - menu := new(Menu) - menu.rebuild(status) - - go watchIPNBus(ctx) -} - -// rebuild the systray menu based on the current Tailscale state. -// -// We currently rebuild the entire menu because it is not easy to update the existing menu. -// You cannot iterate over the items in a menu, nor can you remove some items like separators. -// So for now we rebuild the whole thing, and can optimize this later if needed. -func (menu *Menu) rebuild(status *ipnstate.Status) { - menu.mu.Lock() - defer menu.mu.Unlock() - - if menu.eventCancel != nil { - menu.eventCancel() - } - menu.status = status - systray.ResetMenu() - - menu.connect = systray.AddMenuItem("Connect", "") - menu.disconnect = systray.AddMenuItem("Disconnect", "") - menu.disconnect.Hide() - systray.AddSeparator() - - if status != nil && status.Self != nil { - title := fmt.Sprintf("This Device: %s (%s)", status.Self.HostName, status.Self.TailscaleIPs[0]) - menu.self = systray.AddMenuItem(title, "") - } - systray.AddSeparator() - - menu.more = systray.AddMenuItem("More settings", "") - menu.more.Enable() - - menu.quit = systray.AddMenuItem("Quit", "Quit the app") - menu.quit.Enable() - - ctx := context.Background() - ctx, menu.eventCancel = context.WithCancel(ctx) - go menu.eventLoop(ctx) -} - -// eventLoop is the main event loop for handling click events on menu items -// and responding to Tailscale state changes. -// This method does not return until ctx.Done is closed. -func (menu *Menu) eventLoop(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case state := <-chState: - switch state { - case ipn.Running: - setAppIcon(loading) - status, err := localClient.Status(ctx) - if err != nil { - log.Printf("error getting tailscale status: %v", err) - } - menu.rebuild(status) - setAppIcon(connected) - menu.connect.SetTitle("Connected") - menu.connect.Disable() - menu.disconnect.Show() - menu.disconnect.Enable() - case ipn.NoState, ipn.Stopped: - menu.connect.SetTitle("Connect") - menu.connect.Enable() - menu.disconnect.Hide() - setAppIcon(disconnected) - case ipn.Starting: - setAppIcon(loading) - } - case <-menu.connect.ClickedCh: - _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - WantRunning: true, - }, - WantRunningSet: true, - }) - if err != nil { - log.Print(err) - continue - } - - case <-menu.disconnect.ClickedCh: - _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - WantRunning: false, - }, - WantRunningSet: true, - }) - if err != nil { - log.Printf("disconnecting: %v", err) - continue - } - - case <-menu.self.ClickedCh: - copyTailscaleIP(menu.status.Self) - - case <-menu.more.ClickedCh: - webbrowser.Open("http://100.100.100.100/") - - case <-menu.quit.ClickedCh: - systray.Quit() - } - } -} - -// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState. -// This method does not return. -func watchIPNBus(ctx context.Context) { - for { - if err := watchIPNBusInner(ctx); err != nil { - log.Println(err) - if errors.Is(err, context.Canceled) { - // If the context got canceled, we will never be able to - // reconnect to IPN bus, so exit the process. - log.Fatalf("watchIPNBus: %v", err) - } - } - // If our watch connection breaks, wait a bit before reconnecting. No - // reason to spam the logs if e.g. tailscaled is restarting or goes - // down. - time.Sleep(3 * time.Second) - } -} - -func watchIPNBusInner(ctx context.Context) error { - watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys) - if err != nil { - return fmt.Errorf("watching ipn bus: %w", err) - } - defer watcher.Close() - for { - select { - case <-ctx.Done(): - return nil - default: - n, err := watcher.Next() - if err != nil { - return fmt.Errorf("ipnbus error: %w", err) - } - if n.State != nil { - chState <- *n.State - log.Printf("new state: %v", n.State) - } - } - } -} - -// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard -// and sends a notification with the copied value. -func copyTailscaleIP(device *ipnstate.PeerStatus) { - if device == nil || len(device.TailscaleIPs) == 0 { - return - } - name := strings.Split(device.DNSName, ".")[0] - ip := device.TailscaleIPs[0].String() - err := clipboard.WriteAll(ip) - if err != nil { - log.Printf("clipboard error: %v", err) - } - - sendNotification(fmt.Sprintf("Copied Address for %v", name), ip) -} - -// sendNotification sends a desktop notification with the given title and content. -func sendNotification(title, content string) { - conn, err := dbus.SessionBus() - if err != nil { - log.Printf("dbus: %v", err) - return - } - timeout := 3 * time.Second - obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications") - call := obj.Call("org.freedesktop.Notifications.Notify", 0, "Tailscale", uint32(0), - appIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds())) - if call.Err != nil { - log.Printf("dbus: %v", call.Err) - } -} - -func onExit() { - log.Printf("exiting") - os.Remove(appIcon.Name()) -} diff --git a/cmd/tailscale/cli/advertise.go b/cmd/tailscale/cli/advertise.go deleted file mode 100644 index c9474c4274dd2..0000000000000 --- a/cmd/tailscale/cli/advertise.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "flag" - "fmt" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/tailcfg" -) - -var advertiseArgs struct { - services string // comma-separated list of services to advertise -} - -// TODO(naman): This flag may move to set.go or serve_v2.go after the WIPCode -// envknob is not needed. -var advertiseCmd = &ffcli.Command{ - Name: "advertise", - ShortUsage: "tailscale advertise --services=", - ShortHelp: "Advertise this node as a destination for a service", - Exec: runAdvertise, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("advertise") - fs.StringVar(&advertiseArgs.services, "services", "", "comma-separated services to advertise; each must start with \"svc:\" (e.g. \"svc:idp,svc:nas,svc:database\")") - return fs - })(), -} - -func maybeAdvertiseCmd() []*ffcli.Command { - if !envknob.UseWIPCode() { - return nil - } - return []*ffcli.Command{advertiseCmd} -} - -func runAdvertise(ctx context.Context, args []string) error { - if len(args) > 0 { - return flag.ErrHelp - } - - services, err := parseServiceNames(advertiseArgs.services) - if err != nil { - return err - } - - _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - AdvertiseServicesSet: true, - Prefs: ipn.Prefs{ - AdvertiseServices: services, - }, - }) - return err -} - -// parseServiceNames takes a comma-separated list of service names -// (eg. "svc:hello,svc:webserver,svc:catphotos"), splits them into -// a list and validates each service name. If valid, it returns -// the service names in a slice of strings. -func parseServiceNames(servicesArg string) ([]string, error) { - var services []string - if servicesArg != "" { - services = strings.Split(servicesArg, ",") - for _, svc := range services { - err := tailcfg.CheckServiceName(svc) - if err != nil { - return nil, fmt.Errorf("service %q: %s", svc, err) - } - } - } - return services, nil -} diff --git a/cmd/tailscale/cli/bugreport.go b/cmd/tailscale/cli/bugreport.go deleted file mode 100644 index d671f3df60d76..0000000000000 --- a/cmd/tailscale/cli/bugreport.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" -) - -var bugReportCmd = &ffcli.Command{ - Name: "bugreport", - Exec: runBugReport, - ShortHelp: "Print a shareable identifier to help diagnose issues", - ShortUsage: "tailscale bugreport [note]", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("bugreport") - fs.BoolVar(&bugReportArgs.diagnose, "diagnose", false, "run additional in-depth checks") - fs.BoolVar(&bugReportArgs.record, "record", false, "if true, pause and then write another bugreport") - return fs - })(), -} - -var bugReportArgs struct { - diagnose bool - record bool -} - -func runBugReport(ctx context.Context, args []string) error { - var note string - switch len(args) { - case 0: - case 1: - note = args[0] - default: - return errors.New("unknown arguments") - } - opts := tailscale.BugReportOpts{ - Note: note, - Diagnose: bugReportArgs.diagnose, - } - if !bugReportArgs.record { - // Simple, non-record case - logMarker, err := localClient.BugReportWithOpts(ctx, opts) - if err != nil { - return err - } - outln(logMarker) - return nil - } - - // Recording; run the request in the background - done := make(chan struct{}) - opts.Record = done - - type bugReportResp struct { - marker string - err error - } - resCh := make(chan bugReportResp, 1) - go func() { - m, err := localClient.BugReportWithOpts(ctx, opts) - resCh <- bugReportResp{m, err} - }() - - outln("Recording started; please reproduce your issue and then press Enter...") - fmt.Scanln() - close(done) - res := <-resCh - - if res.err != nil { - return res.err - } - - outln(res.marker) - outln("Please provide both bugreport markers above to the support team or GitHub issue.") - return nil -} diff --git a/cmd/tailscale/cli/cert.go b/cmd/tailscale/cli/cert.go deleted file mode 100644 index 9c8eca5b7d7d0..0000000000000 --- a/cmd/tailscale/cli/cert.go +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "errors" - "flag" - "fmt" - "log" - "net/http" - "os" - "strings" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "software.sslmate.com/src/go-pkcs12" - "tailscale.com/atomicfile" - "tailscale.com/ipn" - "tailscale.com/version" -) - -var certCmd = &ffcli.Command{ - Name: "cert", - Exec: runCert, - ShortHelp: "Get TLS certs", - ShortUsage: "tailscale cert [flags] ", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("cert") - fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset") - fs.StringVar(&certArgs.keyFile, "key-file", "", "output key file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset") - fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk") - fs.DurationVar(&certArgs.minValidity, "min-validity", 0, "ensure the certificate is valid for at least this duration; the output certificate is never expired if this flag is unset or 0, but the lifetime may vary; the maximum allowed min-validity depends on the CA") - return fs - })(), -} - -var certArgs struct { - certFile string - keyFile string - serve bool - minValidity time.Duration -} - -func runCert(ctx context.Context, args []string) error { - if certArgs.serve { - s := &http.Server{ - Addr: ":443", - TLSConfig: &tls.Config{ - GetCertificate: localClient.GetCertificate, - }, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.TLS != nil && !strings.Contains(r.Host, ".") && r.Method == "GET" { - if v, ok := localClient.ExpandSNIName(r.Context(), r.Host); ok { - http.Redirect(w, r, "https://"+v+r.URL.Path, http.StatusTemporaryRedirect) - return - } - } - fmt.Fprintf(w, "

Hello from Tailscale

It works.") - }), - } - switch len(args) { - case 0: - // Nothing. - case 1: - s.Addr = args[0] - default: - return errors.New("too many arguments; max 1 allowed with --serve-demo (the listen address)") - } - - log.Printf("running TLS server on %s ...", s.Addr) - return s.ListenAndServeTLS("", "") - } - - if len(args) != 1 { - var hint bytes.Buffer - if st, err := localClient.Status(ctx); err == nil { - if st.BackendState != ipn.Running.String() { - fmt.Fprintf(&hint, "\nTailscale is not running.\n") - } else if len(st.CertDomains) == 0 { - fmt.Fprintf(&hint, "\nHTTPS cert support is not enabled/configured for your tailnet.\n") - } else if len(st.CertDomains) == 1 { - fmt.Fprintf(&hint, "\nFor domain, use %q.\n", st.CertDomains[0]) - } else { - fmt.Fprintf(&hint, "\nValid domain options: %q.\n", st.CertDomains) - } - } - return fmt.Errorf("Usage: tailscale cert [flags] %s", hint.Bytes()) - } - domain := args[0] - - printf := func(format string, a ...any) { - printf(format, a...) - } - if certArgs.certFile == "-" || certArgs.keyFile == "-" { - printf = log.Printf - log.SetFlags(0) - } - if certArgs.certFile == "" && certArgs.keyFile == "" { - certArgs.certFile = domain + ".crt" - certArgs.keyFile = domain + ".key" - } - certPEM, keyPEM, err := localClient.CertPairWithValidity(ctx, domain, certArgs.minValidity) - if err != nil { - return err - } - needMacWarning := version.IsSandboxedMacOS() - macWarn := func() { - if !needMacWarning { - return - } - needMacWarning = false - dir := "io.tailscale.ipn.macos" - if version.IsMacSysExt() { - dir = "io.tailscale.ipn.macsys" - } - printf("Warning: the macOS CLI runs in a sandbox; this binary's filesystem writes go to $HOME/Library/Containers/%s/Data\n", dir) - } - if certArgs.certFile != "" { - certChanged, err := writeIfChanged(certArgs.certFile, certPEM, 0644) - if err != nil { - return err - } - if certArgs.certFile != "-" { - macWarn() - if certChanged { - printf("Wrote public cert to %v\n", certArgs.certFile) - } else { - printf("Public cert unchanged at %v\n", certArgs.certFile) - } - } - } - if dst := certArgs.keyFile; dst != "" { - contents := keyPEM - if isPKCS12(dst) { - var err error - contents, err = convertToPKCS12(certPEM, keyPEM) - if err != nil { - return err - } - } - keyChanged, err := writeIfChanged(dst, contents, 0600) - if err != nil { - return err - } - if certArgs.keyFile != "-" { - macWarn() - if keyChanged { - printf("Wrote private key to %v\n", dst) - } else { - printf("Private key unchanged at %v\n", dst) - } - } - } - return nil -} - -func writeIfChanged(filename string, contents []byte, mode os.FileMode) (changed bool, err error) { - if filename == "-" { - Stdout.Write(contents) - return false, nil - } - if old, err := os.ReadFile(filename); err == nil && bytes.Equal(contents, old) { - return false, nil - } - if err := atomicfile.WriteFile(filename, contents, mode); err != nil { - return false, err - } - return true, nil -} - -func isPKCS12(dst string) bool { - return strings.HasSuffix(dst, ".p12") || strings.HasSuffix(dst, ".pfx") -} - -func convertToPKCS12(certPEM, keyPEM []byte) ([]byte, error) { - cert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - return nil, err - } - var certs []*x509.Certificate - for _, c := range cert.Certificate { - cert, err := x509.ParseCertificate(c) - if err != nil { - return nil, err - } - certs = append(certs, cert) - } - if len(certs) == 0 { - return nil, errors.New("no certs") - } - // TODO(bradfitz): I'm not sure this is right yet. The goal was to make this - // work for https://github.com/tailscale/tailscale/issues/2928 but I'm still - // fighting Windows. - return pkcs12.Encode(rand.Reader, cert.PrivateKey, certs[0], certs[1:], "" /* no password */) -} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go deleted file mode 100644 index 66961b2e0086d..0000000000000 --- a/cmd/tailscale/cli/cli.go +++ /dev/null @@ -1,418 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package cli contains the cmd/tailscale CLI code in a package that can be included -// in other wrapper binaries such as the Mac and Windows clients. -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "log" - "os" - "runtime" - "strings" - "sync" - "text/tabwriter" - - "github.com/mattn/go-colorable" - "github.com/mattn/go-isatty" - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" - "tailscale.com/cmd/tailscale/cli/ffcomplete" - "tailscale.com/envknob" - "tailscale.com/paths" - "tailscale.com/version/distro" -) - -var Stderr io.Writer = os.Stderr -var Stdout io.Writer = os.Stdout - -func errf(format string, a ...any) { - fmt.Fprintf(Stderr, format, a...) -} - -func printf(format string, a ...any) { - fmt.Fprintf(Stdout, format, a...) -} - -// outln is like fmt.Println in the common case, except when Stdout is -// changed (as in js/wasm). -// -// It's not named println because that looks like the Go built-in -// which goes to stderr and formats slightly differently. -func outln(a ...any) { - fmt.Fprintln(Stdout, a...) -} - -func newFlagSet(name string) *flag.FlagSet { - onError := flag.ExitOnError - if runtime.GOOS == "js" { - onError = flag.ContinueOnError - } - fs := flag.NewFlagSet(name, onError) - fs.SetOutput(Stderr) - return fs -} - -// CleanUpArgs rewrites command line arguments for simplicity and backwards compatibility. -// In particular, it rewrites --authkey to --auth-key. -func CleanUpArgs(args []string) []string { - out := make([]string, 0, len(args)) - for _, arg := range args { - // Rewrite --authkey to --auth-key, and --authkey=x to --auth-key=x, - // and the same for the -authkey variant. - switch { - case arg == "--authkey", arg == "-authkey": - arg = "--auth-key" - case strings.HasPrefix(arg, "--authkey="), strings.HasPrefix(arg, "-authkey="): - arg = strings.TrimLeft(arg, "-") - arg = strings.TrimPrefix(arg, "authkey=") - arg = "--auth-key=" + arg - } - out = append(out, arg) - } - return out -} - -var localClient = tailscale.LocalClient{ - Socket: paths.DefaultTailscaledSocket(), -} - -// Run runs the CLI. The args do not include the binary name. -func Run(args []string) (err error) { - if runtime.GOOS == "linux" && os.Getenv("GOKRAZY_FIRST_START") == "1" && distro.Get() == distro.Gokrazy && os.Getppid() == 1 { - // We're running on gokrazy and it's the first start. - // Don't run the tailscale CLI as a service; just exit. - // See https://gokrazy.org/development/process-interface/ - os.Exit(0) - } - - args = CleanUpArgs(args) - - if len(args) == 1 { - switch args[0] { - case "-V", "--version": - args = []string{"version"} - case "help": - args = []string{"--help"} - } - } - - var warnOnce sync.Once - tailscale.SetVersionMismatchHandler(func(clientVer, serverVer string) { - warnOnce.Do(func() { - fmt.Fprintf(Stderr, "Warning: client version %q != tailscaled server version %q\n", clientVer, serverVer) - }) - }) - - rootCmd := newRootCmd() - if err := rootCmd.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - if noexec := (ffcli.NoExecError{}); errors.As(err, &noexec) { - // When the user enters an unknown subcommand, ffcli tries to run - // the closest valid parent subcommand with everything else as args, - // returning NoExecError if it doesn't have an Exec function. - cmd := noexec.Command - args := cmd.FlagSet.Args() - if len(cmd.Subcommands) > 0 { - if len(args) > 0 { - return fmt.Errorf("%s: unknown subcommand: %s", fullCmd(rootCmd, cmd), args[0]) - } - subs := make([]string, 0, len(cmd.Subcommands)) - for _, sub := range cmd.Subcommands { - subs = append(subs, sub.Name) - } - return fmt.Errorf("%s: missing subcommand: %s", fullCmd(rootCmd, cmd), strings.Join(subs, ", ")) - } - } - return err - } - - if envknob.Bool("TS_DUMP_HELP") { - walkCommands(rootCmd, func(w cmdWalk) bool { - fmt.Println("===") - // UsageFuncs are typically called during Command.Run which ensures - // FlagSet is not nil. - c := w.Command - if c.FlagSet == nil { - c.FlagSet = flag.NewFlagSet(c.Name, flag.ContinueOnError) - } - if c.UsageFunc != nil { - fmt.Println(c.UsageFunc(c)) - } else { - fmt.Println(ffcli.DefaultUsageFunc(c)) - } - return true - }) - return - } - - err = rootCmd.Run(context.Background()) - if tailscale.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" { - return fmt.Errorf("%v\n\nUse 'sudo tailscale %s' or 'tailscale up --operator=$USER' to not require root.", err, strings.Join(args, " ")) - } - if errors.Is(err, flag.ErrHelp) { - return nil - } - return err -} - -func newRootCmd() *ffcli.Command { - rootfs := newFlagSet("tailscale") - rootfs.Func("socket", "path to tailscaled socket", func(s string) error { - localClient.Socket = s - localClient.UseSocketOnly = true - return nil - }) - rootfs.Lookup("socket").DefValue = localClient.Socket - - rootCmd := &ffcli.Command{ - Name: "tailscale", - ShortUsage: "tailscale [flags] [command flags]", - ShortHelp: "The easiest, most secure way to use WireGuard.", - LongHelp: strings.TrimSpace(` -For help on subcommands, add --help after: "tailscale status --help". - -This CLI is still under active development. Commands and flags will -change in the future. -`), - Subcommands: append([]*ffcli.Command{ - upCmd, - downCmd, - setCmd, - loginCmd, - logoutCmd, - switchCmd, - configureCmd, - syspolicyCmd, - netcheckCmd, - ipCmd, - dnsCmd, - statusCmd, - metricsCmd, - pingCmd, - ncCmd, - sshCmd, - funnelCmd(), - serveCmd(), - versionCmd, - webCmd, - fileCmd, - bugReportCmd, - certCmd, - netlockCmd, - licensesCmd, - exitNodeCmd(), - updateCmd, - whoisCmd, - debugCmd, - driveCmd, - idTokenCmd, - }, maybeAdvertiseCmd()...), - FlagSet: rootfs, - Exec: func(ctx context.Context, args []string) error { - if len(args) > 0 { - return fmt.Errorf("tailscale: unknown subcommand: %s", args[0]) - } - return flag.ErrHelp - }, - } - - if runtime.GOOS == "linux" && distro.Get() == distro.Synology { - rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd) - } - - walkCommands(rootCmd, func(w cmdWalk) bool { - if w.UsageFunc == nil { - w.UsageFunc = usageFunc - } - return true - }) - - ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc) - return rootCmd -} - -func fatalf(format string, a ...any) { - if Fatalf != nil { - Fatalf(format, a...) - return - } - log.SetFlags(0) - log.Fatalf(format, a...) -} - -// Fatalf, if non-nil, is used instead of log.Fatalf. -var Fatalf func(format string, a ...any) - -type cmdWalk struct { - *ffcli.Command - parents []*ffcli.Command -} - -func (w cmdWalk) Path() string { - if len(w.parents) == 0 { - return w.Name - } - - var sb strings.Builder - for _, p := range w.parents { - sb.WriteString(p.Name) - sb.WriteString(" ") - } - sb.WriteString(w.Name) - return sb.String() -} - -// walkCommands calls f for root and all of its nested subcommands until f -// returns false or all have been visited. -func walkCommands(root *ffcli.Command, f func(w cmdWalk) (more bool)) { - var walk func(cmd *ffcli.Command, parents []*ffcli.Command, f func(cmdWalk) bool) bool - walk = func(cmd *ffcli.Command, parents []*ffcli.Command, f func(cmdWalk) bool) bool { - if !f(cmdWalk{cmd, parents}) { - return false - } - parents = append(parents, cmd) - for _, sub := range cmd.Subcommands { - if !walk(sub, parents, f) { - return false - } - } - return true - } - walk(root, nil, f) -} - -// fullCmd returns the full "tailscale ... cmd" invocation for a subcommand. -func fullCmd(root, cmd *ffcli.Command) (full string) { - walkCommands(root, func(w cmdWalk) bool { - if w.Command == cmd { - full = w.Path() - return false - } - return true - }) - if full == "" { - return cmd.Name - } - return full -} - -// usageFuncNoDefaultValues is like usageFunc but doesn't print default values. -func usageFuncNoDefaultValues(c *ffcli.Command) string { - return usageFuncOpt(c, false) -} - -func usageFunc(c *ffcli.Command) string { - return usageFuncOpt(c, true) -} - -// hidden is the prefix that hides subcommands and flags from --help output when -// found at the start of the subcommand's LongHelp or flag's Usage. -const hidden = "HIDDEN: " - -func usageFuncOpt(c *ffcli.Command, withDefaults bool) string { - var b strings.Builder - - if c.ShortHelp != "" { - fmt.Fprintf(&b, "%s\n\n", c.ShortHelp) - } - - fmt.Fprintf(&b, "USAGE\n") - if c.ShortUsage != "" { - fmt.Fprintf(&b, " %s\n", strings.ReplaceAll(c.ShortUsage, "\n", "\n ")) - } else { - fmt.Fprintf(&b, " %s\n", c.Name) - } - fmt.Fprintf(&b, "\n") - - if help := strings.TrimPrefix(c.LongHelp, hidden); help != "" { - fmt.Fprintf(&b, "%s\n\n", help) - } - - if len(c.Subcommands) > 0 { - fmt.Fprintf(&b, "SUBCOMMANDS\n") - tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) - for _, subcommand := range c.Subcommands { - if strings.HasPrefix(subcommand.LongHelp, hidden) { - continue - } - fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp) - } - tw.Flush() - fmt.Fprintf(&b, "\n") - } - - if countFlags(c.FlagSet) > 0 { - fmt.Fprintf(&b, "FLAGS\n") - tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) - c.FlagSet.VisitAll(func(f *flag.Flag) { - var s string - name, usage := flag.UnquoteUsage(f) - if strings.HasPrefix(usage, hidden) { - return - } - if isBoolFlag(f) { - s = fmt.Sprintf(" --%s, --%s=false", f.Name, f.Name) - } else { - s = fmt.Sprintf(" --%s", f.Name) // Two spaces before --; see next two comments. - if len(name) > 0 { - s += " " + name - } - } - // Four spaces before the tab triggers good alignment - // for both 4- and 8-space tab stops. - s += "\n \t" - s += strings.ReplaceAll(usage, "\n", "\n \t") - - showDefault := f.DefValue != "" && withDefaults - // Issue 6766: don't show the default Windows socket path. It's long - // and distracting. And people on on Windows aren't likely to ever - // change it anyway. - if runtime.GOOS == "windows" && f.Name == "socket" && strings.HasPrefix(f.DefValue, `\\.\pipe\ProtectedPrefix\`) { - showDefault = false - } - if showDefault { - s += fmt.Sprintf(" (default %s)", f.DefValue) - } - - fmt.Fprintln(&b, s) - }) - tw.Flush() - fmt.Fprintf(&b, "\n") - } - - return strings.TrimSpace(b.String()) -} - -func isBoolFlag(f *flag.Flag) bool { - bf, ok := f.Value.(interface { - IsBoolFlag() bool - }) - return ok && bf.IsBoolFlag() -} - -func countFlags(fs *flag.FlagSet) (n int) { - fs.VisitAll(func(*flag.Flag) { n++ }) - return n -} - -// colorableOutput returns a colorable writer if stdout is a terminal (not, say, -// redirected to a file or pipe), the Stdout writer is os.Stdout (we're not -// embedding the CLI in wasm or a mobile app), and NO_COLOR is not set (see -// https://no-color.org/). If any of those is not the case, ok is false -// and w is Stdout. -func colorableOutput() (w io.Writer, ok bool) { - if Stdout != os.Stdout || - os.Getenv("NO_COLOR") != "" || - !isatty.IsTerminal(os.Stdout.Fd()) { - return Stdout, false - } - return colorable.NewColorableStdout(), true -} diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go deleted file mode 100644 index 0444e914c7260..0000000000000 --- a/cmd/tailscale/cli/cli_test.go +++ /dev/null @@ -1,1513 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - stdcmp "cmp" - "encoding/json" - "flag" - "fmt" - "io" - "net/netip" - "reflect" - "strings" - "testing" - - qt "github.com/frankban/quicktest" - "github.com/google/go-cmp/cmp" - "tailscale.com/envknob" - "tailscale.com/health/healthmsg" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/tstest" - "tailscale.com/types/logger" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/preftype" - "tailscale.com/version/distro" -) - -func TestPanicIfAnyEnvCheckedInInit(t *testing.T) { - envknob.PanicIfAnyEnvCheckedInInit() -} - -func TestShortUsage(t *testing.T) { - t.Setenv("TAILSCALE_USE_WIP_CODE", "1") - if !envknob.UseWIPCode() { - t.Fatal("expected envknob.UseWIPCode() to be true") - } - - walkCommands(newRootCmd(), func(w cmdWalk) bool { - c, parents := w.Command, w.parents - - // Words that we expect to be in the usage. - words := make([]string, len(parents)+1) - for i, parent := range parents { - words[i] = parent.Name - } - words[len(parents)] = c.Name - - // Check the ShortHelp starts with a capital letter. - if prefix, help := trimPrefixes(c.ShortHelp, "HIDDEN: ", "[ALPHA] ", "[BETA] "); help != "" { - if 'a' <= help[0] && help[0] <= 'z' { - if len(help) > 20 { - help = help[:20] + "…" - } - caphelp := string(help[0]-'a'+'A') + help[1:] - t.Errorf("command: %s: ShortHelp %q should start with a capital letter %q", strings.Join(words, " "), prefix+help, prefix+caphelp) - } - } - - // Check all words appear in the usage. - usage := c.ShortUsage - for _, word := range words { - var ok bool - usage, ok = cutWord(usage, word) - if !ok { - full := strings.Join(words, " ") - t.Errorf("command: %s: usage %q should contain the full path %q", full, c.ShortUsage, full) - return true - } - } - return true - }) -} - -func trimPrefixes(full string, prefixes ...string) (trimmed, remaining string) { - s := full -start: - for _, p := range prefixes { - var ok bool - s, ok = strings.CutPrefix(s, p) - if ok { - goto start - } - } - return full[:len(full)-len(s)], s -} - -// cutWord("tailscale debug scale 123", "scale") returns (" 123", true). -func cutWord(s, w string) (after string, ok bool) { - var p string - for { - p, s, ok = strings.Cut(s, w) - if !ok { - return "", false - } - if p != "" && isWordChar(p[len(p)-1]) { - continue - } - if s != "" && isWordChar(s[0]) { - continue - } - return s, true - } -} - -func isWordChar(r byte) bool { - return r == '_' || - ('0' <= r && r <= '9') || - ('A' <= r && r <= 'Z') || - ('a' <= r && r <= 'z') -} - -func TestCutWord(t *testing.T) { - tests := []struct { - in string - word string - out string - ok bool - }{ - {"tailscale debug", "debug", "", true}, - {"tailscale debug", "bug", "", false}, - {"tailscale debug", "tail", "", false}, - {"tailscale debug scaley scale 123", "scale", " 123", true}, - } - for _, test := range tests { - out, ok := cutWord(test.in, test.word) - if out != test.out || ok != test.ok { - t.Errorf("cutWord(%q, %q) = (%q, %t), wanted (%q, %t)", test.in, test.word, out, ok, test.out, test.ok) - } - } -} - -// geese is a collection of gooses. It need not be complete. -// But it should include anything handled specially (e.g. linux, windows) -// and at least one thing that's not (darwin, freebsd). -var geese = []string{"linux", "darwin", "windows", "freebsd"} - -// Test that checkForAccidentalSettingReverts's updateMaskedPrefsFromUpFlag can handle -// all flags. This will panic if a new flag creeps in that's unhandled. -// -// Also, issue 1880: advertise-exit-node was being ignored. Verify that all flags cause an edit. -func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) { - for _, goos := range geese { - var upArgs upArgsT - fs := newUpFlagSet(goos, &upArgs, "up") - fs.VisitAll(func(f *flag.Flag) { - mp := new(ipn.MaskedPrefs) - updateMaskedPrefsFromUpOrSetFlag(mp, f.Name) - got := mp.Pretty() - wantEmpty := preflessFlag(f.Name) - isEmpty := got == "MaskedPrefs{}" - if isEmpty != wantEmpty { - t.Errorf("flag %q created MaskedPrefs %s; want empty=%v", f.Name, got, wantEmpty) - } - }) - } -} - -func TestCheckForAccidentalSettingReverts(t *testing.T) { - tests := []struct { - name string - flags []string // argv to be parsed by FlagSet - curPrefs *ipn.Prefs - - curExitNodeIP netip.Addr - curUser string // os.Getenv("USER") on the client side - goos string // empty means "linux" - distro distro.Distro - - want string - }{ - { - name: "bare_up_means_up", - flags: []string{}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - WantRunning: false, - Hostname: "foo", - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", - }, - { - name: "losing_hostname", - flags: []string{"--accept-dns"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - WantRunning: false, - Hostname: "foo", - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --accept-dns --hostname=foo", - }, - { - name: "hostname_changing_explicitly", - flags: []string{"--hostname=bar"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - Hostname: "foo", - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", - }, - { - name: "hostname_changing_empty_explicitly", - flags: []string{"--hostname="}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - Hostname: "foo", - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", - }, - { - // Issue 1725: "tailscale up --authkey=..." (or other non-empty flags) works from - // a fresh server's initial prefs. - name: "up_with_default_prefs", - flags: []string{"--authkey=foosdlkfjskdljf"}, - curPrefs: ipn.NewPrefs(), - want: "", - }, - { - name: "implicit_operator_change", - flags: []string{"--hostname=foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - OperatorUser: "alice", - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - curUser: "eve", - want: accidentalUpPrefix + " --hostname=foo --operator=alice", - }, - { - name: "implicit_operator_matches_shell_user", - flags: []string{"--hostname=foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - OperatorUser: "alice", - NoStatefulFiltering: opt.NewBool(true), - }, - curUser: "alice", - want: "", - }, - { - name: "error_advertised_routes_exit_node_removed", - flags: []string{"--advertise-routes=10.0.42.0/24"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("10.0.42.0/24"), - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - }, - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --advertise-routes=10.0.42.0/24 --advertise-exit-node", - }, - { - name: "advertised_routes_exit_node_removed_explicit", - flags: []string{"--advertise-routes=10.0.42.0/24", "--advertise-exit-node=false"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("10.0.42.0/24"), - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - }, - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", - }, - { - name: "advertised_routes_includes_the_0_routes", // but no --advertise-exit-node - flags: []string{"--advertise-routes=11.1.43.0/24,0.0.0.0/0,::/0"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("10.0.42.0/24"), - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - }, - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", - }, - { - name: "advertise_exit_node", // Issue 1859 - flags: []string{"--advertise-exit-node"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", - }, - { - name: "advertise_exit_node_over_existing_routes", - flags: []string{"--advertise-exit-node"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("1.2.0.0/16"), - }, - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16", - }, - { - name: "advertise_exit_node_over_existing_routes_and_exit_node", - flags: []string{"--advertise-exit-node"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - netip.MustParsePrefix("1.2.0.0/16"), - }, - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16", - }, - { - name: "exit_node_clearing", // Issue 1777 - flags: []string{"--exit-node="}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - - ExitNodeID: "fooID", - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", - }, - { - name: "remove_all_implicit", - flags: []string{"--force-reauth"}, - curPrefs: &ipn.Prefs{ - WantRunning: true, - ControlURL: ipn.DefaultControlURL, - RouteAll: true, - ExitNodeIP: netip.MustParseAddr("100.64.5.6"), - CorpDNS: false, - ShieldsUp: true, - AdvertiseTags: []string{"tag:foo", "tag:bar"}, - Hostname: "myhostname", - ForceDaemon: true, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/16"), - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - }, - NetfilterMode: preftype.NetfilterNoDivert, - OperatorUser: "alice", - NoStatefulFiltering: opt.NewBool(true), - }, - curUser: "eve", - want: accidentalUpPrefix + " --force-reauth --accept-dns=false --accept-routes --advertise-exit-node --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --hostname=myhostname --netfilter-mode=nodivert --operator=alice --shields-up", - }, - { - name: "remove_all_implicit_except_hostname", - flags: []string{"--hostname=newhostname"}, - curPrefs: &ipn.Prefs{ - WantRunning: true, - ControlURL: ipn.DefaultControlURL, - RouteAll: true, - ExitNodeIP: netip.MustParseAddr("100.64.5.6"), - CorpDNS: false, - ShieldsUp: true, - AdvertiseTags: []string{"tag:foo", "tag:bar"}, - Hostname: "myhostname", - ForceDaemon: true, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/16"), - }, - NetfilterMode: preftype.NetfilterNoDivert, - OperatorUser: "alice", - NoStatefulFiltering: opt.NewBool(true), - }, - curUser: "eve", - want: accidentalUpPrefix + " --hostname=newhostname --accept-dns=false --accept-routes --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --netfilter-mode=nodivert --operator=alice --shields-up", - }, - { - name: "loggedout_is_implicit", - flags: []string{"--hostname=foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - LoggedOut: true, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", // not an error. LoggedOut is implicit. - }, - { - // Test that a pre-1.8 version of Tailscale with bogus NoSNAT pref - // values is able to enable exit nodes without warnings. - name: "make_windows_exit_node", - flags: []string{"--advertise-exit-node"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - RouteAll: true, - - // And assume this no-op accidental pre-1.8 value: - NoSNAT: true, - }, - goos: "windows", - want: "", // not an error - }, - { - name: "ignore_netfilter_change_non_linux", - flags: []string{"--accept-dns"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - - NetfilterMode: preftype.NetfilterNoDivert, // we never had this bug, but pretend it got set non-zero on Windows somehow - }, - goos: "openbsd", - want: "", // not an error - }, - { - name: "operator_losing_routes_step1", // https://twitter.com/EXPbits/status/1390418145047887877 - flags: []string{"--operator=expbits"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - netip.MustParsePrefix("1.2.0.0/16"), - }, - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --operator=expbits --advertise-exit-node --advertise-routes=1.2.0.0/16", - }, - { - name: "operator_losing_routes_step2", // https://twitter.com/EXPbits/status/1390418145047887877 - flags: []string{"--operator=expbits", "--advertise-routes=1.2.0.0/16"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - netip.MustParsePrefix("1.2.0.0/16"), - }, - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --advertise-routes=1.2.0.0/16 --operator=expbits --advertise-exit-node", - }, - { - name: "errors_preserve_explicit_flags", - flags: []string{"--reset", "--force-reauth=false", "--authkey=secretrand"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - WantRunning: false, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - - Hostname: "foo", - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --auth-key=secretrand --force-reauth=false --reset --hostname=foo", - }, - { - name: "error_exit_node_omit_with_ip_pref", - flags: []string{"--hostname=foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - - ExitNodeIP: netip.MustParseAddr("100.64.5.4"), - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.4", - }, - { - name: "error_exit_node_omit_with_id_pref", - flags: []string{"--hostname=foo"}, - curExitNodeIP: netip.MustParseAddr("100.64.5.7"), - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - - ExitNodeID: "some_stable_id", - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.7", - }, - { - name: "error_exit_node_and_allow_lan_omit_with_id_pref", // Issue 3480 - flags: []string{"--hostname=foo"}, - curExitNodeIP: netip.MustParseAddr("100.2.3.4"), - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - - ExitNodeAllowLANAccess: true, - ExitNodeID: "some_stable_id", - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --hostname=foo --exit-node-allow-lan-access --exit-node=100.2.3.4", - }, - { - name: "ignore_login_server_synonym", - flags: []string{"--login-server=https://controlplane.tailscale.com"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - want: "", // not an error - }, - { - name: "ignore_login_server_synonym_on_other_change", - flags: []string{"--netfilter-mode=off"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - CorpDNS: false, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - want: accidentalUpPrefix + " --netfilter-mode=off --accept-dns=false", - }, - { - // Issue 3176: on Synology, don't require --accept-routes=false because user - // might've had an old install, and we don't support --accept-routes anyway. - name: "synology_permit_omit_accept_routes", - flags: []string{"--hostname=foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - CorpDNS: true, - RouteAll: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - goos: "linux", - distro: distro.Synology, - want: "", - }, - { - // Same test case as "synology_permit_omit_accept_routes" above, but - // on non-Synology distro. - name: "not_synology_dont_permit_omit_accept_routes", - flags: []string{"--hostname=foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - CorpDNS: true, - RouteAll: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - goos: "linux", - distro: "", // not Synology - want: accidentalUpPrefix + " --hostname=foo --accept-routes", - }, - { - name: "profile_name_ignored_in_up", - flags: []string{"--hostname=foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - ProfileName: "foo", - NoStatefulFiltering: opt.NewBool(true), - }, - goos: "linux", - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - goos := "linux" - if tt.goos != "" { - goos = tt.goos - } - var upArgs upArgsT - flagSet := newUpFlagSet(goos, &upArgs, "up") - flags := CleanUpArgs(tt.flags) - flagSet.Parse(flags) - newPrefs, err := prefsFromUpArgs(upArgs, t.Logf, new(ipnstate.Status), goos) - if err != nil { - t.Fatal(err) - } - upEnv := upCheckEnv{ - goos: goos, - flagSet: flagSet, - curExitNodeIP: tt.curExitNodeIP, - distro: tt.distro, - user: tt.curUser, - } - applyImplicitPrefs(newPrefs, tt.curPrefs, upEnv) - var got string - if err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upEnv); err != nil { - got = err.Error() - } - if strings.TrimSpace(got) != tt.want { - t.Errorf("unexpected result\n got: %s\nwant: %s\n", got, tt.want) - } - }) - } -} - -func upArgsFromOSArgs(goos string, flagArgs ...string) (args upArgsT) { - fs := newUpFlagSet(goos, &args, "up") - fs.Parse(flagArgs) // populates args - return -} - -func TestPrefsFromUpArgs(t *testing.T) { - tests := []struct { - name string - args upArgsT - goos string // runtime.GOOS; empty means linux - st *ipnstate.Status // or nil - want *ipn.Prefs - wantErr string - wantWarn string - }{ - { - name: "default_linux", - goos: "linux", - args: upArgsFromOSArgs("linux"), - want: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - WantRunning: true, - NoSNAT: false, - NoStatefulFiltering: "true", - NetfilterMode: preftype.NetfilterOn, - CorpDNS: true, - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - }, - }, - }, - { - name: "default_windows", - goos: "windows", - args: upArgsFromOSArgs("windows"), - want: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - WantRunning: true, - CorpDNS: true, - RouteAll: true, - NoSNAT: false, - NoStatefulFiltering: "true", - NetfilterMode: preftype.NetfilterOn, - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - }, - }, - }, - { - name: "advertise_default_route", - args: upArgsFromOSArgs("linux", "--advertise-exit-node"), - want: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - WantRunning: true, - CorpDNS: true, - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - }, - NoStatefulFiltering: "true", - NetfilterMode: preftype.NetfilterOn, - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - }, - }, - }, - { - name: "error_advertise_route_invalid_ip", - args: upArgsT{ - advertiseRoutes: "foo", - }, - wantErr: `"foo" is not a valid IP address or CIDR prefix`, - }, - { - name: "error_advertise_route_unmasked_bits", - args: upArgsT{ - advertiseRoutes: "1.2.3.4/16", - }, - wantErr: `1.2.3.4/16 has non-address bits set; expected 1.2.0.0/16`, - }, - { - name: "error_exit_node_bad_ip", - args: upArgsT{ - exitNodeIP: "foo", - }, - wantErr: `invalid value "foo" for --exit-node; must be IP or unique node name`, - }, - { - name: "error_exit_node_allow_lan_without_exit_node", - args: upArgsT{ - exitNodeAllowLANAccess: true, - }, - wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`, - }, - { - name: "error_tag_prefix", - args: upArgsT{ - advertiseTags: "foo", - }, - wantErr: `tag: "foo": tags must start with 'tag:'`, - }, - { - name: "error_long_hostname", - args: upArgsT{ - hostname: strings.Repeat(strings.Repeat("a", 63)+".", 4), - }, - 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", - args: upArgsT{ - netfilterMode: "", - }, - wantErr: `invalid value --netfilter-mode=""`, - }, - { - name: "error_linux_netfilter_bogus", - args: upArgsT{ - netfilterMode: "bogus", - }, - wantErr: `invalid value --netfilter-mode="bogus"`, - }, - { - name: "error_exit_node_ip_is_self_ip", - args: upArgsT{ - exitNodeIP: "100.105.106.107", - }, - st: &ipnstate.Status{ - TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.105.106.107")}, - }, - wantErr: `cannot use 100.105.106.107 as an exit node as it is a local IP address to this machine; did you mean --advertise-exit-node?`, - }, - { - name: "warn_linux_netfilter_nodivert", - goos: "linux", - args: upArgsT{ - netfilterMode: "nodivert", - }, - wantWarn: "netfilter=nodivert; add iptables calls to ts-* chains manually.", - want: &ipn.Prefs{ - WantRunning: true, - NetfilterMode: preftype.NetfilterNoDivert, - NoSNAT: true, - NoStatefulFiltering: "true", - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - }, - }, - }, - { - name: "warn_linux_netfilter_off", - goos: "linux", - args: upArgsT{ - netfilterMode: "off", - }, - wantWarn: "netfilter=off; configure iptables yourself.", - want: &ipn.Prefs{ - WantRunning: true, - NetfilterMode: preftype.NetfilterOff, - NoSNAT: true, - NoStatefulFiltering: "true", - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - }, - }, - }, - { - name: "via_route_good", - goos: "linux", - args: upArgsT{ - advertiseRoutes: "fd7a:115c:a1e0:b1a::bb:10.0.0.0/112", - netfilterMode: "off", - }, - want: &ipn.Prefs{ - WantRunning: true, - NoSNAT: true, - NoStatefulFiltering: "true", - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"), - }, - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - }, - }, - }, - { - name: "via_route_good_16_bit", - goos: "linux", - args: upArgsT{ - advertiseRoutes: "fd7a:115c:a1e0:b1a::aabb:10.0.0.0/112", - netfilterMode: "off", - }, - want: &ipn.Prefs{ - WantRunning: true, - NoSNAT: true, - NoStatefulFiltering: "true", - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("fd7a:115c:a1e0:b1a::aabb:10.0.0.0/112"), - }, - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - }, - }, - }, - { - name: "via_route_short_prefix", - goos: "linux", - args: upArgsT{ - advertiseRoutes: "fd7a:115c:a1e0:b1a::/64", - netfilterMode: "off", - }, - wantErr: "fd7a:115c:a1e0:b1a::/64 4-in-6 prefix must be at least a /96", - }, - { - name: "via_route_short_reserved_siteid", - goos: "linux", - args: upArgsT{ - advertiseRoutes: "fd7a:115c:a1e0:b1a:1234:5678::/112", - netfilterMode: "off", - }, - wantErr: "route fd7a:115c:a1e0:b1a:1234:5678::/112 contains invalid site ID 12345678; must be 0xffff or less", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var warnBuf tstest.MemLogger - goos := stdcmp.Or(tt.goos, "linux") - st := tt.st - if st == nil { - st = new(ipnstate.Status) - } - got, err := prefsFromUpArgs(tt.args, warnBuf.Logf, st, goos) - gotErr := fmt.Sprint(err) - if tt.wantErr != "" { - if tt.wantErr != gotErr { - t.Errorf("wrong error.\n got error: %v\nwant error: %v\n", gotErr, tt.wantErr) - } - return - } - if err != nil { - t.Fatal(err) - } - if tt.want == nil { - t.Fatal("tt.want is nil") - } - if !got.Equals(tt.want) { - jgot, _ := json.MarshalIndent(got, "", "\t") - jwant, _ := json.MarshalIndent(tt.want, "", "\t") - if bytes.Equal(jgot, jwant) { - t.Logf("prefs differ only in non-JSON-visible ways (nil/non-nil zero-length arrays)") - } - t.Errorf("wrong prefs\n got: %s\nwant: %s\n\ngot: %s\nwant: %s\n", - got.Pretty(), tt.want.Pretty(), - jgot, jwant, - ) - - } - }) - } - -} - -func TestPrefFlagMapping(t *testing.T) { - prefHasFlag := map[string]bool{} - for _, pv := range prefsOfFlag { - for _, pref := range pv { - prefHasFlag[strings.Split(pref, ".")[0]] = true - } - } - - prefType := reflect.TypeFor[ipn.Prefs]() - for i := range prefType.NumField() { - prefName := prefType.Field(i).Name - if prefHasFlag[prefName] { - continue - } - switch prefName { - case "AllowSingleHosts": - // Fake pref for downgrade compat. See #12058. - continue - case "WantRunning", "Persist", "LoggedOut": - // All explicitly handled (ignored) by checkForAccidentalSettingReverts. - continue - case "OSVersion", "DeviceModel": - // Only used by Android, which doesn't have a CLI mode anyway, so - // fine to not map. - continue - case "NotepadURLs": - // TODO(bradfitz): https://github.com/tailscale/tailscale/issues/1830 - continue - case "Egg": - // Not applicable. - continue - case "RunWebClient": - // TODO(tailscale/corp#14335): Currently behind a feature flag. - continue - case "NetfilterKind": - // Handled by TS_DEBUG_FIREWALL_MODE env var, we don't want to have - // a CLI flag for this. The Pref is used by c2n. - continue - case "DriveShares": - // Handled by the tailscale share subcommand, we don't want a CLI - // flag for this. - continue - case "AdvertiseServices": - // Handled by the tailscale advertise subcommand, we don't want a - // CLI flag for this. - continue - case "InternalExitNodePrior": - // Used internally by LocalBackend as part of exit node usage toggling. - // No CLI flag for this. - continue - } - t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName) - } -} - -func TestFlagAppliesToOS(t *testing.T) { - for _, goos := range geese { - var upArgs upArgsT - fs := newUpFlagSet(goos, &upArgs, "up") - fs.VisitAll(func(f *flag.Flag) { - if !flagAppliesToOS(f.Name, goos) { - t.Errorf("flagAppliesToOS(%q, %q) = false but found in %s set", f.Name, goos, goos) - } - }) - } -} - -func TestUpdatePrefs(t *testing.T) { - tests := []struct { - name string - flags []string // argv to be parsed into env.flagSet and env.upArgs - curPrefs *ipn.Prefs - env upCheckEnv // empty goos means "linux" - - // sshOverTailscale specifies if the cmd being run over SSH over Tailscale. - // It is used to test the --accept-risks flag. - sshOverTailscale bool - - // checkUpdatePrefsMutations, if non-nil, is run with the new prefs after - // updatePrefs might've mutated them (from applyImplicitPrefs). - checkUpdatePrefsMutations func(t *testing.T, newPrefs *ipn.Prefs) - - wantSimpleUp bool - wantJustEditMP *ipn.MaskedPrefs - wantErrSubtr string - }{ - { - name: "bare_up_means_up", - flags: []string{}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - WantRunning: false, - Hostname: "foo", - }, - }, - { - name: "just_up", - flags: []string{}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - }, - env: upCheckEnv{ - backendState: "Stopped", - }, - wantSimpleUp: true, - }, - { - name: "just_edit", - flags: []string{}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - }, - env: upCheckEnv{backendState: "Running"}, - wantSimpleUp: true, - wantJustEditMP: &ipn.MaskedPrefs{WantRunningSet: true}, - }, - { - name: "just_edit_reset", - flags: []string{"--reset"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - }, - env: upCheckEnv{backendState: "Running"}, - wantJustEditMP: &ipn.MaskedPrefs{ - AdvertiseRoutesSet: true, - AdvertiseTagsSet: true, - AppConnectorSet: true, - ControlURLSet: true, - CorpDNSSet: true, - ExitNodeAllowLANAccessSet: true, - ExitNodeIDSet: true, - ExitNodeIPSet: true, - HostnameSet: true, - NetfilterModeSet: true, - NoSNATSet: true, - NoStatefulFilteringSet: true, - OperatorUserSet: true, - RouteAllSet: true, - RunSSHSet: true, - ShieldsUpSet: true, - WantRunningSet: true, - }, - }, - { - name: "control_synonym", - flags: []string{}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - }, - env: upCheckEnv{backendState: "Running"}, - wantSimpleUp: true, - wantJustEditMP: &ipn.MaskedPrefs{WantRunningSet: true}, - }, - { - name: "change_login_server", - flags: []string{"--login-server=https://localhost:1000"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - env: upCheckEnv{backendState: "Running"}, - wantSimpleUp: true, - wantJustEditMP: &ipn.MaskedPrefs{WantRunningSet: true}, - wantErrSubtr: "can't change --login-server without --force-reauth", - }, - { - name: "change_tags", - flags: []string{"--advertise-tags=tag:foo"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - env: upCheckEnv{backendState: "Running"}, - }, - { - // Issue 3808: explicitly empty --operator= should clear value. - name: "explicit_empty_operator", - flags: []string{"--operator="}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - OperatorUser: "somebody", - NoStatefulFiltering: opt.NewBool(true), - }, - env: upCheckEnv{user: "somebody", backendState: "Running"}, - wantJustEditMP: &ipn.MaskedPrefs{ - OperatorUserSet: true, - WantRunningSet: true, - }, - checkUpdatePrefsMutations: func(t *testing.T, prefs *ipn.Prefs) { - if prefs.OperatorUser != "" { - t.Errorf("operator sent to backend should be empty; got %q", prefs.OperatorUser) - } - }, - }, - { - name: "enable_ssh", - flags: []string{"--ssh"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - RunSSHSet: true, - WantRunningSet: true, - }, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if !newPrefs.RunSSH { - t.Errorf("RunSSH not set to true") - } - }, - env: upCheckEnv{backendState: "Running"}, - }, - { - name: "disable_ssh", - flags: []string{"--ssh=false"}, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - RunSSH: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - RunSSHSet: true, - WantRunningSet: true, - }, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if newPrefs.RunSSH { - t.Errorf("RunSSH not set to false") - } - }, - env: upCheckEnv{backendState: "Running", upArgs: upArgsT{ - runSSH: true, - }}, - }, - { - name: "disable_ssh_over_ssh_no_risk", - flags: []string{"--ssh=false"}, - sshOverTailscale: true, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - RunSSH: true, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - RunSSHSet: true, - WantRunningSet: true, - }, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if !newPrefs.RunSSH { - t.Errorf("RunSSH not set to true") - } - }, - env: upCheckEnv{backendState: "Running"}, - wantErrSubtr: "aborted, no changes made", - }, - { - name: "enable_ssh_over_ssh_no_risk", - flags: []string{"--ssh=true"}, - sshOverTailscale: true, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - RunSSHSet: true, - WantRunningSet: true, - }, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if !newPrefs.RunSSH { - t.Errorf("RunSSH not set to true") - } - }, - env: upCheckEnv{backendState: "Running"}, - wantErrSubtr: "aborted, no changes made", - }, - { - name: "enable_ssh_over_ssh", - flags: []string{"--ssh=true", "--accept-risk=lose-ssh"}, - sshOverTailscale: true, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - RunSSHSet: true, - WantRunningSet: true, - }, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if !newPrefs.RunSSH { - t.Errorf("RunSSH not set to true") - } - }, - env: upCheckEnv{backendState: "Running"}, - }, - { - name: "disable_ssh_over_ssh", - flags: []string{"--ssh=false", "--accept-risk=lose-ssh"}, - sshOverTailscale: true, - curPrefs: &ipn.Prefs{ - ControlURL: "https://login.tailscale.com", - Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}}, - CorpDNS: true, - RunSSH: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - RunSSHSet: true, - WantRunningSet: true, - }, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if newPrefs.RunSSH { - t.Errorf("RunSSH not set to false") - } - }, - 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", - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - 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", - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: nil, - env: upCheckEnv{backendState: "Running"}, - }, - { - name: "advertise_connector", - flags: []string{"--advertise-connector"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - AppConnectorSet: true, - WantRunningSet: true, - }, - env: upCheckEnv{backendState: "Running"}, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if !newPrefs.AppConnector.Advertise { - t.Errorf("prefs.AppConnector.Advertise not set") - } - }, - }, - { - name: "no_advertise_connector", - flags: []string{"--advertise-connector=false"}, - curPrefs: &ipn.Prefs{ - ControlURL: ipn.DefaultControlURL, - CorpDNS: true, - NetfilterMode: preftype.NetfilterOn, - AppConnector: ipn.AppConnectorPrefs{ - Advertise: true, - }, - NoStatefulFiltering: opt.NewBool(true), - }, - wantJustEditMP: &ipn.MaskedPrefs{ - AppConnectorSet: true, - WantRunningSet: true, - }, - env: upCheckEnv{backendState: "Running"}, - checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { - if newPrefs.AppConnector.Advertise { - t.Errorf("prefs.AppConnector.Advertise not unset") - } - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.sshOverTailscale { - 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" - } - tt.env.flagSet = newUpFlagSet(tt.env.goos, &tt.env.upArgs, "up") - flags := CleanUpArgs(tt.flags) - if err := tt.env.flagSet.Parse(flags); err != nil { - t.Fatal(err) - } - - newPrefs, err := prefsFromUpArgs(tt.env.upArgs, t.Logf, new(ipnstate.Status), tt.env.goos) - if err != nil { - t.Fatal(err) - } - simpleUp, justEditMP, err := updatePrefs(newPrefs, tt.curPrefs, tt.env) - if err != nil { - if tt.wantErrSubtr != "" { - if !strings.Contains(err.Error(), tt.wantErrSubtr) { - t.Fatalf("want error %q, got: %v", tt.wantErrSubtr, err) - } - return - } - t.Fatal(err) - } else if tt.wantErrSubtr != "" { - t.Fatalf("want error %q, got nil", tt.wantErrSubtr) - } - if tt.checkUpdatePrefsMutations != nil { - tt.checkUpdatePrefsMutations(t, newPrefs) - } - if simpleUp != tt.wantSimpleUp { - t.Fatalf("simpleUp=%v, want %v", simpleUp, tt.wantSimpleUp) - } - var oldEditPrefs ipn.Prefs - if justEditMP != nil { - oldEditPrefs = justEditMP.Prefs - justEditMP.Prefs = ipn.Prefs{} // uninteresting - } - if !reflect.DeepEqual(justEditMP, tt.wantJustEditMP) { - t.Logf("justEditMP != wantJustEditMP; following diff omits the Prefs field, which was \n%v", logger.AsJSON(oldEditPrefs)) - t.Fatalf("justEditMP: %v\n\n: ", cmp.Diff(justEditMP, tt.wantJustEditMP, cmpIP)) - } - }) - } -} - -var cmpIP = cmp.Comparer(func(a, b netip.Addr) bool { - return a == b -}) - -func TestCleanUpArgs(t *testing.T) { - c := qt.New(t) - tests := []struct { - in []string - want []string - }{ - {in: []string{"something"}, want: []string{"something"}}, - {in: []string{}, want: []string{}}, - {in: []string{"--authkey=0"}, want: []string{"--auth-key=0"}}, - {in: []string{"a", "--authkey=1", "b"}, want: []string{"a", "--auth-key=1", "b"}}, - {in: []string{"a", "--auth-key=2", "b"}, want: []string{"a", "--auth-key=2", "b"}}, - {in: []string{"a", "-authkey=3", "b"}, want: []string{"a", "--auth-key=3", "b"}}, - {in: []string{"a", "-auth-key=4", "b"}, want: []string{"a", "-auth-key=4", "b"}}, - {in: []string{"a", "--authkey", "5", "b"}, want: []string{"a", "--auth-key", "5", "b"}}, - {in: []string{"a", "-authkey", "6", "b"}, want: []string{"a", "--auth-key", "6", "b"}}, - {in: []string{"a", "authkey", "7", "b"}, want: []string{"a", "authkey", "7", "b"}}, - {in: []string{"--authkeyexpiry", "8"}, want: []string{"--authkeyexpiry", "8"}}, - {in: []string{"--auth-key-expiry", "9"}, want: []string{"--auth-key-expiry", "9"}}, - } - - for _, tt := range tests { - got := CleanUpArgs(tt.in) - c.Assert(got, qt.DeepEquals, tt.want) - } -} - -func TestUpWorthWarning(t *testing.T) { - if !upWorthyWarning(healthmsg.WarnAcceptRoutesOff) { - t.Errorf("WarnAcceptRoutesOff of %q should be worth warning", healthmsg.WarnAcceptRoutesOff) - } - if !upWorthyWarning(healthmsg.TailscaleSSHOnBut + "some problem") { - t.Errorf("want true for SSH problems") - } - if upWorthyWarning("not in map poll") { - t.Errorf("want false for other misc errors") - } -} - -func TestParseNLArgs(t *testing.T) { - tcs := []struct { - name string - input []string - parseKeys bool - parseDisablements bool - - wantErr error - wantKeys []tka.Key - wantDisablements [][]byte - }{ - { - name: "empty", - input: nil, - parseKeys: true, - parseDisablements: true, - }, - { - name: "key no votes", - input: []string{"nlpub:" + strings.Repeat("00", 32)}, - parseKeys: true, - wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 1, Public: bytes.Repeat([]byte{0}, 32)}}, - }, - { - name: "key with votes", - input: []string{"nlpub:" + strings.Repeat("01", 32) + "?5"}, - parseKeys: true, - wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 5, Public: bytes.Repeat([]byte{1}, 32)}}, - }, - { - name: "disablements", - input: []string{"disablement:" + strings.Repeat("02", 32), "disablement-secret:" + strings.Repeat("03", 32)}, - parseDisablements: true, - wantDisablements: [][]byte{bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, 32)}, - }, - { - name: "disablements not allowed", - input: []string{"disablement:" + strings.Repeat("02", 32)}, - parseKeys: true, - wantErr: fmt.Errorf("parsing key 1: key hex string doesn't have expected type prefix tlpub:"), - }, - { - name: "keys not allowed", - input: []string{"nlpub:" + strings.Repeat("02", 32)}, - parseDisablements: true, - wantErr: fmt.Errorf("parsing argument 1: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", "nlpub:0202020202020202020202020202020202020202020202020202020202020202"), - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - keys, disablements, err := parseNLArgs(tc.input, tc.parseKeys, tc.parseDisablements) - if (tc.wantErr == nil && err != nil) || - (tc.wantErr != nil && err == nil) || - (tc.wantErr != nil && err != nil && tc.wantErr.Error() != err.Error()) { - t.Fatalf("parseNLArgs(%v).err = %v, want %v", tc.input, err, tc.wantErr) - } - - if !reflect.DeepEqual(keys, tc.wantKeys) { - t.Errorf("keys = %v, want %v", keys, tc.wantKeys) - } - if !reflect.DeepEqual(disablements, tc.wantDisablements) { - t.Errorf("disablements = %v, want %v", disablements, tc.wantDisablements) - } - }) - } -} - -func TestHelpAlias(t *testing.T) { - var stdout, stderr bytes.Buffer - tstest.Replace[io.Writer](t, &Stdout, &stdout) - tstest.Replace[io.Writer](t, &Stderr, &stderr) - - gotExit0 := false - defer func() { - if !gotExit0 { - t.Error("expected os.Exit(0) to be called") - return - } - if !strings.Contains(stderr.String(), "SUBCOMMANDS") { - t.Errorf("expected help output to contain SUBCOMMANDS; got stderr=%q; stdout=%q", stderr.String(), stdout.String()) - } - }() - defer func() { - if e := recover(); e != nil { - if strings.Contains(fmt.Sprint(e), "unexpected call to os.Exit(0)") { - gotExit0 = true - } else { - t.Errorf("unexpected panic: %v", e) - } - } - }() - err := Run([]string{"help"}) - if err != nil { - t.Fatalf("Run: %v", err) - } -} diff --git a/cmd/tailscale/cli/configure-kube.go b/cmd/tailscale/cli/configure-kube.go deleted file mode 100644 index 6af15e3d9ae7b..0000000000000 --- a/cmd/tailscale/cli/configure-kube.go +++ /dev/null @@ -1,201 +0,0 @@ -// 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" - "slices" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "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: "tailscale configure 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, error) { - if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" { - if version.IsSandboxedMacOS() { - return "", errors.New("$KUBECONFIG is incompatible with the App Store version") - } - var out string - for _, out = range filepath.SplitList(kubeconfig) { - if info, err := os.Stat(out); !os.IsNotExist(err) && !info.IsDir() { - break - } - } - return out, nil - } - - 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"), nil -} - -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, ".") - var kubeconfig string - if kubeconfig, err = kubeconfigPath(); err != nil { - return err - } - if err = setKubeconfigForPeer(targetFQDN, kubeconfig); 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 deleted file mode 100644 index d71a9b627e7f0..0000000000000 --- a/cmd/tailscale/cli/configure-kube_test.go +++ /dev/null @@ -1,221 +0,0 @@ -// 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: "all configs, clusters, users have been deleted", - in: `apiVersion: v1 -clusters: null -contexts: null -kind: Config -current-context: some-non-existent-cluster -users: null`, - 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-synology-cert.go b/cmd/tailscale/cli/configure-synology-cert.go deleted file mode 100644 index aabcb8dfad866..0000000000000 --- a/cmd/tailscale/cli/configure-synology-cert.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "log" - "os" - "os/exec" - "path" - "runtime" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/version/distro" -) - -var synologyConfigureCertCmd = &ffcli.Command{ - Name: "synology-cert", - Exec: runConfigureSynologyCert, - ShortHelp: "Configure Synology with a TLS certificate for your tailnet", - ShortUsage: "synology-cert [--domain ]", - LongHelp: strings.TrimSpace(` -This command is intended to run periodically as root on a Synology device to -create or refresh the TLS certificate for the tailnet domain. - -See: https://tailscale.com/kb/1153/enabling-https -`), - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("synology-cert") - fs.StringVar(&synologyConfigureCertArgs.domain, "domain", "", "Tailnet domain to create or refresh certificates for. Ignored if only one domain exists.") - return fs - })(), -} - -var synologyConfigureCertArgs struct { - domain string -} - -func runConfigureSynologyCert(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("unknown arguments") - } - if runtime.GOOS != "linux" || distro.Get() != distro.Synology { - return errors.New("only implemented on Synology") - } - if uid := os.Getuid(); uid != 0 { - return fmt.Errorf("must be run as root, not %q (%v)", os.Getenv("USER"), uid) - } - hi := hostinfo.New() - isDSM6 := strings.HasPrefix(hi.DistroVersion, "6.") - isDSM7 := strings.HasPrefix(hi.DistroVersion, "7.") - if !isDSM6 && !isDSM7 { - return fmt.Errorf("unsupported DSM version %q", hi.DistroVersion) - } - - domain := synologyConfigureCertArgs.domain - if st, err := localClient.Status(ctx); err == nil { - if st.BackendState != ipn.Running.String() { - return fmt.Errorf("Tailscale is not running.") - } else if len(st.CertDomains) == 0 { - return fmt.Errorf("TLS certificate support is not enabled/configured for your tailnet.") - } else if len(st.CertDomains) == 1 { - if domain != "" && domain != st.CertDomains[0] { - log.Printf("Ignoring supplied domain %q, TLS certificate will be created for %q.\n", domain, st.CertDomains[0]) - } - domain = st.CertDomains[0] - } else { - var found bool - for _, d := range st.CertDomains { - if d == domain { - found = true - break - } - } - if !found { - return fmt.Errorf("Domain %q was not one of the valid domain options: %q.", domain, st.CertDomains) - } - } - } - - // Check for an existing certificate, and replace it if it already exists - var id string - certs, err := listCerts(ctx, synowebapiCommand{}) - if err != nil { - return err - } - for _, c := range certs { - if c.Subject.CommonName == domain { - id = c.ID - break - } - } - - certPEM, keyPEM, err := localClient.CertPair(ctx, domain) - if err != nil { - return err - } - - // Certs have to be written to file for the upload command to work. - tmpDir, err := os.MkdirTemp("", "") - if err != nil { - return fmt.Errorf("can't create temp dir: %w", err) - } - defer os.RemoveAll(tmpDir) - keyFile := path.Join(tmpDir, "key.pem") - os.WriteFile(keyFile, keyPEM, 0600) - certFile := path.Join(tmpDir, "cert.pem") - os.WriteFile(certFile, certPEM, 0600) - - if err := uploadCert(ctx, synowebapiCommand{}, certFile, keyFile, id); err != nil { - return err - } - - return nil -} - -type subject struct { - CommonName string `json:"common_name"` -} - -type certificateInfo struct { - ID string `json:"id"` - Desc string `json:"desc"` - Subject subject `json:"subject"` -} - -// listCerts fetches a list of the certificates that DSM knows about -func listCerts(ctx context.Context, c synoAPICaller) ([]certificateInfo, error) { - rawData, err := c.Call(ctx, "SYNO.Core.Certificate.CRT", "list", nil) - if err != nil { - return nil, err - } - - var payload struct { - Certificates []certificateInfo `json:"certificates"` - } - if err := json.Unmarshal(rawData, &payload); err != nil { - return nil, fmt.Errorf("decoding certificate list response payload: %w", err) - } - - return payload.Certificates, nil -} - -// uploadCert creates or replaces a certificate. If id is given, it will attempt to replace the certificate with that ID. -func uploadCert(ctx context.Context, c synoAPICaller, certFile, keyFile string, id string) error { - params := map[string]string{ - "key_tmp": keyFile, - "cert_tmp": certFile, - "desc": "Tailnet Certificate", - } - if id != "" { - params["id"] = id - } - - rawData, err := c.Call(ctx, "SYNO.Core.Certificate", "import", params) - if err != nil { - return err - } - - var payload struct { - NewID string `json:"id"` - } - if err := json.Unmarshal(rawData, &payload); err != nil { - return fmt.Errorf("decoding certificate upload response payload: %w", err) - } - log.Printf("Tailnet Certificate uploaded with ID %q.", payload.NewID) - - return nil - -} - -type synoAPICaller interface { - Call(context.Context, string, string, map[string]string) (json.RawMessage, error) -} - -type apiResponse struct { - Success bool `json:"success"` - Error *apiError `json:"error,omitempty"` - Data json.RawMessage `json:"data"` -} - -type apiError struct { - Code int64 `json:"code"` - Errors string `json:"errors"` -} - -// synowebapiCommand implements synoAPICaller using the /usr/syno/bin/synowebapi binary. Must be run as root. -type synowebapiCommand struct{} - -func (s synowebapiCommand) Call(ctx context.Context, api, method string, params map[string]string) (json.RawMessage, error) { - args := []string{"--exec", fmt.Sprintf("api=%s", api), fmt.Sprintf("method=%s", method)} - - for k, v := range params { - args = append(args, fmt.Sprintf("%s=%q", k, v)) - } - - out, err := exec.CommandContext(ctx, "/usr/syno/bin/synowebapi", args...).Output() - if err != nil { - return nil, fmt.Errorf("calling %q method of %q API: %v, %s", method, api, err, out) - } - - var payload apiResponse - if err := json.Unmarshal(out, &payload); err != nil { - return nil, fmt.Errorf("decoding response json from %q method of %q API: %w", method, api, err) - } - - if payload.Error != nil { - return nil, fmt.Errorf("error response from %q method of %q API: %v", method, api, payload.Error) - } - - return payload.Data, nil -} diff --git a/cmd/tailscale/cli/configure-synology-cert_test.go b/cmd/tailscale/cli/configure-synology-cert_test.go deleted file mode 100644 index 801285e550d9b..0000000000000 --- a/cmd/tailscale/cli/configure-synology-cert_test.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "testing" -) - -type fakeAPICaller struct { - Data json.RawMessage - Error error -} - -func (c fakeAPICaller) Call(_ context.Context, _, _ string, _ map[string]string) (json.RawMessage, error) { - return c.Data, c.Error -} - -func Test_listCerts(t *testing.T) { - tests := []struct { - name string - caller synoAPICaller - want []certificateInfo - wantErr bool - }{ - { - name: "normal response", - caller: fakeAPICaller{ - Data: json.RawMessage(`{ -"certificates" : [ - { - "desc" : "Tailnet Certificate", - "id" : "cG2XBt", - "is_broken" : false, - "is_default" : false, - "issuer" : { - "common_name" : "R3", - "country" : "US", - "organization" : "Let's Encrypt" - }, - "key_types" : "ECC", - "renewable" : false, - "services" : [ - { - "display_name" : "DSM Desktop Service", - "display_name_i18n" : "common:web_desktop", - "isPkg" : false, - "multiple_cert" : true, - "owner" : "root", - "service" : "default", - "subscriber" : "system", - "user_setable" : true - } - ], - "signature_algorithm" : "sha256WithRSAEncryption", - "subject" : { - "common_name" : "foo.tailscale.ts.net", - "sub_alt_name" : [ "foo.tailscale.ts.net" ] - }, - "user_deletable" : true, - "valid_from" : "Sep 26 11:39:43 2023 GMT", - "valid_till" : "Dec 25 11:39:42 2023 GMT" - }, - { - "desc" : "", - "id" : "sgmnpb", - "is_broken" : false, - "is_default" : false, - "issuer" : { - "city" : "Taipei", - "common_name" : "Synology Inc. CA", - "country" : "TW", - "organization" : "Synology Inc." - }, - "key_types" : "", - "renewable" : false, - "self_signed_cacrt_info" : { - "issuer" : { - "city" : "Taipei", - "common_name" : "Synology Inc. CA", - "country" : "TW", - "organization" : "Synology Inc." - }, - "subject" : { - "city" : "Taipei", - "common_name" : "Synology Inc. CA", - "country" : "TW", - "organization" : "Synology Inc." - } - }, - "services" : [], - "signature_algorithm" : "sha256WithRSAEncryption", - "subject" : { - "city" : "Taipei", - "common_name" : "synology.com", - "country" : "TW", - "organization" : "Synology Inc.", - "sub_alt_name" : [] - }, - "user_deletable" : true, - "valid_from" : "May 27 00:23:19 2019 GMT", - "valid_till" : "Feb 11 00:23:19 2039 GMT" - } -] -}`), - Error: nil, - }, - want: []certificateInfo{ - {Desc: "Tailnet Certificate", ID: "cG2XBt", Subject: subject{CommonName: "foo.tailscale.ts.net"}}, - {Desc: "", ID: "sgmnpb", Subject: subject{CommonName: "synology.com"}}, - }, - }, - { - name: "call error", - caller: fakeAPICaller{nil, fmt.Errorf("caller failed")}, - wantErr: true, - }, - { - name: "payload decode error", - caller: fakeAPICaller{json.RawMessage("This isn't JSON!"), nil}, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := listCerts(context.Background(), tt.caller) - if (err != nil) != tt.wantErr { - t.Errorf("listCerts() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("listCerts() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/cmd/tailscale/cli/configure-synology.go b/cmd/tailscale/cli/configure-synology.go deleted file mode 100644 index 9d674e56dd79a..0000000000000 --- a/cmd/tailscale/cli/configure-synology.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "os" - "os/exec" - "runtime" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/hostinfo" - "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: runConfigureSynology, - ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage, - ShortHelp: synologyConfigureCmd.ShortHelp, - LongHelp: hidden + synologyConfigureCmd.LongHelp, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("configure-host") - return fs - })(), -} - -var synologyConfigureCmd = &ffcli.Command{ - Name: "synology", - Exec: runConfigureSynology, - ShortUsage: "tailscale configure synology", - ShortHelp: "Configure Synology to enable outbound connections", - LongHelp: strings.TrimSpace(` -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/s/synology-outbound -`), - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("synology") - return fs - })(), -} - -func runConfigureSynology(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("unknown arguments") - } - if runtime.GOOS != "linux" || distro.Get() != distro.Synology { - return errors.New("only implemented on Synology") - } - if uid := os.Getuid(); uid != 0 { - return fmt.Errorf("must be run as root, not %q (%v)", os.Getenv("USER"), uid) - } - hi := hostinfo.New() - isDSM6 := strings.HasPrefix(hi.DistroVersion, "6.") - isDSM7 := strings.HasPrefix(hi.DistroVersion, "7.") - if !isDSM6 && !isDSM7 { - return fmt.Errorf("unsupported DSM version %q", hi.DistroVersion) - } - if _, err := os.Stat("/dev/net/tun"); os.IsNotExist(err) { - if err := os.MkdirAll("/dev/net", 0755); err != nil { - return fmt.Errorf("creating /dev/net: %v", err) - } - if out, err := exec.Command("/bin/mknod", "/dev/net/tun", "c", "10", "200").CombinedOutput(); err != nil { - return fmt.Errorf("creating /dev/net/tun: %v, %s", err, out) - } - } - if err := os.Chmod("/dev/net", 0755); err != nil { - return err - } - if err := os.Chmod("/dev/net/tun", 0666); err != nil { - return err - } - if isDSM6 { - printf("/dev/net/tun exists and has permissions 0666. Skipping setcap on DSM6.\n") - return nil - } - - const daemonBin = "/var/packages/Tailscale/target/bin/tailscaled" - if _, err := os.Stat(daemonBin); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("tailscaled binary not found at %s. Is the Tailscale *.spk package installed?", daemonBin) - } - return err - } - if out, err := exec.Command("/bin/setcap", "cap_net_admin,cap_net_raw+eip", daemonBin).CombinedOutput(); err != nil { - return fmt.Errorf("setcap: %v, %s", err, out) - } - printf("Done. To restart Tailscale to use the new permissions, run:\n\n sudo synosystemctl restart pkgctl-Tailscale.service\n\n") - return nil -} diff --git a/cmd/tailscale/cli/configure.go b/cmd/tailscale/cli/configure.go deleted file mode 100644 index fd136d766360d..0000000000000 --- a/cmd/tailscale/cli/configure.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "flag" - "runtime" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/version/distro" -) - -var configureCmd = &ffcli.Command{ - Name: "configure", - ShortUsage: "tailscale 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(), -} - -func configureSubcommands() (out []*ffcli.Command) { - if runtime.GOOS == "linux" && distro.Get() == distro.Synology { - out = append(out, synologyConfigureCmd) - out = append(out, synologyConfigureCertCmd) - } - return out -} diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go deleted file mode 100644 index 78bd708e54fee..0000000000000 --- a/cmd/tailscale/cli/debug.go +++ /dev/null @@ -1,1247 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bufio" - "bytes" - "context" - "encoding/binary" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httputil" - "net/netip" - "net/url" - "os" - "os/exec" - "runtime" - "runtime/debug" - "strconv" - "strings" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "golang.org/x/net/http/httpproxy" - "golang.org/x/net/http2" - "tailscale.com/client/tailscale" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/control/controlhttp" - "tailscale.com/hostinfo" - "tailscale.com/internal/noiseconn" - "tailscale.com/ipn" - "tailscale.com/net/netmon" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tshttpproxy" - "tailscale.com/paths" - "tailscale.com/safesocket" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/must" - "tailscale.com/wgengine/capture" -) - -var debugCmd = &ffcli.Command{ - Name: "debug", - Exec: runDebug, - ShortUsage: "tailscale debug ", - ShortHelp: "Debug commands", - LongHelp: hidden + `"tailscale debug" contains misc debug facilities; it is not a stable interface.`, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("debug") - fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME") - fs.StringVar(&debugArgs.cpuFile, "cpu-profile", "", "if non-empty, grab a CPU profile for --profile-seconds seconds and write it to this file; - for stdout") - fs.StringVar(&debugArgs.memFile, "mem-profile", "", "if non-empty, grab a memory profile and write it to this file; - for stdout") - fs.IntVar(&debugArgs.cpuSec, "profile-seconds", 15, "number of seconds to run a CPU profile for, when --cpu-profile is non-empty") - return fs - })(), - Subcommands: []*ffcli.Command{ - { - Name: "derp-map", - ShortUsage: "tailscale debug derp-map", - Exec: runDERPMap, - ShortHelp: "Print DERP map", - }, - { - Name: "component-logs", - ShortUsage: "tailscale debug component-logs [" + strings.Join(ipn.DebuggableComponents, "|") + "]", - Exec: runDebugComponentLogs, - ShortHelp: "Enable/disable debug logs for a component", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("component-logs") - fs.DurationVar(&debugComponentLogsArgs.forDur, "for", time.Hour, "how long to enable debug logs for; zero or negative means to disable") - return fs - })(), - }, - { - Name: "daemon-goroutines", - ShortUsage: "tailscale debug daemon-goroutines", - Exec: runDaemonGoroutines, - ShortHelp: "Print tailscaled's goroutines", - }, - { - Name: "daemon-logs", - ShortUsage: "tailscale debug daemon-logs", - Exec: runDaemonLogs, - ShortHelp: "Watch tailscaled's server logs", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("daemon-logs") - fs.IntVar(&daemonLogsArgs.verbose, "verbose", 0, "verbosity level") - fs.BoolVar(&daemonLogsArgs.time, "time", false, "include client time") - return fs - })(), - }, - { - Name: "metrics", - ShortUsage: "tailscale debug metrics", - Exec: runDaemonMetrics, - ShortHelp: "Print tailscaled's metrics", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("metrics") - fs.BoolVar(&metricsArgs.watch, "watch", false, "print JSON dump of delta values") - return fs - })(), - }, - { - Name: "env", - ShortUsage: "tailscale debug env", - Exec: runEnv, - ShortHelp: "Print cmd/tailscale environment", - }, - { - Name: "stat", - ShortUsage: "tailscale debug stat ", - Exec: runStat, - ShortHelp: "Stat a file", - }, - { - Name: "hostinfo", - ShortUsage: "tailscale debug hostinfo", - Exec: runHostinfo, - ShortHelp: "Print hostinfo", - }, - { - Name: "local-creds", - ShortUsage: "tailscale debug local-creds", - Exec: runLocalCreds, - ShortHelp: "Print how to access Tailscale LocalAPI", - }, - { - Name: "restun", - ShortUsage: "tailscale debug restun", - Exec: localAPIAction("restun"), - ShortHelp: "Force a magicsock restun", - }, - { - Name: "rebind", - ShortUsage: "tailscale debug rebind", - Exec: localAPIAction("rebind"), - ShortHelp: "Force a magicsock rebind", - }, - { - Name: "derp-set-on-demand", - ShortUsage: "tailscale debug derp-set-on-demand", - Exec: localAPIAction("derp-set-homeless"), - ShortHelp: "Enable DERP on-demand mode (breaks reachability)", - }, - { - Name: "derp-unset-on-demand", - ShortUsage: "tailscale debug derp-unset-on-demand", - Exec: localAPIAction("derp-unset-homeless"), - ShortHelp: "Disable DERP on-demand mode", - }, - { - Name: "break-tcp-conns", - ShortUsage: "tailscale debug break-tcp-conns", - Exec: localAPIAction("break-tcp-conns"), - ShortHelp: "Break any open TCP connections from the daemon", - }, - { - Name: "break-derp-conns", - ShortUsage: "tailscale debug break-derp-conns", - Exec: localAPIAction("break-derp-conns"), - ShortHelp: "Break any open DERP connections from the daemon", - }, - { - Name: "pick-new-derp", - ShortUsage: "tailscale debug pick-new-derp", - Exec: localAPIAction("pick-new-derp"), - ShortHelp: "Switch to some other random DERP home region for a short time", - }, - { - Name: "force-netmap-update", - ShortUsage: "tailscale debug force-netmap-update", - Exec: localAPIAction("force-netmap-update"), - ShortHelp: "Force a full no-op netmap update (for load testing)", - }, - { - // TODO(bradfitz,maisem): eventually promote this out of debug - Name: "reload-config", - ShortUsage: "tailscale debug reload-config", - Exec: reloadConfig, - ShortHelp: "Reload config", - }, - { - Name: "control-knobs", - ShortUsage: "tailscale debug control-knobs", - Exec: debugControlKnobs, - ShortHelp: "See current control knobs", - }, - { - Name: "prefs", - ShortUsage: "tailscale debug prefs", - Exec: runPrefs, - ShortHelp: "Print prefs", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("prefs") - fs.BoolVar(&prefsArgs.pretty, "pretty", false, "If true, pretty-print output") - return fs - })(), - }, - { - Name: "watch-ipn", - ShortUsage: "tailscale debug watch-ipn", - Exec: runWatchIPN, - ShortHelp: "Subscribe to IPN message bus", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("watch-ipn") - fs.BoolVar(&watchIPNArgs.netmap, "netmap", true, "include netmap in messages") - fs.BoolVar(&watchIPNArgs.initial, "initial", false, "include initial status") - fs.BoolVar(&watchIPNArgs.rateLimit, "rate-limit", true, "rate limit messags") - fs.BoolVar(&watchIPNArgs.showPrivateKey, "show-private-key", false, "include node private key in printed netmap") - fs.IntVar(&watchIPNArgs.count, "count", 0, "exit after printing this many statuses, or 0 to keep going forever") - return fs - })(), - }, - { - Name: "netmap", - ShortUsage: "tailscale debug netmap", - Exec: runNetmap, - ShortHelp: "Print the current network map", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("netmap") - fs.BoolVar(&netmapArgs.showPrivateKey, "show-private-key", false, "include node private key in printed netmap") - return fs - })(), - }, - { - Name: "via", - ShortUsage: "tailscale debug via \n" + - "tailscale debug via ", - Exec: runVia, - ShortHelp: "Convert between site-specific IPv4 CIDRs and IPv6 'via' routes", - }, - { - Name: "ts2021", - ShortUsage: "tailscale debug ts2021", - Exec: runTS2021, - ShortHelp: "Debug ts2021 protocol connectivity", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("ts2021") - fs.StringVar(&ts2021Args.host, "host", "controlplane.tailscale.com", "hostname of control plane") - fs.IntVar(&ts2021Args.version, "version", int(tailcfg.CurrentCapabilityVersion), "protocol version") - fs.BoolVar(&ts2021Args.verbose, "verbose", false, "be extra verbose") - return fs - })(), - }, - { - Name: "set-expire", - ShortUsage: "tailscale debug set-expire --in=1m", - Exec: runSetExpire, - ShortHelp: "Manipulate node key expiry for testing", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("set-expire") - fs.DurationVar(&setExpireArgs.in, "in", 0, "if non-zero, set node key to expire this duration from now") - return fs - })(), - }, - { - Name: "dev-store-set", - ShortUsage: "tailscale debug dev-store-set", - Exec: runDevStoreSet, - ShortHelp: "Set a key/value pair during development", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("store-set") - fs.BoolVar(&devStoreSetArgs.danger, "danger", false, "accept danger") - return fs - })(), - }, - { - Name: "derp", - ShortUsage: "tailscale debug derp", - Exec: runDebugDERP, - ShortHelp: "Test a DERP configuration", - }, - { - Name: "capture", - ShortUsage: "tailscale debug capture", - Exec: runCapture, - ShortHelp: "Streams pcaps for debugging", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("capture") - fs.StringVar(&captureArgs.outFile, "o", "", "path to stream the pcap (or - for stdout), leave empty to start wireshark") - return fs - })(), - }, - { - Name: "portmap", - ShortUsage: "tailscale debug portmap", - Exec: debugPortmap, - ShortHelp: "Run portmap 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.gatewayAddr, "gateway-addr", "", `override gateway IP (must also pass --self-addr)`) - fs.StringVar(&debugPortmapArgs.selfAddr, "self-addr", "", `override self IP (must also pass --gateway-addr)`) - fs.BoolVar(&debugPortmapArgs.logHTTP, "log-http", false, `print all HTTP requests and responses to the log`) - return fs - })(), - }, - { - Name: "peer-endpoint-changes", - ShortUsage: "tailscale debug peer-endpoint-changes ", - Exec: runPeerEndpointChanges, - ShortHelp: "Prints debug information about a peer's endpoint changes", - }, - { - Name: "dial-types", - ShortUsage: "tailscale debug dial-types ", - Exec: runDebugDialTypes, - ShortHelp: "Prints debug information about connecting to a given host or IP", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("dial-types") - fs.StringVar(&debugDialTypesArgs.network, "network", "tcp", `network type to dial ("tcp", "udp", etc.)`) - return fs - })(), - }, - { - Name: "resolve", - ShortUsage: "tailscale debug resolve ", - Exec: runDebugResolve, - ShortHelp: "Does a DNS lookup", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("resolve") - fs.StringVar(&resolveArgs.net, "net", "ip", "network type to resolve (ip, ip4, ip6)") - return fs - })(), - }, - { - Name: "go-buildinfo", - ShortUsage: "tailscale debug go-buildinfo", - ShortHelp: "Prints Go's runtime/debug.BuildInfo", - Exec: runGoBuildInfo, - }, - }, -} - -func runGoBuildInfo(ctx context.Context, args []string) error { - bi, ok := debug.ReadBuildInfo() - if !ok { - return errors.New("no Go build info") - } - e := json.NewEncoder(os.Stdout) - e.SetIndent("", "\t") - return e.Encode(bi) -} - -var debugArgs struct { - file string - cpuSec int - cpuFile string - memFile string -} - -func writeProfile(dst string, v []byte) error { - if dst == "-" { - _, err := Stdout.Write(v) - return err - } - return os.WriteFile(dst, v, 0600) -} - -func outName(dst string) string { - if dst == "-" { - return "stdout" - } - if runtime.GOOS == "darwin" { - return fmt.Sprintf("%s (warning: sandboxed macOS binaries write to Library/Containers; use - to write to stdout and redirect to file instead)", dst) - } - return dst -} - -func runDebug(ctx context.Context, args []string) error { - if len(args) > 0 { - return fmt.Errorf("tailscale debug: unknown subcommand: %s", args[0]) - } - var usedFlag bool - if out := debugArgs.cpuFile; out != "" { - usedFlag = true // TODO(bradfitz): add "pprof" subcommand - log.Printf("Capturing CPU profile for %v seconds ...", debugArgs.cpuSec) - if v, err := localClient.Pprof(ctx, "profile", debugArgs.cpuSec); err != nil { - return err - } else { - if err := writeProfile(out, v); err != nil { - return err - } - log.Printf("CPU profile written to %s", outName(out)) - } - } - if out := debugArgs.memFile; out != "" { - usedFlag = true // TODO(bradfitz): add "pprof" subcommand - log.Printf("Capturing memory profile ...") - if v, err := localClient.Pprof(ctx, "heap", 0); err != nil { - return err - } else { - if err := writeProfile(out, v); err != nil { - return err - } - log.Printf("Memory profile written to %s", outName(out)) - } - } - if debugArgs.file != "" { - usedFlag = true // TODO(bradfitz): add "file" subcommand - if debugArgs.file == "get" { - wfs, err := localClient.WaitingFiles(ctx) - if err != nil { - fatalf("%v\n", err) - } - e := json.NewEncoder(Stdout) - e.SetIndent("", "\t") - e.Encode(wfs) - return nil - } - if name, ok := strings.CutPrefix(debugArgs.file, "delete:"); ok { - return localClient.DeleteWaitingFile(ctx, name) - } - rc, size, err := localClient.GetWaitingFile(ctx, debugArgs.file) - if err != nil { - return err - } - log.Printf("Size: %v\n", size) - io.Copy(Stdout, rc) - return nil - } - if usedFlag { - // TODO(bradfitz): delete this path when all debug flags are migrated - // to subcommands. - return nil - } - return errors.New("tailscale debug: subcommand or flag required") -} - -func runLocalCreds(ctx context.Context, args []string) error { - port, token, err := safesocket.LocalTCPPortAndToken() - if err == nil { - printf("curl -u:%s http://localhost:%d/localapi/v0/status\n", token, port) - return nil - } - if runtime.GOOS == "windows" { - runLocalAPIProxy() - return nil - } - printf("curl --unix-socket %s http://local-tailscaled.sock/localapi/v0/status\n", paths.DefaultTailscaledSocket()) - return nil -} - -type localClientRoundTripper struct{} - -func (localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return localClient.DoLocalRequest(req) -} - -func runLocalAPIProxy() { - rp := httputil.NewSingleHostReverseProxy(&url.URL{ - Scheme: "http", - Host: apitype.LocalAPIHost, - Path: "/", - }) - dir := rp.Director - rp.Director = func(req *http.Request) { - dir(req) - req.Host = "" - req.RequestURI = "" - } - rp.Transport = localClientRoundTripper{} - lc, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatal(err) - } - fmt.Printf("Serving LocalAPI proxy on http://%s\n", lc.Addr()) - fmt.Printf("curl.exe http://%v/localapi/v0/status\n", lc.Addr()) - fmt.Printf("Ctrl+C to stop") - http.Serve(lc, rp) -} - -var prefsArgs struct { - pretty bool -} - -func runPrefs(ctx context.Context, args []string) error { - prefs, err := localClient.GetPrefs(ctx) - if err != nil { - return err - } - if prefsArgs.pretty { - outln(prefs.Pretty()) - } else { - j, _ := json.MarshalIndent(prefs, "", "\t") - outln(string(j)) - } - return nil -} - -var watchIPNArgs struct { - netmap bool - initial bool - showPrivateKey bool - rateLimit bool - count int -} - -func runWatchIPN(ctx context.Context, args []string) error { - var mask ipn.NotifyWatchOpt - if watchIPNArgs.initial { - mask = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap - } - if !watchIPNArgs.showPrivateKey { - mask |= ipn.NotifyNoPrivateKeys - } - if watchIPNArgs.rateLimit { - mask |= ipn.NotifyRateLimit - } - watcher, err := localClient.WatchIPNBus(ctx, mask) - if err != nil { - return err - } - defer watcher.Close() - fmt.Fprintf(Stderr, "Connected.\n") - for seen := 0; watchIPNArgs.count == 0 || seen < watchIPNArgs.count; seen++ { - n, err := watcher.Next() - if err != nil { - return err - } - if !watchIPNArgs.netmap { - n.NetMap = nil - } - j, _ := json.MarshalIndent(n, "", "\t") - fmt.Printf("%s\n", j) - } - return nil -} - -var netmapArgs struct { - showPrivateKey bool -} - -func runNetmap(ctx context.Context, args []string) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - var mask ipn.NotifyWatchOpt = ipn.NotifyInitialNetMap - if !netmapArgs.showPrivateKey { - mask |= ipn.NotifyNoPrivateKeys - } - watcher, err := localClient.WatchIPNBus(ctx, mask) - if err != nil { - return err - } - defer watcher.Close() - - n, err := watcher.Next() - if err != nil { - return err - } - j, _ := json.MarshalIndent(n.NetMap, "", "\t") - fmt.Printf("%s\n", j) - return nil -} - -func runDERPMap(ctx context.Context, args []string) error { - dm, err := localClient.CurrentDERPMap(ctx) - if err != nil { - return fmt.Errorf( - "failed to get local derp map, instead `curl %s/derpmap/default`: %w", ipn.DefaultControlURL, err, - ) - } - enc := json.NewEncoder(Stdout) - enc.SetIndent("", "\t") - enc.Encode(dm) - return nil -} - -func localAPIAction(action string) func(context.Context, []string) error { - return func(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("unexpected arguments") - } - return localClient.DebugAction(ctx, action) - } -} - -func reloadConfig(ctx context.Context, args []string) error { - ok, err := localClient.ReloadConfig(ctx) - if err != nil { - return err - } - if ok { - printf("config reloaded\n") - return nil - } - printf("config mode not in use\n") - os.Exit(1) - panic("unreachable") -} - -func runEnv(ctx context.Context, args []string) error { - for _, e := range os.Environ() { - outln(e) - } - return nil -} - -func runStat(ctx context.Context, args []string) error { - for _, a := range args { - fi, err := os.Lstat(a) - if err != nil { - printf("%s: %v\n", a, err) - continue - } - printf("%s: %v, %v\n", a, fi.Mode(), fi.Size()) - if fi.IsDir() { - ents, _ := os.ReadDir(a) - for i, ent := range ents { - if i == 25 { - printf(" ...\n") - break - } - printf(" - %s\n", ent.Name()) - } - } - } - return nil -} - -func runHostinfo(ctx context.Context, args []string) error { - hi := hostinfo.New() - j, _ := json.MarshalIndent(hi, "", " ") - Stdout.Write(j) - return nil -} - -func runDaemonGoroutines(ctx context.Context, args []string) error { - goroutines, err := localClient.Goroutines(ctx) - if err != nil { - return err - } - Stdout.Write(goroutines) - return nil -} - -var daemonLogsArgs struct { - verbose int - time bool -} - -func runDaemonLogs(ctx context.Context, args []string) error { - logs, err := localClient.TailDaemonLogs(ctx) - if err != nil { - return err - } - d := json.NewDecoder(logs) - for { - var line struct { - Text string `json:"text"` - Verbose int `json:"v"` - Time string `json:"client_time"` - } - err := d.Decode(&line) - if err != nil { - return err - } - line.Text = strings.TrimSpace(line.Text) - if line.Text == "" || line.Verbose > daemonLogsArgs.verbose { - continue - } - if daemonLogsArgs.time { - fmt.Printf("%s %s\n", line.Time, line.Text) - } else { - fmt.Println(line.Text) - } - } -} - -var metricsArgs struct { - watch bool -} - -func runDaemonMetrics(ctx context.Context, args []string) error { - last := map[string]int64{} - for { - out, err := localClient.DaemonMetrics(ctx) - if err != nil { - return err - } - if !metricsArgs.watch { - Stdout.Write(out) - return nil - } - bs := bufio.NewScanner(bytes.NewReader(out)) - type change struct { - name string - from, to int64 - } - var changes []change - var maxNameLen int - for bs.Scan() { - line := bytes.TrimSpace(bs.Bytes()) - if len(line) == 0 || line[0] == '#' { - continue - } - f := strings.Fields(string(line)) - if len(f) != 2 { - continue - } - name := f[0] - n, _ := strconv.ParseInt(f[1], 10, 64) - prev, ok := last[name] - if ok && prev == n { - continue - } - last[name] = n - if !ok { - continue - } - changes = append(changes, change{name, prev, n}) - if len(name) > maxNameLen { - maxNameLen = len(name) - } - } - if len(changes) > 0 { - format := fmt.Sprintf("%%-%ds %%+5d => %%v\n", maxNameLen) - for _, c := range changes { - fmt.Fprintf(Stdout, format, c.name, c.to-c.from, c.to) - } - io.WriteString(Stdout, "\n") - } - time.Sleep(time.Second) - } -} - -func runVia(ctx context.Context, args []string) error { - switch len(args) { - default: - return errors.New("expect either or ") - case 1: - ipp, err := netip.ParsePrefix(args[0]) - if err != nil { - return err - } - if !ipp.Addr().Is6() { - return errors.New("with one argument, expect an IPv6 CIDR") - } - if !tsaddr.TailscaleViaRange().Contains(ipp.Addr()) { - return errors.New("not a via route") - } - if ipp.Bits() < 96 { - return errors.New("short length, want /96 or more") - } - v4 := tsaddr.UnmapVia(ipp.Addr()) - a := ipp.Addr().As16() - siteID := binary.BigEndian.Uint32(a[8:12]) - printf("site %v (0x%x), %v\n", siteID, siteID, netip.PrefixFrom(v4, ipp.Bits()-96)) - case 2: - siteID, err := strconv.ParseUint(args[0], 0, 32) - if err != nil { - return fmt.Errorf("invalid site-id %q; must be decimal or hex with 0x prefix", args[0]) - } - if siteID > 0xffff { - return fmt.Errorf("site-id values over 65535 are currently reserved") - } - ipp, err := netip.ParsePrefix(args[1]) - if err != nil { - return err - } - via, err := tsaddr.MapVia(uint32(siteID), ipp) - if err != nil { - return err - } - outln(via) - } - return nil -} - -var ts2021Args struct { - host string // "controlplane.tailscale.com" - version int // 27 or whatever - verbose bool -} - -func runTS2021(ctx context.Context, args []string) error { - log.SetOutput(Stdout) - log.SetFlags(log.Ltime | log.Lmicroseconds) - - keysURL := "https://" + ts2021Args.host + "/key?v=" + strconv.Itoa(ts2021Args.version) - - if ts2021Args.verbose { - u, err := url.Parse(keysURL) - if err != nil { - return err - } - envConf := httpproxy.FromEnvironment() - if *envConf == (httpproxy.Config{}) { - log.Printf("HTTP proxy env: (none)") - } else { - log.Printf("HTTP proxy env: %+v", envConf) - } - proxy, err := tshttpproxy.ProxyFromEnvironment(&http.Request{URL: u}) - log.Printf("tshttpproxy.ProxyFromEnvironment = (%v, %v)", proxy, err) - } - machinePrivate := key.NewMachine() - var dialer net.Dialer - - var keys struct { - PublicKey key.MachinePublic - } - log.Printf("Fetching keys from %s ...", keysURL) - req, err := http.NewRequestWithContext(ctx, "GET", keysURL, nil) - if err != nil { - return err - } - res, err := http.DefaultClient.Do(req) - if err != nil { - log.Printf("Do: %v", err) - return err - } - if res.StatusCode != 200 { - log.Printf("Status: %v", res.Status) - return errors.New(res.Status) - } - if err := json.NewDecoder(res.Body).Decode(&keys); err != nil { - log.Printf("JSON: %v", err) - return fmt.Errorf("decoding /keys JSON: %w", err) - } - res.Body.Close() - if ts2021Args.verbose { - log.Printf("got public key: %v", keys.PublicKey) - } - - dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { - log.Printf("Dial(%q, %q) ...", network, address) - c, err := dialer.DialContext(ctx, network, address) - if err != nil { - // skip logging context cancellation errors - if !errors.Is(err, context.Canceled) { - log.Printf("Dial(%q, %q) = %v", network, address, err) - } - } else { - log.Printf("Dial(%q, %q) = %v / %v", network, address, c.LocalAddr(), c.RemoteAddr()) - } - return c, err - } - var logf logger.Logf - if ts2021Args.verbose { - logf = log.Printf - } - - netMon, err := netmon.New(logger.WithPrefix(logf, "netmon: ")) - if err != nil { - return fmt.Errorf("creating netmon: %w", err) - } - - noiseDialer := &controlhttp.Dialer{ - Hostname: ts2021Args.host, - HTTPPort: "80", - HTTPSPort: "443", - MachineKey: machinePrivate, - ControlKey: keys.PublicKey, - ProtocolVersion: uint16(ts2021Args.version), - Dialer: dialFunc, - Logf: logf, - NetMon: netMon, - } - const tries = 2 - for i := range tries { - err := tryConnect(ctx, keys.PublicKey, noiseDialer) - if err != nil { - log.Printf("error on attempt %d/%d: %v", i+1, tries, err) - continue - } - break - } - return nil -} - -func tryConnect(ctx context.Context, controlPublic key.MachinePublic, noiseDialer *controlhttp.Dialer) error { - conn, err := noiseDialer.Dial(ctx) - log.Printf("controlhttp.Dial = %p, %v", conn, err) - if err != nil { - return err - } - log.Printf("did noise handshake") - - gotPeer := conn.Peer() - if gotPeer != controlPublic { - log.Printf("peer = %v, want %v", gotPeer, controlPublic) - return errors.New("key mismatch") - } - - log.Printf("final underlying conn: %v / %v", conn.LocalAddr(), conn.RemoteAddr()) - - h2Transport, err := http2.ConfigureTransports(&http.Transport{ - IdleConnTimeout: time.Second, - }) - if err != nil { - return fmt.Errorf("http2.ConfigureTransports: %w", err) - } - - // Now, create a Noise conn over the existing conn. - nc, err := noiseconn.New(conn.Conn, h2Transport, 0, nil) - if err != nil { - return fmt.Errorf("noiseconn.New: %w", err) - } - defer nc.Close() - - // Reserve a RoundTrip for the whoami request. - ok, _, err := nc.ReserveNewRequest(ctx) - if err != nil { - return fmt.Errorf("ReserveNewRequest: %w", err) - } - if !ok { - return errors.New("ReserveNewRequest failed") - } - - // Make a /whoami request to the server to verify that we can actually - // communicate over the newly-established connection. - whoamiURL := "http://" + ts2021Args.host + "/machine/whoami" - req, err := http.NewRequestWithContext(ctx, "GET", whoamiURL, nil) - if err != nil { - return err - } - resp, err := nc.RoundTrip(req) - if err != nil { - return fmt.Errorf("RoundTrip whoami request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - log.Printf("whoami request returned status %v", resp.Status) - } else { - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("reading whoami response: %w", err) - } - log.Printf("whoami response: %q", body) - } - return nil -} - -var debugComponentLogsArgs struct { - forDur time.Duration -} - -func runDebugComponentLogs(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("usage: tailscale debug component-logs [" + strings.Join(ipn.DebuggableComponents, "|") + "]") - } - component := args[0] - dur := debugComponentLogsArgs.forDur - - err := localClient.SetComponentDebugLogging(ctx, component, dur) - if err != nil { - return err - } - if debugComponentLogsArgs.forDur <= 0 { - fmt.Printf("Disabled debug logs for component %q\n", component) - } else { - fmt.Printf("Enabled debug logs for component %q for %v\n", component, dur) - } - return nil -} - -var devStoreSetArgs struct { - danger bool -} - -func runDevStoreSet(ctx context.Context, args []string) error { - if len(args) != 2 { - return errors.New("usage: tailscale debug dev-store-set --danger ") - } - if !devStoreSetArgs.danger { - return errors.New("this command is dangerous; use --danger to proceed") - } - key, val := args[0], args[1] - if val == "-" { - valb, err := io.ReadAll(os.Stdin) - if err != nil { - return err - } - val = string(valb) - } - return localClient.SetDevStoreKeyValue(ctx, key, val) -} - -func runDebugDERP(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("usage: tailscale debug derp ") - } - st, err := localClient.DebugDERPRegion(ctx, args[0]) - if err != nil { - return err - } - fmt.Printf("%s\n", must.Get(json.MarshalIndent(st, "", " "))) - return nil -} - -var setExpireArgs struct { - in time.Duration -} - -func runSetExpire(ctx context.Context, args []string) error { - if len(args) != 0 || setExpireArgs.in == 0 { - return errors.New("usage: tailscale debug set-expire --in=") - } - return localClient.DebugSetExpireIn(ctx, setExpireArgs.in) -} - -var captureArgs struct { - outFile string -} - -func runCapture(ctx context.Context, args []string) error { - stream, err := localClient.StreamDebugCapture(ctx) - if err != nil { - return err - } - defer stream.Close() - - switch captureArgs.outFile { - case "-": - fmt.Fprintln(Stderr, "Press Ctrl-C to stop the capture.") - _, err = io.Copy(os.Stdout, stream) - return err - case "": - lua, err := os.CreateTemp("", "ts-dissector") - if err != nil { - return err - } - defer os.Remove(lua.Name()) - lua.Write([]byte(capture.DissectorLua)) - if err := lua.Close(); err != nil { - return err - } - - wireshark := exec.CommandContext(ctx, "wireshark", "-X", "lua_script:"+lua.Name(), "-k", "-i", "-") - wireshark.Stdin = stream - wireshark.Stdout = os.Stdout - wireshark.Stderr = os.Stderr - return wireshark.Run() - } - - f, err := os.OpenFile(captureArgs.outFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - return err - } - defer f.Close() - fmt.Fprintln(Stderr, "Press Ctrl-C to stop the capture.") - _, err = io.Copy(f, stream) - return err -} - -var debugPortmapArgs struct { - duration time.Duration - gatewayAddr string - selfAddr string - ty string - logHTTP bool -} - -func debugPortmap(ctx context.Context, args []string) error { - opts := &tailscale.DebugPortmapOpts{ - Duration: debugPortmapArgs.duration, - Type: debugPortmapArgs.ty, - LogHTTP: debugPortmapArgs.logHTTP, - } - if (debugPortmapArgs.gatewayAddr != "") != (debugPortmapArgs.selfAddr != "") { - return fmt.Errorf("if one of --gateway-addr and --self-addr is provided, the other must be as well") - } - if debugPortmapArgs.gatewayAddr != "" { - var err error - opts.GatewayAddr, err = netip.ParseAddr(debugPortmapArgs.gatewayAddr) - if err != nil { - return fmt.Errorf("invalid --gateway-addr: %w", err) - } - opts.SelfAddr, err = netip.ParseAddr(debugPortmapArgs.selfAddr) - if err != nil { - return fmt.Errorf("invalid --self-addr: %w", err) - } - } - rc, err := localClient.DebugPortmap(ctx, opts) - 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: tailscale debug peer-endpoint-changes ") - } - 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 -} - -func debugControlKnobs(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("unexpected arguments") - } - v, err := localClient.DebugResultJSON(ctx, "control-knobs") - if err != nil { - return err - } - e := json.NewEncoder(os.Stdout) - e.SetIndent("", " ") - e.Encode(v) - return nil -} - -var debugDialTypesArgs struct { - network string -} - -func runDebugDialTypes(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) != 2 || args[0] == "" || args[1] == "" { - return errors.New("usage: tailscale debug dial-types ") - } - - port, err := strconv.ParseUint(args[1], 10, 16) - if err != nil { - return fmt.Errorf("invalid port %q: %w", args[1], err) - } - - hostOrIP := args[0] - ip, _, err := tailscaleIPFromArg(ctx, hostOrIP) - if err != nil { - return err - } - if ip != hostOrIP { - log.Printf("lookup %q => %q", hostOrIP, ip) - } - - qparams := make(url.Values) - qparams.Set("ip", ip) - qparams.Set("port", strconv.FormatUint(port, 10)) - qparams.Set("network", debugDialTypesArgs.network) - - req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/debug-dial-types?"+qparams.Encode(), 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 - } - - fmt.Printf("%s", body) - return nil -} - -var resolveArgs struct { - net string // "ip", "ip4", "ip6"" -} - -func runDebugResolve(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("usage: tailscale debug resolve ") - } - - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - host := args[0] - ips, err := net.DefaultResolver.LookupIP(ctx, resolveArgs.net, host) - if err != nil { - return err - } - for _, ip := range ips { - fmt.Printf("%s\n", ip) - } - return nil -} diff --git a/cmd/tailscale/cli/diag.go b/cmd/tailscale/cli/diag.go deleted file mode 100644 index ebf26985fe0bd..0000000000000 --- a/cmd/tailscale/cli/diag.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || windows || darwin - -package cli - -import ( - "fmt" - "os/exec" - "path/filepath" - "runtime" - "strings" - - ps "github.com/mitchellh/go-ps" - "tailscale.com/version/distro" -) - -// fixTailscaledConnectError is called when the local tailscaled has -// been determined unreachable due to the provided origErr value. It -// returns either the same error or a better one to help the user -// understand why tailscaled isn't running for their platform. -func fixTailscaledConnectError(origErr error) error { - procs, err := ps.Processes() - if err != nil { - return fmt.Errorf("failed to connect to local Tailscaled process and failed to enumerate processes while looking for it") - } - var foundProc ps.Process - for _, proc := range procs { - base := filepath.Base(proc.Executable()) - if base == "tailscaled" { - foundProc = proc - break - } - if runtime.GOOS == "darwin" && base == "IPNExtension" { - foundProc = proc - break - } - if runtime.GOOS == "windows" && strings.EqualFold(base, "tailscaled.exe") { - foundProc = proc - break - } - } - if foundProc == nil { - switch runtime.GOOS { - case "windows": - return fmt.Errorf("failed to connect to local tailscaled process; is the Tailscale service running?") - case "darwin": - return fmt.Errorf("failed to connect to local Tailscale service; is Tailscale running?") - case "linux": - var hint string - if isSystemdSystem() { - hint = " (sudo systemctl start tailscaled ?)" - } - return fmt.Errorf("failed to connect to local tailscaled; it doesn't appear to be running%s", hint) - } - return fmt.Errorf("failed to connect to local tailscaled process; it doesn't appear to be running") - } - return fmt.Errorf("failed to connect to local tailscaled (which appears to be running as %v, pid %v). Got error: %w", foundProc.Executable(), foundProc.Pid(), origErr) -} - -// isSystemdSystem reports whether the current machine uses systemd -// and in particular whether the systemctl command is available. -func isSystemdSystem() bool { - if runtime.GOOS != "linux" { - return false - } - switch distro.Get() { - case distro.QNAP, distro.Gokrazy, distro.Synology, distro.Unraid: - return false - } - _, err := exec.LookPath("systemctl") - return err == nil -} diff --git a/cmd/tailscale/cli/diag_other.go b/cmd/tailscale/cli/diag_other.go deleted file mode 100644 index ece10cc79a822..0000000000000 --- a/cmd/tailscale/cli/diag_other.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !linux && !windows && !darwin - -package cli - -import "fmt" - -// The github.com/mitchellh/go-ps package doesn't work on all platforms, -// so just don't diagnose connect failures. - -func fixTailscaledConnectError(origErr error) error { - return fmt.Errorf("failed to connect to local tailscaled process (is it running?); got: %w", origErr) -} diff --git a/cmd/tailscale/cli/dns-query.go b/cmd/tailscale/cli/dns-query.go deleted file mode 100644 index da2d9d2a56d77..0000000000000 --- a/cmd/tailscale/cli/dns-query.go +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "flag" - "fmt" - "net/netip" - "os" - "text/tabwriter" - - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/types/dnstype" -) - -func runDNSQuery(ctx context.Context, args []string) error { - if len(args) < 1 { - return flag.ErrHelp - } - name := args[0] - queryType := "A" - if len(args) >= 2 { - queryType = args[1] - } - fmt.Printf("DNS query for %q (%s) using internal resolver:\n", name, queryType) - fmt.Println() - bytes, resolvers, err := localClient.QueryDNS(ctx, name, queryType) - if err != nil { - fmt.Printf("failed to query DNS: %v\n", err) - return nil - } - - if len(resolvers) == 1 { - fmt.Printf("Forwarding to resolver: %v\n", makeResolverString(*resolvers[0])) - } else { - fmt.Println("Multiple resolvers available:") - for _, r := range resolvers { - fmt.Printf(" - %v\n", makeResolverString(*r)) - } - } - fmt.Println() - var p dnsmessage.Parser - header, err := p.Start(bytes) - if err != nil { - fmt.Printf("failed to parse DNS response: %v\n", err) - return err - } - fmt.Printf("Response code: %v\n", header.RCode.String()) - fmt.Println() - p.SkipAllQuestions() - if header.RCode != dnsmessage.RCodeSuccess { - fmt.Println("No answers were returned.") - return nil - } - answers, err := p.AllAnswers() - if err != nil { - fmt.Printf("failed to parse DNS answers: %v\n", err) - return err - } - if len(answers) == 0 { - fmt.Println(" (no answers found)") - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "Name\tTTL\tClass\tType\tBody") - fmt.Fprintln(w, "----\t---\t-----\t----\t----") - for _, a := range answers { - fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", a.Header.Name.String(), a.Header.TTL, a.Header.Class.String(), a.Header.Type.String(), makeAnswerBody(a)) - } - w.Flush() - - fmt.Println() - return nil -} - -// makeAnswerBody returns a string with the DNS answer body in a human-readable format. -func makeAnswerBody(a dnsmessage.Resource) string { - switch a.Header.Type { - case dnsmessage.TypeA: - return makeABody(a.Body) - case dnsmessage.TypeAAAA: - return makeAAAABody(a.Body) - case dnsmessage.TypeCNAME: - return makeCNAMEBody(a.Body) - case dnsmessage.TypeMX: - return makeMXBody(a.Body) - case dnsmessage.TypeNS: - return makeNSBody(a.Body) - case dnsmessage.TypeOPT: - return makeOPTBody(a.Body) - case dnsmessage.TypePTR: - return makePTRBody(a.Body) - case dnsmessage.TypeSRV: - return makeSRVBody(a.Body) - case dnsmessage.TypeTXT: - return makeTXTBody(a.Body) - default: - return a.Body.GoString() - } -} - -func makeABody(a dnsmessage.ResourceBody) string { - if a, ok := a.(*dnsmessage.AResource); ok { - return netip.AddrFrom4(a.A).String() - } - return "" -} -func makeAAAABody(aaaa dnsmessage.ResourceBody) string { - if a, ok := aaaa.(*dnsmessage.AAAAResource); ok { - return netip.AddrFrom16(a.AAAA).String() - } - return "" -} -func makeCNAMEBody(cname dnsmessage.ResourceBody) string { - if c, ok := cname.(*dnsmessage.CNAMEResource); ok { - return c.CNAME.String() - } - return "" -} -func makeMXBody(mx dnsmessage.ResourceBody) string { - if m, ok := mx.(*dnsmessage.MXResource); ok { - return fmt.Sprintf("%s (Priority=%d)", m.MX, m.Pref) - } - return "" -} -func makeNSBody(ns dnsmessage.ResourceBody) string { - if n, ok := ns.(*dnsmessage.NSResource); ok { - return n.NS.String() - } - return "" -} -func makeOPTBody(opt dnsmessage.ResourceBody) string { - if o, ok := opt.(*dnsmessage.OPTResource); ok { - return o.GoString() - } - return "" -} -func makePTRBody(ptr dnsmessage.ResourceBody) string { - if p, ok := ptr.(*dnsmessage.PTRResource); ok { - return p.PTR.String() - } - return "" -} -func makeSRVBody(srv dnsmessage.ResourceBody) string { - if s, ok := srv.(*dnsmessage.SRVResource); ok { - return fmt.Sprintf("Target=%s, Port=%d, Priority=%d, Weight=%d", s.Target.String(), s.Port, s.Priority, s.Weight) - } - return "" -} -func makeTXTBody(txt dnsmessage.ResourceBody) string { - if t, ok := txt.(*dnsmessage.TXTResource); ok { - return fmt.Sprintf("%q", t.TXT) - } - return "" -} -func makeResolverString(r dnstype.Resolver) string { - if len(r.BootstrapResolution) > 0 { - return fmt.Sprintf("%s (bootstrap: %v)", r.Addr, r.BootstrapResolution) - } - return fmt.Sprintf("%s", r.Addr) -} diff --git a/cmd/tailscale/cli/dns-status.go b/cmd/tailscale/cli/dns-status.go deleted file mode 100644 index e487c66bc331c..0000000000000 --- a/cmd/tailscale/cli/dns-status.go +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "fmt" - "maps" - "slices" - "strings" - - "tailscale.com/ipn" - "tailscale.com/types/netmap" -) - -// dnsStatusArgs are the arguments for the "dns status" subcommand. -var dnsStatusArgs struct { - all bool -} - -func runDNSStatus(ctx context.Context, args []string) error { - all := dnsStatusArgs.all - s, err := localClient.Status(ctx) - if err != nil { - return err - } - - prefs, err := localClient.GetPrefs(ctx) - if err != nil { - return err - } - enabledStr := "disabled.\n\n(Run 'tailscale set --accept-dns=true' to start sending DNS queries to the Tailscale DNS resolver)" - if prefs.CorpDNS { - enabledStr = "enabled.\n\nTailscale is configured to handle DNS queries on this device.\nRun 'tailscale set --accept-dns=false' to revert to your system default DNS resolver." - } - fmt.Print("\n") - fmt.Println("=== 'Use Tailscale DNS' status ===") - fmt.Print("\n") - fmt.Printf("Tailscale DNS: %s\n", enabledStr) - fmt.Print("\n") - fmt.Println("=== MagicDNS configuration ===") - fmt.Print("\n") - fmt.Println("This is the DNS configuration provided by the coordination server to this device.") - fmt.Print("\n") - if s.CurrentTailnet == nil { - fmt.Println("No tailnet information available; make sure you're logged in to a tailnet.") - return nil - } else if s.CurrentTailnet.MagicDNSEnabled { - fmt.Printf("MagicDNS: enabled tailnet-wide (suffix = %s)", s.CurrentTailnet.MagicDNSSuffix) - fmt.Print("\n\n") - fmt.Printf("Other devices in your tailnet can reach this device at %s\n", s.Self.DNSName) - } else { - fmt.Printf("MagicDNS: disabled tailnet-wide.\n") - } - fmt.Print("\n") - - netMap, err := fetchNetMap() - if err != nil { - fmt.Printf("Failed to fetch network map: %v\n", err) - return err - } - dnsConfig := netMap.DNS - fmt.Println("Resolvers (in preference order):") - if len(dnsConfig.Resolvers) == 0 { - fmt.Println(" (no resolvers configured, system default will be used: see 'System DNS configuration' below)") - } - for _, r := range dnsConfig.Resolvers { - fmt.Printf(" - %v", r.Addr) - if r.BootstrapResolution != nil { - fmt.Printf(" (bootstrap: %v)", r.BootstrapResolution) - } - fmt.Print("\n") - } - fmt.Print("\n") - fmt.Println("Split DNS Routes:") - if len(dnsConfig.Routes) == 0 { - fmt.Println(" (no routes configured: split DNS disabled)") - } - for _, k := range slices.Sorted(maps.Keys(dnsConfig.Routes)) { - v := dnsConfig.Routes[k] - for _, r := range v { - fmt.Printf(" - %-30s -> %v", k, r.Addr) - if r.BootstrapResolution != nil { - fmt.Printf(" (bootstrap: %v)", r.BootstrapResolution) - } - fmt.Print("\n") - } - } - fmt.Print("\n") - if all { - fmt.Println("Fallback Resolvers:") - if len(dnsConfig.FallbackResolvers) == 0 { - fmt.Println(" (no fallback resolvers configured)") - } - for i, r := range dnsConfig.FallbackResolvers { - fmt.Printf(" %d: %v\n", i, r) - } - fmt.Print("\n") - } - fmt.Println("Search Domains:") - if len(dnsConfig.Domains) == 0 { - fmt.Println(" (no search domains configured)") - } - domains := dnsConfig.Domains - slices.Sort(domains) - for _, r := range domains { - fmt.Printf(" - %v\n", r) - } - fmt.Print("\n") - if all { - fmt.Println("Nameservers IP Addresses:") - if len(dnsConfig.Nameservers) == 0 { - fmt.Println(" (none were provided)") - } - for _, r := range dnsConfig.Nameservers { - fmt.Printf(" - %v\n", r) - } - fmt.Print("\n") - fmt.Println("Certificate Domains:") - if len(dnsConfig.CertDomains) == 0 { - fmt.Println(" (no certificate domains are configured)") - } - for _, r := range dnsConfig.CertDomains { - fmt.Printf(" - %v\n", r) - } - fmt.Print("\n") - fmt.Println("Additional DNS Records:") - if len(dnsConfig.ExtraRecords) == 0 { - fmt.Println(" (no extra records are configured)") - } - for _, er := range dnsConfig.ExtraRecords { - if er.Type == "" { - fmt.Printf(" - %-50s -> %v\n", er.Name, er.Value) - } else { - fmt.Printf(" - [%s] %-50s -> %v\n", er.Type, er.Name, er.Value) - } - } - fmt.Print("\n") - fmt.Println("Filtered suffixes when forwarding DNS queries as an exit node:") - if len(dnsConfig.ExitNodeFilteredSet) == 0 { - fmt.Println(" (no suffixes are filtered)") - } - for _, s := range dnsConfig.ExitNodeFilteredSet { - fmt.Printf(" - %s\n", s) - } - fmt.Print("\n") - } - - fmt.Println("=== System DNS configuration ===") - fmt.Print("\n") - fmt.Println("This is the DNS configuration that Tailscale believes your operating system is using.\nTailscale may use this configuration if 'Override Local DNS' is disabled in the admin console,\nor if no resolvers are provided by the coordination server.") - fmt.Print("\n") - osCfg, err := localClient.GetDNSOSConfig(ctx) - if err != nil { - if strings.Contains(err.Error(), "not supported") { - // avoids showing the HTTP error code which would be odd here - fmt.Println(" (reading the system DNS configuration is not supported on this platform)") - } else { - fmt.Printf(" (failed to read system DNS configuration: %v)\n", err) - } - } else if osCfg == nil { - fmt.Println(" (no OS DNS configuration available)") - } else { - fmt.Println("Nameservers:") - if len(osCfg.Nameservers) == 0 { - fmt.Println(" (no nameservers found, DNS queries might fail\nunless the coordination server is providing a nameserver)") - } - for _, ns := range osCfg.Nameservers { - fmt.Printf(" - %v\n", ns) - } - fmt.Print("\n") - fmt.Println("Search domains:") - if len(osCfg.SearchDomains) == 0 { - fmt.Println(" (no search domains found)") - } - for _, sd := range osCfg.SearchDomains { - fmt.Printf(" - %v\n", sd) - } - if all { - fmt.Print("\n") - fmt.Println("Match domains:") - if len(osCfg.MatchDomains) == 0 { - fmt.Println(" (no match domains found)") - } - for _, md := range osCfg.MatchDomains { - fmt.Printf(" - %v\n", md) - } - } - } - fmt.Print("\n") - fmt.Println("[this is a preliminary version of this command; the output format may change in the future]") - return nil -} - -func fetchNetMap() (netMap *netmap.NetworkMap, err error) { - w, err := localClient.WatchIPNBus(context.Background(), ipn.NotifyInitialNetMap) - if err != nil { - return nil, err - } - defer w.Close() - notify, err := w.Next() - if err != nil { - return nil, err - } - if notify.NetMap == nil { - return nil, fmt.Errorf("no network map yet available, please try again later") - } - return notify.NetMap, nil -} - -func dnsStatusLongHelp() string { - return `The 'tailscale dns status' subcommand prints the current DNS status and configuration, including: - -- Whether the built-in DNS forwarder is enabled. -- The MagicDNS configuration provided by the coordination server. -- Details on which resolver(s) Tailscale believes the system is using by default. - -The --all flag can be used to output advanced debugging information, including fallback resolvers, nameservers, certificate domains, extra records, and the exit node filtered set. - -=== Contents of the MagicDNS configuration === - -The MagicDNS configuration is provided by the coordination server to the client and includes the following components: - -- MagicDNS enablement status: Indicates whether MagicDNS is enabled across the entire tailnet. - -- MagicDNS Suffix: The DNS suffix used for devices within your tailnet. - -- DNS Name: The DNS name that other devices in the tailnet can use to reach this device. - -- Resolvers: The preferred DNS resolver(s) to be used for resolving queries, in order of preference. If no resolvers are listed here, the system defaults are used. - -- Split DNS Routes: Custom DNS resolvers may be used to resolve hostnames in specific domains, this is also known as a 'Split DNS' configuration. The mapping of domains to their respective resolvers is provided here. - -- Certificate Domains: The DNS names for which the coordination server will assist in provisioning TLS certificates. - -- Extra Records: Additional DNS records that the coordination server might provide to the internal DNS resolver. - -- Exit Node Filtered Set: DNS suffixes that the node, when acting as an exit node DNS proxy, will not answer. - -For more information about the DNS functionality built into Tailscale, refer to https://tailscale.com/kb/1054/dns.` -} diff --git a/cmd/tailscale/cli/dns.go b/cmd/tailscale/cli/dns.go deleted file mode 100644 index 042ce1a94161a..0000000000000 --- a/cmd/tailscale/cli/dns.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "flag" - - "github.com/peterbourgon/ff/v3/ffcli" -) - -var dnsCmd = &ffcli.Command{ - Name: "dns", - ShortHelp: "Diagnose the internal DNS forwarder", - LongHelp: dnsCmdLongHelp(), - ShortUsage: "tailscale dns [flags]", - UsageFunc: usageFuncNoDefaultValues, - Subcommands: []*ffcli.Command{ - { - Name: "status", - ShortUsage: "tailscale dns status [--all]", - Exec: runDNSStatus, - ShortHelp: "Prints the current DNS status and configuration", - LongHelp: dnsStatusLongHelp(), - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("status") - fs.BoolVar(&dnsStatusArgs.all, "all", false, "outputs advanced debugging information (fallback resolvers, nameservers, cert domains, extra records, and exit node filtered set)") - return fs - })(), - }, - { - Name: "query", - ShortUsage: "tailscale dns query [a|aaaa|cname|mx|ns|opt|ptr|srv|txt]", - Exec: runDNSQuery, - ShortHelp: "Perform a DNS query", - LongHelp: "The 'tailscale dns query' subcommand performs a DNS query for the specified name using the internal DNS forwarder (100.100.100.100).\n\nIt also provides information about the resolver(s) used to resolve the query.", - }, - - // TODO: implement `tailscale log` here - - // The above work is tracked in https://github.com/tailscale/tailscale/issues/13326 - }, -} - -func dnsCmdLongHelp() string { - return `The 'tailscale dns' subcommand provides tools for diagnosing the internal DNS forwarder (100.100.100.100). - -For more information about the DNS functionality built into Tailscale, refer to https://tailscale.com/kb/1054/dns.` -} diff --git a/cmd/tailscale/cli/down.go b/cmd/tailscale/cli/down.go deleted file mode 100644 index 1eb85a13e6c78..0000000000000 --- a/cmd/tailscale/cli/down.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "flag" - "fmt" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/ipn" -) - -var downCmd = &ffcli.Command{ - Name: "down", - ShortUsage: "tailscale down", - ShortHelp: "Disconnect from Tailscale", - - Exec: runDown, - FlagSet: newDownFlagSet(), -} - -var downArgs struct { - acceptedRisks string -} - -func newDownFlagSet() *flag.FlagSet { - downf := newFlagSet("down") - registerAcceptRiskFlag(downf, &downArgs.acceptedRisks) - return downf -} - -func runDown(ctx context.Context, args []string) error { - if len(args) > 0 { - return fmt.Errorf("too many non-flag arguments: %q", args) - } - - if isSSHOverTailscale() { - if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will disable Tailscale and result in your session disconnecting.`, downArgs.acceptedRisks); err != nil { - return err - } - } - - st, err := localClient.Status(ctx) - if err != nil { - return fmt.Errorf("error fetching current status: %w", err) - } - if st.BackendState == "Stopped" { - fmt.Fprintf(Stderr, "Tailscale was already stopped.\n") - return nil - } - _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - WantRunning: false, - }, - WantRunningSet: true, - }) - return err -} diff --git a/cmd/tailscale/cli/drive.go b/cmd/tailscale/cli/drive.go deleted file mode 100644 index 929852b4c5a32..0000000000000 --- a/cmd/tailscale/cli/drive.go +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "fmt" - "path/filepath" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/drive" -) - -const ( - driveShareUsage = "tailscale drive share " - driveRenameUsage = "tailscale drive rename " - driveUnshareUsage = "tailscale drive unshare " - driveListUsage = "tailscale drive list" -) - -var driveCmd = &ffcli.Command{ - Name: "drive", - ShortHelp: "Share a directory with your tailnet", - ShortUsage: strings.Join([]string{ - driveShareUsage, - driveRenameUsage, - driveUnshareUsage, - driveListUsage, - }, "\n"), - LongHelp: buildShareLongHelp(), - UsageFunc: usageFuncNoDefaultValues, - Subcommands: []*ffcli.Command{ - { - Name: "share", - ShortUsage: driveShareUsage, - Exec: runDriveShare, - ShortHelp: "[ALPHA] Create or modify a share", - }, - { - Name: "rename", - ShortUsage: driveRenameUsage, - ShortHelp: "[ALPHA] Rename a share", - Exec: runDriveRename, - }, - { - Name: "unshare", - ShortUsage: driveUnshareUsage, - ShortHelp: "[ALPHA] Remove a share", - Exec: runDriveUnshare, - }, - { - Name: "list", - ShortUsage: driveListUsage, - ShortHelp: "[ALPHA] List current shares", - Exec: runDriveList, - }, - }, -} - -// runDriveShare is the entry point for the "tailscale drive share" command. -func runDriveShare(ctx context.Context, args []string) error { - if len(args) != 2 { - return fmt.Errorf("usage: %s", driveShareUsage) - } - - name, path := args[0], args[1] - - absolutePath, err := filepath.Abs(path) - if err != nil { - return err - } - - err = localClient.DriveShareSet(ctx, &drive.Share{ - Name: name, - Path: absolutePath, - }) - if err == nil { - fmt.Printf("Sharing %q as %q\n", path, name) - } - return err -} - -// runDriveUnshare is the entry point for the "tailscale drive unshare" command. -func runDriveUnshare(ctx context.Context, args []string) error { - if len(args) != 1 { - return fmt.Errorf("usage: %s", driveUnshareUsage) - } - name := args[0] - - err := localClient.DriveShareRemove(ctx, name) - if err == nil { - fmt.Printf("No longer sharing %q\n", name) - } - return err -} - -// runDriveRename is the entry point for the "tailscale drive rename" command. -func runDriveRename(ctx context.Context, args []string) error { - if len(args) != 2 { - return fmt.Errorf("usage: %s", driveRenameUsage) - } - oldName := args[0] - newName := args[1] - - err := localClient.DriveShareRename(ctx, oldName, newName) - if err == nil { - fmt.Printf("Renamed share %q to %q\n", oldName, newName) - } - return err -} - -// runDriveList is the entry point for the "tailscale drive list" command. -func runDriveList(ctx context.Context, args []string) error { - if len(args) != 0 { - return fmt.Errorf("usage: %s", driveListUsage) - } - - shares, err := localClient.DriveShareList(ctx) - if err != nil { - return err - } - - longestName := 4 // "name" - longestPath := 4 // "path" - longestAs := 2 // "as" - for _, share := range shares { - if len(share.Name) > longestName { - longestName = len(share.Name) - } - if len(share.Path) > longestPath { - longestPath = len(share.Path) - } - if len(share.As) > longestAs { - longestAs = len(share.As) - } - } - formatString := fmt.Sprintf("%%-%ds %%-%ds %%s\n", longestName, longestPath) - fmt.Printf(formatString, "name", "path", "as") - fmt.Printf(formatString, strings.Repeat("-", longestName), strings.Repeat("-", longestPath), strings.Repeat("-", longestAs)) - for _, share := range shares { - fmt.Printf(formatString, share.Name, share.Path, share.As) - } - - return nil -} - -func buildShareLongHelp() string { - longHelpAs := "" - if drive.AllowShareAs() { - longHelpAs = shareLongHelpAs - } - return fmt.Sprintf(shareLongHelpBase, longHelpAs) -} - -var shareLongHelpBase = `Taildrive allows you to share directories with other machines on your tailnet. - -In order to share folders, your node needs to have the node attribute "drive:share". - -In order to access shares, your node needs to have the node attribute "drive:access". - -For example, to enable sharing and accessing shares for all member nodes: - - "nodeAttrs": [ - { - "target": ["autogroup:member"], - "attr": [ - "drive:share", - "drive:access", - ], - }] - -Each share is identified by a name and points to a directory at a specific path. For example, to share the path /Users/me/Documents under the name "docs", you would run: - - $ tailscale drive share docs /Users/me/Documents - -Note that the system forces share names to lowercase to avoid problems with clients that don't support case-sensitive filenames. - -Share names may only contain the letters a-z, underscore _, parentheses (), or spaces. Leading and trailing spaces are omitted. - -All Tailscale shares have a globally unique path consisting of the tailnet, the machine name and the share name. For example, if the above share was created on the machine "mylaptop" on the tailnet "mydomain.com", the share's path would be: - - /mydomain.com/mylaptop/docs - -In order to access this share, other machines on the tailnet can connect to the above path on a WebDAV server running at 100.100.100.100:8080, for example: - - http://100.100.100.100:8080/mydomain.com/mylaptop/docs - -Permissions to access shares are controlled via ACLs. For example, to give the group "home" read-only access to the above share, use the below ACL grant: - - "grants": [ - { - "src": ["group:home"], - "dst": ["mylaptop"], - "app": { - "tailscale.com/cap/drive": [{ - "shares": ["docs"], - "access": "ro" - }] - } - }] - -Whenever anyone in the group "home" connects to the share, they connect as if they are using your local machine user. They'll be able to read the same files as your user, and if they create files, those files will be owned by your user.%s - -On small tailnets, it may be convenient to categorically give all users full access to their own shares. That can be accomplished with the below grant. - - "grants": [ - { - "src": ["autogroup:member"], - "dst": ["autogroup:self"], - "app": { - "tailscale.com/cap/drive": [{ - "shares": ["*"], - "access": "rw" - }] - } - }] - -You can rename shares, for example you could rename the above share by running: - - $ tailscale drive rename docs newdocs - -You can remove shares by name, for example you could remove the above share by running: - - $ tailscale drive unshare newdocs - -You can get a list of currently published shares by running: - - $ tailscale drive list` - -const shareLongHelpAs = ` - -If you want a share to be accessed as a different user, you can use sudo to accomplish this. For example, to create the aforementioned share as "theuser", you could run: - - $ sudo -u theuser tailscale drive share docs /Users/theuser/Documents` diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go deleted file mode 100644 index 6b9247a7bc303..0000000000000 --- a/cmd/tailscale/cli/exitnode.go +++ /dev/null @@ -1,326 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "cmp" - "context" - "errors" - "flag" - "fmt" - "slices" - "strings" - "text/tabwriter" - - "github.com/kballard/go-shellquote" - "github.com/peterbourgon/ff/v3/ffcli" - xmaps "golang.org/x/exp/maps" - "tailscale.com/envknob" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" -) - -func exitNodeCmd() *ffcli.Command { - return &ffcli.Command{ - Name: "exit-node", - ShortUsage: "tailscale exit-node [flags]", - ShortHelp: "Show machines on your tailnet configured as exit nodes", - Subcommands: append([]*ffcli.Command{ - { - Name: "list", - ShortUsage: "tailscale exit-node list [flags]", - ShortHelp: "Show exit nodes", - Exec: runExitNodeList, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("list") - fs.StringVar(&exitNodeArgs.filter, "filter", "", "filter exit nodes by country") - return fs - })(), - }, - { - Name: "suggest", - ShortUsage: "tailscale exit-node suggest", - ShortHelp: "Suggests the best available exit node", - Exec: runExitNodeSuggest, - }}, - (func() []*ffcli.Command { - if !envknob.UseWIPCode() { - return nil - } - return []*ffcli.Command{ - { - Name: "connect", - ShortUsage: "tailscale exit-node connect", - ShortHelp: "Connect to most recently used exit node", - Exec: exitNodeSetUse(true), - }, - { - Name: "disconnect", - ShortUsage: "tailscale exit-node disconnect", - ShortHelp: "Disconnect from current exit node, if any", - Exec: exitNodeSetUse(false), - }, - } - })()...), - } -} - -var exitNodeArgs struct { - filter string -} - -func exitNodeSetUse(wantOn bool) func(ctx context.Context, args []string) error { - return func(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("unexpected non-flag arguments") - } - err := localClient.SetUseExitNode(ctx, wantOn) - if err != nil { - if !wantOn { - pref, err := localClient.GetPrefs(ctx) - if err == nil && pref.ExitNodeID == "" { - // Two processes concurrently turned it off. - return nil - } - } - } - return err - } -} - -// runExitNodeList returns a formatted list of exit nodes for a tailnet. -// If the exit node has location and priority data, only the highest -// priority node for each city location is shown to the user. -// If the country location has more than one city, an 'Any' city -// is returned for the country, which lists the highest priority -// node in that country. -// For countries without location data, each exit node is displayed. -func runExitNodeList(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("unexpected non-flag arguments to 'tailscale exit-node list'") - } - getStatus := localClient.Status - st, err := getStatus(ctx) - if err != nil { - return fixTailscaledConnectError(err) - } - - var peers []*ipnstate.PeerStatus - for _, ps := range st.Peer { - if !ps.ExitNodeOption { - // We only show exit nodes under the exit-node subcommand. - continue - } - peers = append(peers, ps) - } - - if len(peers) == 0 { - return errors.New("no exit nodes found") - } - - filteredPeers := filterFormatAndSortExitNodes(peers, exitNodeArgs.filter) - - if len(filteredPeers.Countries) == 0 && exitNodeArgs.filter != "" { - return fmt.Errorf("no exit nodes found for %q", exitNodeArgs.filter) - } - - w := tabwriter.NewWriter(Stdout, 10, 5, 5, ' ', 0) - defer w.Flush() - fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", "IP", "HOSTNAME", "COUNTRY", "CITY", "STATUS") - for _, country := range filteredPeers.Countries { - for _, city := range country.Cities { - for _, peer := range city.Peers { - fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", peer.TailscaleIPs[0], strings.Trim(peer.DNSName, "."), country.Name, city.Name, peerStatus(peer)) - } - } - } - fmt.Fprintln(w) - fmt.Fprintln(w) - fmt.Fprintln(w, "# To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name.") - fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP.") - if hasAnyExitNodeSuggestions(peers) { - fmt.Fprintln(w, "# To have Tailscale suggest an exit node, use `tailscale exit-node suggest`.") - } - return nil -} - -// runExitNodeSuggest returns a suggested exit node ID to connect to and shows the chosen exit node tailcfg.StableNodeID. -// If there are no derp based exit nodes to choose from or there is a failure in finding a suggestion, the command will return an error indicating so. -func runExitNodeSuggest(ctx context.Context, args []string) error { - res, err := localClient.SuggestExitNode(ctx) - if err != nil { - return fmt.Errorf("suggest exit node: %w", err) - } - if res.ID == "" { - fmt.Println("No exit node suggestion is available.") - return nil - } - fmt.Printf("Suggested exit node: %v\nTo accept this suggestion, use `tailscale set --exit-node=%v`.\n", res.Name, shellquote.Join(res.Name)) - return nil -} - -func hasAnyExitNodeSuggestions(peers []*ipnstate.PeerStatus) bool { - for _, peer := range peers { - if peer.HasCap(tailcfg.NodeAttrSuggestExitNode) { - return true - } - } - return false -} - -// peerStatus returns a string representing the current state of -// a peer. If there is no notable state, a - is returned. -func peerStatus(peer *ipnstate.PeerStatus) string { - if !peer.Active { - if peer.ExitNode { - return "selected but offline" - } - if !peer.Online { - return "offline" - } - } - - if peer.ExitNode { - return "selected" - } - - return "-" -} - -type filteredExitNodes struct { - Countries []*filteredCountry -} - -type filteredCountry struct { - Name string - Cities []*filteredCity -} - -type filteredCity struct { - Name string - Peers []*ipnstate.PeerStatus -} - -const noLocationData = "-" - -var noLocation = &tailcfg.Location{ - Country: noLocationData, - CountryCode: noLocationData, - City: noLocationData, - CityCode: noLocationData, -} - -// filterFormatAndSortExitNodes filters and sorts exit nodes into -// alphabetical order, by country, city and then by priority if -// present. -// If an exit node has location data, and the country has more than -// one city, an `Any` city is added to the country that contains the -// highest priority exit node within that country. -// For exit nodes without location data, their country fields are -// defined as '-' to indicate that the data is not available. -func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string) filteredExitNodes { - // first get peers into some fixed order, as code below doesn't break ties - // and our input comes from a random range-over-map. - slices.SortFunc(peers, func(a, b *ipnstate.PeerStatus) int { - return strings.Compare(a.DNSName, b.DNSName) - }) - - countries := make(map[string]*filteredCountry) - cities := make(map[string]*filteredCity) - for _, ps := range peers { - loc := cmp.Or(ps.Location, noLocation) - - if filterBy != "" && !strings.EqualFold(loc.Country, filterBy) { - continue - } - - co, ok := countries[loc.CountryCode] - if !ok { - co = &filteredCountry{ - Name: loc.Country, - } - countries[loc.CountryCode] = co - } - - ci, ok := cities[loc.CityCode] - if !ok { - ci = &filteredCity{ - Name: loc.City, - } - cities[loc.CityCode] = ci - co.Cities = append(co.Cities, ci) - } - ci.Peers = append(ci.Peers, ps) - } - - filteredExitNodes := filteredExitNodes{ - Countries: xmaps.Values(countries), - } - - for _, country := range filteredExitNodes.Countries { - if country.Name == noLocationData { - // Countries without location data should not - // be filtered further. - continue - } - - var countryAnyPeer []*ipnstate.PeerStatus - for _, city := range country.Cities { - sortPeersByPriority(city.Peers) - countryAnyPeer = append(countryAnyPeer, city.Peers...) - var reducedCityPeers []*ipnstate.PeerStatus - for i, peer := range city.Peers { - if filterBy != "" { - // If the peers are being filtered, we return all peers to the user. - reducedCityPeers = append(reducedCityPeers, city.Peers...) - break - } - // If the peers are not being filtered, we only return the highest priority peer and any peer that - // is currently the active exit node. - if i == 0 || peer.ExitNode { - reducedCityPeers = append(reducedCityPeers, peer) - } - } - city.Peers = reducedCityPeers - } - sortByCityName(country.Cities) - sortPeersByPriority(countryAnyPeer) - - if len(country.Cities) > 1 { - // For countries with more than one city, we want to return the - // option of the best peer for that country. - country.Cities = append([]*filteredCity{ - { - Name: "Any", - Peers: []*ipnstate.PeerStatus{countryAnyPeer[0]}, - }, - }, country.Cities...) - } - } - sortByCountryName(filteredExitNodes.Countries) - - return filteredExitNodes -} - -// sortPeersByPriority sorts a slice of PeerStatus -// by location.Priority, in order of highest priority. -func sortPeersByPriority(peers []*ipnstate.PeerStatus) { - slices.SortStableFunc(peers, func(a, b *ipnstate.PeerStatus) int { - return cmp.Compare(b.Location.Priority, a.Location.Priority) - }) -} - -// sortByCityName sorts a slice of filteredCity alphabetically -// by name. The '-' used to indicate no location data will always -// be sorted to the front of the slice. -func sortByCityName(cities []*filteredCity) { - slices.SortStableFunc(cities, func(a, b *filteredCity) int { return strings.Compare(a.Name, b.Name) }) -} - -// sortByCountryName sorts a slice of filteredCountry alphabetically -// by name. The '-' used to indicate no location data will always -// be sorted to the front of the slice. -func sortByCountryName(countries []*filteredCountry) { - slices.SortStableFunc(countries, func(a, b *filteredCountry) int { return strings.Compare(a.Name, b.Name) }) -} diff --git a/cmd/tailscale/cli/exitnode_test.go b/cmd/tailscale/cli/exitnode_test.go deleted file mode 100644 index 9d569a45a4615..0000000000000 --- a/cmd/tailscale/cli/exitnode_test.go +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -func TestFilterFormatAndSortExitNodes(t *testing.T) { - t.Run("without filter", func(t *testing.T) { - ps := []*ipnstate.PeerStatus{ - { - HostName: "everest-1", - Location: &tailcfg.Location{ - Country: "Everest", - CountryCode: "evr", - City: "Hillary", - CityCode: "hil", - Priority: 100, - }, - }, - { - HostName: "lhotse-1", - Location: &tailcfg.Location{ - Country: "Lhotse", - CountryCode: "lho", - City: "Fritz", - CityCode: "fri", - Priority: 200, - }, - }, - { - HostName: "lhotse-2", - Location: &tailcfg.Location{ - Country: "Lhotse", - CountryCode: "lho", - City: "Fritz", - CityCode: "fri", - Priority: 100, - }, - }, - { - HostName: "nuptse-1", - Location: &tailcfg.Location{ - Country: "Nuptse", - CountryCode: "nup", - City: "Walmsley", - CityCode: "wal", - Priority: 200, - }, - }, - { - HostName: "nuptse-2", - Location: &tailcfg.Location{ - Country: "Nuptse", - CountryCode: "nup", - City: "Bonington", - CityCode: "bon", - Priority: 10, - }, - }, - { - HostName: "Makalu", - }, - } - - want := filteredExitNodes{ - Countries: []*filteredCountry{ - { - Name: noLocationData, - Cities: []*filteredCity{ - { - Name: noLocationData, - Peers: []*ipnstate.PeerStatus{ - ps[5], - }, - }, - }, - }, - { - Name: "Everest", - Cities: []*filteredCity{ - { - Name: "Hillary", - Peers: []*ipnstate.PeerStatus{ - ps[0], - }, - }, - }, - }, - { - Name: "Lhotse", - Cities: []*filteredCity{ - { - Name: "Fritz", - Peers: []*ipnstate.PeerStatus{ - ps[1], - }, - }, - }, - }, - { - Name: "Nuptse", - Cities: []*filteredCity{ - { - Name: "Any", - Peers: []*ipnstate.PeerStatus{ - ps[3], - }, - }, - { - Name: "Bonington", - Peers: []*ipnstate.PeerStatus{ - ps[4], - }, - }, - { - Name: "Walmsley", - Peers: []*ipnstate.PeerStatus{ - ps[3], - }, - }, - }, - }, - }, - } - - result := filterFormatAndSortExitNodes(ps, "") - - if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" { - t.Fatal(res) - } - }) - - t.Run("with country filter", func(t *testing.T) { - ps := []*ipnstate.PeerStatus{ - { - HostName: "baker-1", - Location: &tailcfg.Location{ - Country: "Pacific", - CountryCode: "pst", - City: "Baker", - CityCode: "col", - Priority: 100, - }, - }, - { - HostName: "hood-1", - Location: &tailcfg.Location{ - Country: "Pacific", - CountryCode: "pst", - City: "Hood", - CityCode: "hoo", - Priority: 500, - }, - }, - { - HostName: "rainier-1", - Location: &tailcfg.Location{ - Country: "Pacific", - CountryCode: "pst", - City: "Rainier", - CityCode: "rai", - Priority: 100, - }, - }, - { - HostName: "rainier-2", - Location: &tailcfg.Location{ - Country: "Pacific", - CountryCode: "pst", - City: "Rainier", - CityCode: "rai", - Priority: 10, - }, - }, - { - HostName: "mitchell-1", - Location: &tailcfg.Location{ - Country: "Atlantic", - CountryCode: "atl", - City: "Mitchell", - CityCode: "mit", - Priority: 200, - }, - }, - } - - want := filteredExitNodes{ - Countries: []*filteredCountry{ - { - Name: "Pacific", - Cities: []*filteredCity{ - { - Name: "Any", - Peers: []*ipnstate.PeerStatus{ - ps[1], - }, - }, - { - Name: "Baker", - Peers: []*ipnstate.PeerStatus{ - ps[0], - }, - }, - { - Name: "Hood", - Peers: []*ipnstate.PeerStatus{ - ps[1], - }, - }, - { - Name: "Rainier", - Peers: []*ipnstate.PeerStatus{ - ps[2], ps[3], - }, - }, - }, - }, - }, - } - - result := filterFormatAndSortExitNodes(ps, "Pacific") - - if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" { - t.Fatal(res) - } - }) -} - -func TestSortPeersByPriority(t *testing.T) { - ps := []*ipnstate.PeerStatus{ - { - Location: &tailcfg.Location{ - Priority: 100, - }, - }, - { - Location: &tailcfg.Location{ - Priority: 200, - }, - }, - { - Location: &tailcfg.Location{ - Priority: 300, - }, - }, - } - - sortPeersByPriority(ps) - - if ps[0].Location.Priority != 300 { - t.Fatalf("sortPeersByPriority did not order PeerStatus with highest priority as index 0, got %v, want %v", ps[0].Location.Priority, 300) - } -} - -func TestSortByCountryName(t *testing.T) { - fc := []*filteredCountry{ - { - Name: "Albania", - }, - { - Name: "Sweden", - }, - { - Name: "Zimbabwe", - }, - { - Name: noLocationData, - }, - } - - sortByCountryName(fc) - - if fc[0].Name != noLocationData { - t.Fatalf("sortByCountryName did not order countries by alphabetical order, got %v, want %v", fc[0].Name, noLocationData) - } -} - -func TestSortByCityName(t *testing.T) { - fc := []*filteredCity{ - { - Name: "Kingston", - }, - { - Name: "Goteborg", - }, - { - Name: "Squamish", - }, - { - Name: noLocationData, - }, - } - - sortByCityName(fc) - - if fc[0].Name != noLocationData { - t.Fatalf("sortByCityName did not order cities by alphabetical order, got %v, want %v", fc[0].Name, noLocationData) - } -} diff --git a/cmd/tailscale/cli/ffcomplete/complete.go b/cmd/tailscale/cli/ffcomplete/complete.go deleted file mode 100644 index fbd5b9d62823d..0000000000000 --- a/cmd/tailscale/cli/ffcomplete/complete.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 && !ts_omit_completion - -// Package ffcomplete provides shell tab-completion of subcommands, flags and -// arguments for Go programs written with [ffcli]. -// -// The shell integration scripts have been extracted from Cobra -// (https://cobra.dev/), whose authors deserve most of the credit for this work. -// These shell completion functions invoke `$0 completion __complete -- ...` -// which is wired up to [Complete]. -package ffcomplete - -import ( - "context" - "flag" - "fmt" - "io" - "log" - "os" - "strconv" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/cmd/tailscale/cli/ffcomplete/internal" - "tailscale.com/tempfork/spf13/cobra" -) - -type compOpts struct { - showFlags bool - showDescs bool -} - -func newFS(name string, opts *compOpts) *flag.FlagSet { - fs := flag.NewFlagSet(name, flag.ContinueOnError) - fs.BoolVar(&opts.showFlags, "flags", true, "Suggest flag completions with subcommands") - fs.BoolVar(&opts.showDescs, "descs", true, "Include flag, subcommand, and other descriptions in completions") - return fs -} - -// Inject adds the 'completion' subcommand to the root command which provide the -// user with shell scripts for calling `completion __command` to provide -// tab-completion suggestions. -// -// root.Name needs to match the command that the user is tab-completing for the -// shell script to work as expected by default. -// -// The hide function is called with the __complete Command instance to provide a -// hook to omit it from the help output, if desired. -func Inject(root *ffcli.Command, hide func(*ffcli.Command), usageFunc func(*ffcli.Command) string) { - var opts compOpts - compFS := newFS("completion", &opts) - - completeCmd := &ffcli.Command{ - Name: "__complete", - ShortUsage: root.Name + " completion __complete -- ", - ShortHelp: "Tab-completion suggestions for interactive shells", - UsageFunc: usageFunc, - FlagSet: compFS, - Exec: func(ctx context.Context, args []string) error { - // Set up debug logging for the rest of this function call. - if t := os.Getenv("BASH_COMP_DEBUG_FILE"); t != "" { - tf, err := os.OpenFile(t, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return fmt.Errorf("opening debug file: %w", err) - } - defer func(origW io.Writer, origPrefix string, origFlags int) { - log.SetOutput(origW) - log.SetFlags(origFlags) - log.SetPrefix(origPrefix) - tf.Close() - }(log.Writer(), log.Prefix(), log.Flags()) - log.SetOutput(tf) - log.SetFlags(log.Lshortfile) - log.SetPrefix("debug: ") - } - - // Send back the results to the shell. - words, dir, err := internal.Complete(root, args, opts.showFlags, opts.showDescs) - if err != nil { - dir = ShellCompDirectiveError - } - for _, word := range words { - fmt.Println(word) - } - fmt.Println(":" + strconv.Itoa(int(dir))) - return err - }, - } - if hide != nil { - hide(completeCmd) - } - - root.Subcommands = append( - root.Subcommands, - &ffcli.Command{ - Name: "completion", - ShortUsage: root.Name + " completion [--flags] [--descs]", - ShortHelp: "Shell tab-completion scripts", - LongHelp: fmt.Sprintf(cobra.UsageTemplate, root.Name), - - // Print help if run without args. - Exec: func(ctx context.Context, args []string) error { return flag.ErrHelp }, - - // Omit the '__complete' subcommand from the 'completion' help. - UsageFunc: func(c *ffcli.Command) string { - // Filter the subcommands to omit '__complete'. - s := make([]*ffcli.Command, 0, len(c.Subcommands)) - for _, sub := range c.Subcommands { - if !strings.HasPrefix(sub.Name, "__") { - s = append(s, sub) - } - } - - // Swap in the filtered subcommands list for the rest of the call. - defer func(r []*ffcli.Command) { c.Subcommands = r }(c.Subcommands) - c.Subcommands = s - - // Render the usage. - if usageFunc == nil { - return ffcli.DefaultUsageFunc(c) - } - return usageFunc(c) - }, - - Subcommands: append( - scriptCmds(root, usageFunc), - completeCmd, - ), - }, - ) -} - -// Flag registers a completion function for the flag in fs with given name. -// comp will always called with a 1-element slice. -// -// comp will be called to return suggestions when the user tries to tab-complete -// '--name=' or '--name ' for the commands using fs. -func Flag(fs *flag.FlagSet, name string, comp CompleteFunc) { - f := fs.Lookup(name) - if f == nil { - panic(fmt.Errorf("ffcomplete.Flag: flag %s not found", name)) - } - if internal.CompleteFlags == nil { - internal.CompleteFlags = make(map[*flag.Flag]CompleteFunc) - } - internal.CompleteFlags[f] = comp -} - -// Args registers a completion function for the args of cmd. -// -// comp will be called to return suggestions when the user tries to tab-complete -// `prog ` or `prog subcmd arg1 `, for example. -func Args(cmd *ffcli.Command, comp CompleteFunc) { - if internal.CompleteCmds == nil { - internal.CompleteCmds = make(map[*ffcli.Command]CompleteFunc) - } - internal.CompleteCmds[cmd] = comp -} diff --git a/cmd/tailscale/cli/ffcomplete/complete_omit.go b/cmd/tailscale/cli/ffcomplete/complete_omit.go deleted file mode 100644 index bafc059e7b71d..0000000000000 --- a/cmd/tailscale/cli/ffcomplete/complete_omit.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 && ts_omit_completion - -package ffcomplete - -import ( - "flag" - - "github.com/peterbourgon/ff/v3/ffcli" -) - -func Inject(root *ffcli.Command, hide func(*ffcli.Command), usageFunc func(*ffcli.Command) string) {} - -func Flag(fs *flag.FlagSet, name string, comp CompleteFunc) {} -func Args(cmd *ffcli.Command, comp CompleteFunc) *ffcli.Command { return cmd } diff --git a/cmd/tailscale/cli/ffcomplete/ffcomplete.go b/cmd/tailscale/cli/ffcomplete/ffcomplete.go deleted file mode 100644 index 4b8207ec60a0c..0000000000000 --- a/cmd/tailscale/cli/ffcomplete/ffcomplete.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ffcomplete - -import ( - "strings" - - "tailscale.com/cmd/tailscale/cli/ffcomplete/internal" - "tailscale.com/tempfork/spf13/cobra" -) - -type ShellCompDirective = cobra.ShellCompDirective - -const ( - ShellCompDirectiveError = cobra.ShellCompDirectiveError - ShellCompDirectiveNoSpace = cobra.ShellCompDirectiveNoSpace - ShellCompDirectiveNoFileComp = cobra.ShellCompDirectiveNoFileComp - ShellCompDirectiveFilterFileExt = cobra.ShellCompDirectiveFilterFileExt - ShellCompDirectiveFilterDirs = cobra.ShellCompDirectiveFilterDirs - ShellCompDirectiveKeepOrder = cobra.ShellCompDirectiveKeepOrder - ShellCompDirectiveDefault = cobra.ShellCompDirectiveDefault -) - -// CompleteFunc is used to return tab-completion suggestions to the user as they -// are typing command-line instructions. It returns the list of things to -// suggest and an additional directive to the shell about what extra -// functionality to enable. -type CompleteFunc = internal.CompleteFunc - -// LastArg returns the last element of args, or the empty string if args is -// empty. -func LastArg(args []string) string { - if len(args) == 0 { - return "" - } - return args[len(args)-1] -} - -// Fixed returns a CompleteFunc which suggests the given words. -func Fixed(words ...string) CompleteFunc { - return func(args []string) ([]string, cobra.ShellCompDirective, error) { - match := LastArg(args) - matches := make([]string, 0, len(words)) - for _, word := range words { - if strings.HasPrefix(word, match) { - matches = append(matches, word) - } - } - return matches, cobra.ShellCompDirectiveNoFileComp, nil - } -} - -// FilesWithExtensions returns a CompleteFunc that tells the shell to limit file -// suggestions to those with the given extensions. -func FilesWithExtensions(exts ...string) CompleteFunc { - return func(args []string) ([]string, cobra.ShellCompDirective, error) { - return exts, cobra.ShellCompDirectiveFilterFileExt, nil - } -} diff --git a/cmd/tailscale/cli/ffcomplete/internal/complete.go b/cmd/tailscale/cli/ffcomplete/internal/complete.go deleted file mode 100644 index b6c39dc837215..0000000000000 --- a/cmd/tailscale/cli/ffcomplete/internal/complete.go +++ /dev/null @@ -1,271 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package internal contains internal code for the ffcomplete package. -package internal - -import ( - "flag" - "fmt" - "strings" - - "github.com/peterbourgon/ff/v3" - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/tempfork/spf13/cobra" -) - -var ( - CompleteCmds map[*ffcli.Command]CompleteFunc - CompleteFlags map[*flag.Flag]CompleteFunc -) - -type CompleteFunc func([]string) ([]string, cobra.ShellCompDirective, error) - -// Complete returns the autocomplete suggestions for the root program and args. -// -// The returned words do not necessarily need to be prefixed with the last arg -// which is being completed. For example, '--bool-flag=' will have completions -// 'true' and 'false'. -// -// "HIDDEN: " is trimmed from the start of Flag Usage's. -func Complete(root *ffcli.Command, args []string, startFlags, descs bool) (words []string, dir cobra.ShellCompDirective, err error) { - // Explicitly log panics. - defer func() { - if r := recover(); r != nil { - if rerr, ok := err.(error); ok { - err = fmt.Errorf("panic: %w", rerr) - } else { - err = fmt.Errorf("panic: %v", r) - } - } - }() - - // Set up the arguments. - if len(args) == 0 { - args = []string{""} - } - - // Completion criteria. - completeArg := args[len(args)-1] - args = args[:len(args)-1] - emitFlag := startFlags || strings.HasPrefix(completeArg, "-") - emitArgs := true - - // Traverse the command-tree to find the cmd command whose - // subcommand, flags, or arguments are being completed. - cmd := root -walk: - for { - // Ensure there's a flagset with ContinueOnError set. - if cmd.FlagSet == nil { - cmd.FlagSet = flag.NewFlagSet(cmd.Name, flag.ContinueOnError) - } - cmd.FlagSet.Init(cmd.FlagSet.Name(), flag.ContinueOnError) - - // Manually split the args so we know when we're completing flags/args. - flagArgs, argArgs, flagNeedingValue := splitFlagArgs(cmd.FlagSet, args) - if flagNeedingValue != "" { - completeArg = flagNeedingValue + "=" + completeArg - emitFlag = true - } - args = argArgs - - // Parse the flags. - err := ff.Parse(cmd.FlagSet, flagArgs, cmd.Options...) - if err != nil { - return nil, 0, fmt.Errorf("%s flag parsing: %w", cmd.Name, err) - } - if cmd.FlagSet.NArg() > 0 { - // This shouldn't happen if splitFlagArgs is accurately finding the - // split between flags and args. - _ = false - } - if len(args) == 0 { - break - } - - // Check if the first argument is actually a subcommand. - for _, sub := range cmd.Subcommands { - if strings.EqualFold(sub.Name, args[0]) { - args = args[1:] - cmd = sub - continue walk - } - } - break - } - if len(args) > 0 { - emitFlag = false - } - - // Complete '-flag=...'. If the args ended with '-flag ...' we will have - // rewritten to '-flag=...' by now. - if emitFlag && strings.HasPrefix(completeArg, "-") && strings.Contains(completeArg, "=") { - // Don't complete '-flag' later on as the - // flag name is terminated by a '='. - emitFlag = false - emitArgs = false - - dashFlag, completeVal, _ := strings.Cut(completeArg, "=") - _, f := cutDash(dashFlag) - flag := cmd.FlagSet.Lookup(f) - if flag != nil { - if comp := CompleteFlags[flag]; comp != nil { - // Complete custom flag values. - var err error - words, dir, err = comp([]string{completeVal}) - if err != nil { - return nil, 0, fmt.Errorf("completing %s flag %s: %w", cmd.Name, flag.Name, err) - } - } else if isBoolFlag(flag) { - // Complete true/false. - for _, vals := range [][]string{ - {"true", "TRUE", "True", "1"}, - {"false", "FALSE", "False", "0"}, - } { - for _, val := range vals { - if strings.HasPrefix(val, completeVal) { - words = append(words, val) - break - } - } - } - } - } - } - - // Complete '-flag...'. - if emitFlag { - used := make(map[string]struct{}) - cmd.FlagSet.Visit(func(f *flag.Flag) { - used[f.Name] = struct{}{} - }) - - cd, cf := cutDash(completeArg) - cmd.FlagSet.VisitAll(func(f *flag.Flag) { - if !strings.HasPrefix(f.Name, cf) { - return - } - // Skip flags already set by the user. - if _, seen := used[f.Name]; seen { - return - } - // Suggest single-dash '-v' for single-char flags and - // double-dash '--verbose' for longer. - d := cd - if (d == "" || d == "-") && cf == "" && len(f.Name) > 1 { - d = "--" - } - if descs { - _, usage := flag.UnquoteUsage(f) - usage = strings.TrimPrefix(usage, "HIDDEN: ") - if usage != "" { - words = append(words, d+f.Name+"\t"+usage) - return - } - } - words = append(words, d+f.Name) - }) - } - - if emitArgs { - // Complete 'sub...'. - for _, sub := range cmd.Subcommands { - if strings.HasPrefix(sub.Name, completeArg) { - if descs { - if sub.ShortHelp != "" { - words = append(words, sub.Name+"\t"+sub.ShortHelp) - continue - } - } - words = append(words, sub.Name) - } - } - - // Complete custom args. - if comp := CompleteCmds[cmd]; comp != nil { - w, d, err := comp(append(args, completeArg)) - if err != nil { - return nil, 0, fmt.Errorf("completing %s args: %w", cmd.Name, err) - } - dir = d - words = append(words, w...) - } - } - - // Strip any descriptions if they were suppressed. - clean := words[:0] - for _, w := range words { - if !descs { - w, _, _ = strings.Cut(w, "\t") - } - w = cutAny(w, "\n\r") - if w == "" || w[0] == '\t' { - continue - } - clean = append(clean, w) - } - return clean, dir, nil -} - -func cutAny(s, cutset string) string { - i := strings.IndexAny(s, cutset) - if i == -1 { - return s - } - return s[:i] -} - -// splitFlagArgs separates a list of command-line arguments into arguments -// comprising flags and their values, preceding arguments to be passed to the -// command. This follows the stdlib 'flag' parsing conventions. If the final -// argument is a flag name which takes a value but has no value specified, it is -// omitted from flagArgs and argArgs and instead returned in needValue. -func splitFlagArgs(fs *flag.FlagSet, args []string) (flagArgs, argArgs []string, flagNeedingValue string) { - for i := 0; i < len(args); i++ { - a := args[i] - if a == "--" { - return args[:i], args[i+1:], "" - } - - d, f := cutDash(a) - if d == "" { - return args[:i], args[i:], "" - } - if strings.Contains(f, "=") { - continue - } - - flag := fs.Lookup(f) - if flag == nil { - return args[:i], args[i:], "" - } - if isBoolFlag(flag) { - continue - } - - // Consume an extra argument for the flag value. - if i == len(args)-1 { - return args[:i], nil, args[i] - } - i++ - } - return args, nil, "" -} - -func cutDash(s string) (dashes, flag string) { - if strings.HasPrefix(s, "-") { - if strings.HasPrefix(s[1:], "-") { - return "--", s[2:] - } - return "-", s[1:] - } - return "", s -} - -func isBoolFlag(f *flag.Flag) bool { - bf, ok := f.Value.(interface { - IsBoolFlag() bool - }) - return ok && bf.IsBoolFlag() -} diff --git a/cmd/tailscale/cli/ffcomplete/internal/complete_test.go b/cmd/tailscale/cli/ffcomplete/internal/complete_test.go deleted file mode 100644 index 7e36b1bcd1437..0000000000000 --- a/cmd/tailscale/cli/ffcomplete/internal/complete_test.go +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package internal_test - -import ( - _ "embed" - "flag" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/cmd/tailscale/cli/ffcomplete" - "tailscale.com/cmd/tailscale/cli/ffcomplete/internal" -) - -func newFlagSet(name string, errh flag.ErrorHandling, flags func(fs *flag.FlagSet)) *flag.FlagSet { - fs := flag.NewFlagSet(name, errh) - if flags != nil { - flags(fs) - } - return fs -} - -func TestComplete(t *testing.T) { - t.Parallel() - - // Build our test program in testdata. - root := &ffcli.Command{ - Name: "prog", - FlagSet: newFlagSet("prog", flag.ContinueOnError, func(fs *flag.FlagSet) { - fs.Bool("v", false, "verbose") - fs.Bool("root-bool", false, "root `bool`") - fs.String("root-str", "", "some `text`") - }), - Subcommands: []*ffcli.Command{ - { - Name: "debug", - ShortHelp: "Debug data", - FlagSet: newFlagSet("prog debug", flag.ExitOnError, func(fs *flag.FlagSet) { - fs.String("cpu-profile", "", "write cpu profile to `file`") - fs.Bool("debug-bool", false, "debug bool") - fs.Int("level", 0, "a number") - fs.String("enum", "", "a flag that takes several specific values") - ffcomplete.Flag(fs, "enum", ffcomplete.Fixed("alpha", "beta", "charlie")) - }), - }, - func() *ffcli.Command { - cmd := &ffcli.Command{ - Name: "ping", - FlagSet: newFlagSet("prog ping", flag.ContinueOnError, func(fs *flag.FlagSet) { - fs.String("until", "", "when pinging should end\nline break!") - ffcomplete.Flag(fs, "until", ffcomplete.Fixed("forever", "direct")) - }), - } - ffcomplete.Args(cmd, ffcomplete.Fixed( - "jupiter\t5th planet\nand largets", - "neptune\t8th planet", - "venus\t2nd planet", - "\tonly description", - "\nonly line break", - )) - return cmd - }(), - }, - } - - tests := []struct { - args []string - showFlags bool - showDescs bool - wantComp []string - wantDir ffcomplete.ShellCompDirective - }{ - { - args: []string{"deb"}, - wantComp: []string{"debug"}, - }, - { - args: []string{"deb"}, - showDescs: true, - wantComp: []string{"debug\tDebug data"}, - }, - { - args: []string{"-"}, - wantComp: []string{"--root-bool", "--root-str", "-v"}, - }, - { - args: []string{"--"}, - wantComp: []string{"--root-bool", "--root-str", "--v"}, - }, - { - args: []string{"-r"}, - wantComp: []string{"-root-bool", "-root-str"}, - }, - { - args: []string{"--r"}, - wantComp: []string{"--root-bool", "--root-str"}, - }, - { - args: []string{"--root-str=s", "--r"}, - wantComp: []string{"--root-bool"}, // omits --root-str which is already set - }, - { - // '--' disables flag parsing, so we shouldn't suggest flags. - args: []string{"--", "--root"}, - wantComp: nil, - }, - { - // '--' is used as the value of '--root-str'. - args: []string{"--root-str", "--", "--r"}, - wantComp: []string{"--root-bool"}, - }, - { - // '--' here is a flag value, so doesn't disable flag parsing. - args: []string{"--root-str", "--", "--root"}, - wantComp: []string{"--root-bool"}, - }, - { - // Equivalent to '--root-str=-- -- --r' meaning '--r' is not - // a flag because it's preceded by a '--' argument: - // https://go.dev/play/p/UCtftQqVhOD. - args: []string{"--root-str", "--", "--", "--r"}, - wantComp: nil, - }, - { - args: []string{"--root-bool="}, - wantComp: []string{"true", "false"}, - }, - { - args: []string{"--root-bool=t"}, - wantComp: []string{"true"}, - }, - { - args: []string{"--root-bool=T"}, - wantComp: []string{"TRUE"}, - }, - { - args: []string{"debug", "--de"}, - wantComp: []string{"--debug-bool"}, - }, - { - args: []string{"debug", "--enum="}, - wantComp: []string{"alpha", "beta", "charlie"}, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - { - args: []string{"debug", "--enum=al"}, - wantComp: []string{"alpha"}, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - { - args: []string{"debug", "--level", ""}, - wantComp: nil, - }, - { - args: []string{"debug", "--enum", "b"}, - wantComp: []string{"beta"}, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - { - args: []string{"debug", "--enum", "al"}, - wantComp: []string{"alpha"}, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - { - args: []string{"ping", ""}, - showFlags: true, - wantComp: []string{"--until", "jupiter", "neptune", "venus"}, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - { - args: []string{"ping", ""}, - showFlags: true, - showDescs: true, - wantComp: []string{ - "--until\twhen pinging should end", - "jupiter\t5th planet", - "neptune\t8th planet", - "venus\t2nd planet", - }, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - { - args: []string{"ping", ""}, - wantComp: []string{"jupiter", "neptune", "venus"}, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - { - args: []string{"ping", "j"}, - wantComp: []string{"jupiter"}, - wantDir: ffcomplete.ShellCompDirectiveNoFileComp, - }, - } - - // Run the tests. - for _, test := range tests { - test := test - name := strings.Join(test.args, "␣") - if test.showFlags { - name += "+flags" - } - if test.showDescs { - name += "+descs" - } - t.Run(name, func(t *testing.T) { - // Capture the binary - complete, dir, err := internal.Complete(root, test.args, test.showFlags, test.showDescs) - if err != nil { - t.Fatalf("completion error: %s", err) - } - - // Test the results match our expectation. - if test.wantComp != nil { - if diff := cmp.Diff(test.wantComp, complete); diff != "" { - t.Errorf("unexpected completion directives (-want +got):\n%s", diff) - } - } - if test.wantDir != dir { - t.Errorf("got shell completion directive %[1]d (%[1]s), want %[2]d (%[2]s)", dir, test.wantDir) - } - }) - } -} diff --git a/cmd/tailscale/cli/ffcomplete/scripts.go b/cmd/tailscale/cli/ffcomplete/scripts.go deleted file mode 100644 index 8218683afa349..0000000000000 --- a/cmd/tailscale/cli/ffcomplete/scripts.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 && !ts_omit_completion && !ts_omit_completion_scripts - -package ffcomplete - -import ( - "context" - "flag" - "os" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/tempfork/spf13/cobra" -) - -func compCmd(fs *flag.FlagSet) string { - var s strings.Builder - s.WriteString("completion __complete") - fs.VisitAll(func(f *flag.Flag) { - s.WriteString(" --") - s.WriteString(f.Name) - s.WriteString("=") - s.WriteString(f.Value.String()) - }) - s.WriteString(" --") - return s.String() -} - -func scriptCmds(root *ffcli.Command, usageFunc func(*ffcli.Command) string) []*ffcli.Command { - nameForVar := root.Name - nameForVar = strings.ReplaceAll(nameForVar, "-", "_") - nameForVar = strings.ReplaceAll(nameForVar, ":", "_") - - var ( - bashFS = newFS("bash", &compOpts{}) - zshFS = newFS("zsh", &compOpts{}) - fishFS = newFS("fish", &compOpts{}) - pwshFS = newFS("powershell", &compOpts{}) - ) - - return []*ffcli.Command{ - { - Name: "bash", - ShortHelp: "Generate bash shell completion script", - ShortUsage: ". <( " + root.Name + " completion bash )", - UsageFunc: usageFunc, - FlagSet: bashFS, - Exec: func(ctx context.Context, args []string) error { - return cobra.ScriptBash(os.Stdout, root.Name, compCmd(bashFS), nameForVar) - }, - }, - { - Name: "zsh", - ShortHelp: "Generate zsh shell completion script", - ShortUsage: ". <( " + root.Name + " completion zsh )", - UsageFunc: usageFunc, - FlagSet: zshFS, - Exec: func(ctx context.Context, args []string) error { - return cobra.ScriptZsh(os.Stdout, root.Name, compCmd(zshFS), nameForVar) - }, - }, - { - Name: "fish", - ShortHelp: "Generate fish shell completion script", - ShortUsage: root.Name + " completion fish | source", - UsageFunc: usageFunc, - FlagSet: fishFS, - Exec: func(ctx context.Context, args []string) error { - return cobra.ScriptFish(os.Stdout, root.Name, compCmd(fishFS), nameForVar) - }, - }, - { - Name: "powershell", - ShortHelp: "Generate powershell completion script", - ShortUsage: root.Name + " completion powershell | Out-String | Invoke-Expression", - UsageFunc: usageFunc, - FlagSet: pwshFS, - Exec: func(ctx context.Context, args []string) error { - return cobra.ScriptPowershell(os.Stdout, root.Name, compCmd(pwshFS), nameForVar) - }, - }, - } -} diff --git a/cmd/tailscale/cli/ffcomplete/scripts_omit.go b/cmd/tailscale/cli/ffcomplete/scripts_omit.go deleted file mode 100644 index b5d520c3fe1d9..0000000000000 --- a/cmd/tailscale/cli/ffcomplete/scripts_omit.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 && !ts_omit_completion && ts_omit_completion_scripts - -package ffcomplete - -import "github.com/peterbourgon/ff/v3/ffcli" - -func scriptCmds(root *ffcli.Command, usageFunc func(*ffcli.Command) string) []*ffcli.Command { - return nil -} diff --git a/cmd/tailscale/cli/file.go b/cmd/tailscale/cli/file.go deleted file mode 100644 index cd776244679c8..0000000000000 --- a/cmd/tailscale/cli/file.go +++ /dev/null @@ -1,639 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - "context" - "errors" - "flag" - "fmt" - "io" - "log" - "mime" - "net/http" - "net/netip" - "os" - "path" - "path/filepath" - "strings" - "sync/atomic" - "time" - "unicode/utf8" - - "github.com/mattn/go-isatty" - "github.com/peterbourgon/ff/v3/ffcli" - "golang.org/x/time/rate" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/cmd/tailscale/cli/ffcomplete" - "tailscale.com/envknob" - "tailscale.com/net/tsaddr" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - tsrate "tailscale.com/tstime/rate" - "tailscale.com/util/quarantine" - "tailscale.com/util/truncate" - "tailscale.com/version" -) - -var fileCmd = &ffcli.Command{ - Name: "file", - ShortUsage: "tailscale file ...", - ShortHelp: "Send or receive files", - Subcommands: []*ffcli.Command{ - fileCpCmd, - fileGetCmd, - }, -} - -type countingReader struct { - io.Reader - n atomic.Int64 -} - -func (c *countingReader) Read(buf []byte) (int, error) { - n, err := c.Reader.Read(buf) - c.n.Add(int64(n)) - return n, err -} - -var fileCpCmd = &ffcli.Command{ - Name: "cp", - ShortUsage: "tailscale file cp :", - ShortHelp: "Copy file(s) to a host", - Exec: runCp, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("cp") - fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when is \"-\" (stdin)") - fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output") - fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets") - return fs - })(), -} - -var cpArgs struct { - name string - verbose bool - targets bool -} - -func runCp(ctx context.Context, args []string) error { - if cpArgs.targets { - return runCpTargets(ctx, args) - } - if len(args) < 2 { - return errors.New("usage: tailscale file cp :") - } - files, target := args[:len(args)-1], args[len(args)-1] - target, ok := strings.CutSuffix(target, ":") - if !ok { - return fmt.Errorf("final argument to 'tailscale file cp' must end in colon") - } - hadBrackets := false - if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") { - hadBrackets = true - target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]") - } - if ip, err := netip.ParseAddr(target); err == nil && ip.Is6() && !hadBrackets { - return fmt.Errorf("an IPv6 literal must be written as [%s]", ip) - } else if hadBrackets && (err != nil || !ip.Is6()) { - return errors.New("unexpected brackets around target") - } - ip, _, err := tailscaleIPFromArg(ctx, target) - if err != nil { - return err - } - - stableID, isOffline, err := getTargetStableID(ctx, ip) - if err != nil { - return fmt.Errorf("can't send to %s: %v", target, err) - } - if isOffline { - fmt.Fprintf(Stderr, "# warning: %s is offline\n", target) - } - - if len(files) > 1 { - if cpArgs.name != "" { - return errors.New("can't use --name= with multiple files") - } - for _, fileArg := range files { - if fileArg == "-" { - return errors.New("can't use '-' as STDIN file when providing filename arguments") - } - } - } - - for _, fileArg := range files { - var fileContents *countingReader - var name = cpArgs.name - var contentLength int64 = -1 - if fileArg == "-" { - fileContents = &countingReader{Reader: os.Stdin} - if name == "" { - name, fileContents, err = pickStdinFilename() - if err != nil { - return err - } - } - } else { - f, err := os.Open(fileArg) - if err != nil { - if version.IsSandboxedMacOS() { - return errors.New("the GUI version of Tailscale on macOS runs in a macOS sandbox that can't read files") - } - return err - } - defer f.Close() - fi, err := f.Stat() - if err != nil { - return err - } - if fi.IsDir() { - return errors.New("directories not supported") - } - contentLength = fi.Size() - fileContents = &countingReader{Reader: io.LimitReader(f, contentLength)} - if name == "" { - name = filepath.Base(fileArg) - } - - if envknob.Bool("TS_DEBUG_SLOW_PUSH") { - fileContents = &countingReader{Reader: &slowReader{r: fileContents}} - } - } - - if cpArgs.verbose { - log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID) - } - - var group syncs.WaitGroup - ctxProgress, cancelProgress := context.WithCancel(ctx) - defer cancelProgress() - if isatty.IsTerminal(os.Stderr.Fd()) { - group.Go(func() { progressPrinter(ctxProgress, name, fileContents.n.Load, contentLength) }) - } - - err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents) - cancelProgress() - group.Wait() // wait for progress printer to stop before reporting the error - if err != nil { - return err - } - if cpArgs.verbose { - log.Printf("sent %q", name) - } - } - return nil -} - -func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64) { - var rateValueFast, rateValueSlow tsrate.Value - rateValueFast.HalfLife = 1 * time.Second // fast response for rate measurement - rateValueSlow.HalfLife = 10 * time.Second // slow response for ETA measurement - var prevContentCount int64 - print := func() { - currContentCount := contentCount() - rateValueFast.Add(float64(currContentCount - prevContentCount)) - rateValueSlow.Add(float64(currContentCount - prevContentCount)) - prevContentCount = currContentCount - - const vtRestartLine = "\r\x1b[K" - fmt.Fprintf(os.Stderr, "%s%s %s %s", - vtRestartLine, - rightPad(name, 36), - leftPad(formatIEC(float64(currContentCount), "B"), len("1023.00MiB")), - leftPad(formatIEC(rateValueFast.Rate(), "B/s"), len("1023.00MiB/s"))) - if contentLength >= 0 { - currContentCount = min(currContentCount, contentLength) // cap at 100% - ratioRemain := float64(currContentCount) / float64(contentLength) - bytesRemain := float64(contentLength - currContentCount) - secsRemain := bytesRemain / rateValueSlow.Rate() - secs := int(min(max(0, secsRemain), 99*60*60+59+60+59)) - fmt.Fprintf(os.Stderr, " %s %s", - leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")), - fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60)) - } - } - - tc := time.NewTicker(250 * time.Millisecond) - defer tc.Stop() - print() - for { - select { - case <-ctx.Done(): - print() - fmt.Fprintln(os.Stderr) - return - case <-tc.C: - print() - } - } -} - -func leftPad(s string, n int) string { - s = truncateString(s, n) - return strings.Repeat(" ", max(n-len(s), 0)) + s -} - -func rightPad(s string, n int) string { - s = truncateString(s, n) - return s + strings.Repeat(" ", max(n-len(s), 0)) -} - -func truncateString(s string, n int) string { - if len(s) <= n { - return s - } - return truncate.String(s, max(n-1, 0)) + "…" -} - -func formatIEC(n float64, unit string) string { - switch { - case n < 1<<10: - return fmt.Sprintf("%0.2f%s", n/(1<<0), unit) - case n < 1<<20: - return fmt.Sprintf("%0.2fKi%s", n/(1<<10), unit) - case n < 1<<30: - return fmt.Sprintf("%0.2fMi%s", n/(1<<20), unit) - case n < 1<<40: - return fmt.Sprintf("%0.2fGi%s", n/(1<<30), unit) - default: - return fmt.Sprintf("%0.2fTi%s", n/(1<<40), unit) - } -} - -func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNodeID, isOffline bool, err error) { - ip, err := netip.ParseAddr(ipStr) - if err != nil { - return "", false, err - } - fts, err := localClient.FileTargets(ctx) - if err != nil { - return "", false, err - } - for _, ft := range fts { - n := ft.Node - for _, a := range n.Addresses { - if a.Addr() != ip { - continue - } - isOffline = n.Online != nil && !*n.Online - return n.StableID, isOffline, nil - } - } - return "", false, fileTargetErrorDetail(ctx, ip) -} - -// fileTargetErrorDetail returns a non-nil error saying why ip is an -// invalid file sharing target. -func fileTargetErrorDetail(ctx context.Context, ip netip.Addr) error { - found := false - if st, err := localClient.Status(ctx); err == nil && st.Self != nil { - for _, peer := range st.Peer { - for _, pip := range peer.TailscaleIPs { - if pip == ip { - found = true - if peer.UserID != st.Self.UserID { - return errors.New("owned by different user; can only send files to your own devices") - } - } - } - } - } - if found { - return errors.New("target seems to be running an old Tailscale version") - } - if !tsaddr.IsTailscaleIP(ip) { - return fmt.Errorf("unknown target; %v is not a Tailscale IP address", ip) - } - return errors.New("unknown target; not in your Tailnet") -} - -const maxSniff = 4 << 20 - -func ext(b []byte) string { - if len(b) < maxSniff && utf8.Valid(b) { - return ".txt" - } - if exts, _ := mime.ExtensionsByType(http.DetectContentType(b)); len(exts) > 0 { - return exts[0] - } - return "" -} - -// pickStdinFilename reads a bit of stdin to return a good filename -// for its contents. The returned Reader is the concatenation of the -// read and unread bits. -func pickStdinFilename() (name string, r *countingReader, err error) { - sniff, err := io.ReadAll(io.LimitReader(os.Stdin, maxSniff)) - if err != nil { - return "", nil, err - } - return "stdin" + ext(sniff), &countingReader{Reader: io.MultiReader(bytes.NewReader(sniff), os.Stdin)}, nil -} - -type slowReader struct { - r io.Reader - rl *rate.Limiter -} - -func (r *slowReader) Read(p []byte) (n int, err error) { - const burst = 4 << 10 - plen := len(p) - if plen > burst { - plen = burst - } - if r.rl == nil { - r.rl = rate.NewLimiter(rate.Limit(1<<10), burst) - } - n, err = r.r.Read(p[:plen]) - r.rl.WaitN(context.Background(), n) - return -} - -func runCpTargets(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("invalid arguments with --targets") - } - fts, err := localClient.FileTargets(ctx) - if err != nil { - return err - } - for _, ft := range fts { - n := ft.Node - var detail string - if n.Online != nil { - if !*n.Online { - detail = "offline" - } - } else { - detail = "unknown-status" - } - if detail != "" && n.LastSeen != nil { - d := time.Since(*n.LastSeen) - detail += fmt.Sprintf("; last seen %v ago", d.Round(time.Minute)) - } - if detail != "" { - detail = "\t" + detail - } - printf("%s\t%s%s\n", n.Addresses[0].Addr(), n.ComputedName, detail) - } - return nil -} - -// onConflict is a flag.Value for the --conflict flag's three string options. -type onConflict string - -const ( - skipOnExist onConflict = "skip" - overwriteExisting onConflict = "overwrite" // Overwrite any existing file at the target location - createNumberedFiles onConflict = "rename" // Create an alternately named file in the style of Chrome Downloads -) - -func (v *onConflict) String() string { return string(*v) } - -func (v *onConflict) Set(s string) error { - if s == "" { - *v = skipOnExist - return nil - } - *v = onConflict(strings.ToLower(s)) - if *v != skipOnExist && *v != overwriteExisting && *v != createNumberedFiles { - return fmt.Errorf("%q is not one of (skip|overwrite|rename)", s) - } - return nil -} - -var fileGetCmd = &ffcli.Command{ - Name: "get", - ShortUsage: "tailscale file get [--wait] [--verbose] [--conflict=(skip|overwrite|rename)] ", - ShortHelp: "Move files out of the Tailscale file inbox", - Exec: runFileGet, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("get") - fs.BoolVar(&getArgs.wait, "wait", false, "wait for a file to arrive if inbox is empty") - fs.BoolVar(&getArgs.loop, "loop", false, "run get in a loop, receiving files as they come in") - fs.BoolVar(&getArgs.verbose, "verbose", false, "verbose output") - fs.Var(&getArgs.conflict, "conflict", "`behavior`"+` when a conflicting (same-named) file already exists in the target directory. - skip: skip conflicting files: leave them in the taildrop inbox and print an error. get any non-conflicting files - overwrite: overwrite existing file - rename: write to a new number-suffixed filename`) - ffcomplete.Flag(fs, "conflict", ffcomplete.Fixed("skip", "overwrite", "rename")) - return fs - })(), -} - -var getArgs = struct { - wait bool - loop bool - verbose bool - conflict onConflict -}{conflict: skipOnExist} - -func numberedFileName(dir, name string, i int) string { - ext := path.Ext(name) - return filepath.Join(dir, fmt.Sprintf("%s (%d)%s", - strings.TrimSuffix(name, ext), - i, ext)) -} - -func openFileOrSubstitute(dir, base string, action onConflict) (*os.File, error) { - targetFile := filepath.Join(dir, base) - f, err := os.OpenFile(targetFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644) - if err == nil { - return f, nil - } - // Something went wrong trying to open targetFile as a new file for writing. - switch action { - default: - // This should not happen. - return nil, fmt.Errorf("file issue. how to resolve this conflict? no one knows.") - case skipOnExist: - if _, statErr := os.Stat(targetFile); statErr == nil { - // we can stat a file at that path: so it already exists. - return nil, fmt.Errorf("refusing to overwrite file: %w", err) - } - return nil, fmt.Errorf("failed to write; %w", err) - case overwriteExisting: - // remove the target file and create it anew so we don't fall for an - // attacker who symlinks a known target name to a file he wants changed. - if err = os.Remove(targetFile); err != nil { - return nil, fmt.Errorf("unable to remove target file: %w", err) - } - if f, err = os.OpenFile(targetFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644); err != nil { - return nil, fmt.Errorf("unable to overwrite: %w", err) - } - return f, nil - case createNumberedFiles: - // It's possible the target directory or filesystem isn't writable by us, - // not just that the target file(s) already exists. For now, give up after - // a limited number of attempts. In future, maybe distinguish this case - // and follow in the style of https://tinyurl.com/chromium100 - maxAttempts := 100 - for i := 1; i < maxAttempts; i++ { - if f, err = os.OpenFile(numberedFileName(dir, base, i), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644); err == nil { - return f, nil - } - } - return nil, fmt.Errorf("unable to find a name for writing %v, final attempt: %w", targetFile, err) - } -} - -func receiveFile(ctx context.Context, wf apitype.WaitingFile, dir string) (targetFile string, size int64, err error) { - rc, size, err := localClient.GetWaitingFile(ctx, wf.Name) - if err != nil { - return "", 0, fmt.Errorf("opening inbox file %q: %w", wf.Name, err) - } - defer rc.Close() - f, err := openFileOrSubstitute(dir, wf.Name, getArgs.conflict) - if err != nil { - return "", 0, err - } - // Apply quarantine attribute before copying - if err := quarantine.SetOnFile(f); err != nil { - return "", 0, fmt.Errorf("failed to apply quarantine attribute to file %v: %v", f.Name(), err) - } - _, err = io.Copy(f, rc) - if err != nil { - f.Close() - return "", 0, fmt.Errorf("failed to write %v: %v", f.Name(), err) - } - return f.Name(), size, f.Close() -} - -func runFileGetOneBatch(ctx context.Context, dir string) []error { - var wfs []apitype.WaitingFile - var err error - var errs []error - for len(errs) == 0 { - wfs, err = localClient.WaitingFiles(ctx) - if err != nil { - errs = append(errs, fmt.Errorf("getting WaitingFiles: %w", err)) - break - } - if len(wfs) != 0 || !(getArgs.wait || getArgs.loop) { - break - } - if getArgs.verbose { - printf("waiting for file...") - } - if err := waitForFile(ctx); err != nil { - errs = append(errs, err) - } - } - - deleted := 0 - for i, wf := range wfs { - if len(errs) > 100 { - // Likely, everything is broken. - // Don't try to receive any more files in this batch. - errs = append(errs, fmt.Errorf("too many errors in runFileGetOneBatch(). %d files unexamined", len(wfs)-i)) - break - } - writtenFile, size, err := receiveFile(ctx, wf, dir) - if err != nil { - errs = append(errs, err) - continue - } - if getArgs.verbose { - printf("wrote %v as %v (%d bytes)\n", wf.Name, writtenFile, size) - } - if err = localClient.DeleteWaitingFile(ctx, wf.Name); err != nil { - errs = append(errs, fmt.Errorf("deleting %q from inbox: %v", wf.Name, err)) - continue - } - deleted++ - } - if deleted == 0 && len(wfs) > 0 { - // persistently stuck files are basically an error - errs = append(errs, fmt.Errorf("moved %d/%d files", deleted, len(wfs))) - } else if getArgs.verbose { - printf("moved %d/%d files\n", deleted, len(wfs)) - } - return errs -} - -func runFileGet(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("usage: tailscale file get ") - } - log.SetFlags(0) - - dir := args[0] - if dir == "/dev/null" { - return wipeInbox(ctx) - } - - if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { - return fmt.Errorf("%q is not a directory", dir) - } - if getArgs.loop { - for { - errs := runFileGetOneBatch(ctx, dir) - for _, err := range errs { - outln(err) - } - if len(errs) > 0 { - // It's possible whatever caused the error(s) (e.g. conflicting target file, - // full disk, unwritable target directory) will re-occur if we try again so - // let's back off and not busy loop on error. - // - // If we've been invoked as: - // tailscale file get --conflict=skip ~/Downloads - // then any file coming in named the same as one in ~/Downloads will always - // appear as an "error" until the user clears it, but other incoming files - // should be receivable when they arrive, so let's not wait too long to - // check again. - time.Sleep(5 * time.Second) - } - } - } - errs := runFileGetOneBatch(ctx, dir) - if len(errs) == 0 { - return nil - } - for _, err := range errs[:len(errs)-1] { - outln(err) - } - return errs[len(errs)-1] -} - -func wipeInbox(ctx context.Context) error { - if getArgs.wait { - return errors.New("can't use --wait with /dev/null target") - } - wfs, err := localClient.WaitingFiles(ctx) - if err != nil { - return fmt.Errorf("getting WaitingFiles: %w", err) - } - deleted := 0 - for _, wf := range wfs { - if getArgs.verbose { - log.Printf("deleting %v ...", wf.Name) - } - if err := localClient.DeleteWaitingFile(ctx, wf.Name); err != nil { - return fmt.Errorf("deleting %q: %v", wf.Name, err) - } - deleted++ - } - if getArgs.verbose { - log.Printf("deleted %d files", deleted) - } - return nil -} - -func waitForFile(ctx context.Context) error { - for { - ff, err := localClient.AwaitWaitingFiles(ctx, time.Hour) - if len(ff) > 0 { - return nil - } - if err := ctx.Err(); err != nil { - return err - } - if err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { - return err - } - } -} diff --git a/cmd/tailscale/cli/funnel.go b/cmd/tailscale/cli/funnel.go deleted file mode 100644 index a95f9e27083b6..0000000000000 --- a/cmd/tailscale/cli/funnel.go +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "flag" - "fmt" - "net" - "strconv" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/ipn" - "tailscale.com/tailcfg" -) - -var funnelCmd = func() *ffcli.Command { - se := &serveEnv{lc: &localClient} - // previously used to serve legacy newFunnelCommand unless useWIPCode is true - // change is limited to make a revert easier and full cleanup to come after the relase. - // TODO(tylersmalley): cleanup and removal of newFunnelCommand as of 2023-10-16 - return newServeV2Command(se, funnel) -} - -// 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.Join([]string{ - "tailscale funnel {on|off}", - "tailscale funnel status [--json]", - }, "\n"), - 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, - Subcommands: []*ffcli.Command{ - { - Name: "status", - Exec: e.runServeStatus, - ShortUsage: "tailscale funnel status [--json]", - ShortHelp: "Show current serve/funnel status", - FlagSet: e.newFlags("funnel-status", func(fs *flag.FlagSet) { - fs.BoolVar(&e.json, "json", false, "output JSON") - }), - }, - }, - } -} - -// 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) - } - - port64, err := strconv.ParseUint(args[0], 10, 16) - if err != nil { - return err - } - port := uint16(port64) - - if on { - // Don't block from turning off existing Funnel if - // network configuration/capabilities have changed. - // Only block from starting new Funnels. - if err := e.verifyFunnelEnabled(ctx, port); err != nil { - return err - } - } - - st, err := e.getLocalClientStatusWithoutPeers(ctx) - if err != nil { - return fmt.Errorf("getting client status: %w", 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 - } - sc.SetFunnel(dnsName, port, on) - - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - printFunnelWarning(sc) - return nil -} - -// verifyFunnelEnabled verifies that the self node is allowed to use Funnel. -// -// If Funnel is not yet enabled by the current node capabilities, -// the user is sent through an interactive flow to enable the feature. -// Once enabled, verifyFunnelEnabled checks that the given port is allowed -// with Funnel. -// -// If an error is reported, the CLI should stop execution and return the error. -// -// verifyFunnelEnabled may refresh the local state and modify the st input. -func (e *serveEnv) verifyFunnelEnabled(ctx context.Context, port uint16) error { - enableErr := e.enableFeatureInteractive(ctx, "funnel", tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel) - st, statusErr := e.getLocalClientStatusWithoutPeers(ctx) // get updated status; interactive flow may block - switch { - case statusErr != nil: - return fmt.Errorf("getting client status: %w", statusErr) - case enableErr != nil: - // enableFeatureInteractive is a new flow behind a control server - // feature flag. If anything caused it to error, fallback to using - // the old CheckFunnelAccess call. Likely this domain does not have - // the feature flag on. - // TODO(sonia,tailscale/corp#10577): Remove this fallback once the - // control flag is turned on for all domains. - if err := ipn.CheckFunnelAccess(port, st.Self); err != nil { - return err - } - default: - // Done with enablement, make sure the requested port is allowed. - if err := ipn.CheckFunnelPort(port, st.Self); err != nil { - return err - } - } - 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(Stderr, "\nWarning: funnel=on for %s, but no serve config\n", hp) - } - } - if warn { - fmt.Fprintf(Stderr, " run: `tailscale serve --help` to see how to configure handlers\n") - } -} diff --git a/cmd/tailscale/cli/id-token.go b/cmd/tailscale/cli/id-token.go deleted file mode 100644 index a4d02c95a82c1..0000000000000 --- a/cmd/tailscale/cli/id-token.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/envknob" -) - -var idTokenCmd = &ffcli.Command{ - Name: "id-token", - ShortUsage: "tailscale id-token ", - ShortHelp: "Fetch an OIDC id-token for the Tailscale machine", - LongHelp: hidden, - Exec: runIDToken, -} - -func runIDToken(ctx context.Context, args []string) error { - if !envknob.UseWIPCode() { - return errors.New("tailscale id-token: works-in-progress require TAILSCALE_USE_WIP_CODE=1 envvar") - } - if len(args) != 1 { - return errors.New("usage: tailscale id-token ") - } - - tr, err := localClient.IDToken(ctx, args[0]) - if err != nil { - return err - } - - outln(tr.IDToken) - return nil -} diff --git a/cmd/tailscale/cli/ip.go b/cmd/tailscale/cli/ip.go deleted file mode 100644 index 8379329120436..0000000000000 --- a/cmd/tailscale/cli/ip.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "net/netip" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/ipn/ipnstate" -) - -var ipCmd = &ffcli.Command{ - Name: "ip", - ShortUsage: "tailscale ip [-1] [-4] [-6] [peer hostname or ip address]", - ShortHelp: "Show Tailscale IP addresses", - LongHelp: "Show Tailscale IP addresses for peer. Peer defaults to the current machine.", - Exec: runIP, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("ip") - fs.BoolVar(&ipArgs.want1, "1", false, "only print one IP address") - fs.BoolVar(&ipArgs.want4, "4", false, "only print IPv4 address") - fs.BoolVar(&ipArgs.want6, "6", false, "only print IPv6 address") - return fs - })(), -} - -var ipArgs struct { - want1 bool - want4 bool - want6 bool -} - -func runIP(ctx context.Context, args []string) error { - if len(args) > 1 { - return errors.New("too many arguments, expected at most one peer") - } - var of string - if len(args) == 1 { - of = args[0] - } - - v4, v6 := ipArgs.want4, ipArgs.want6 - nflags := 0 - for _, b := range []bool{ipArgs.want1, v4, v6} { - if b { - nflags++ - } - } - if nflags > 1 { - return errors.New("tailscale ip -1, -4, and -6 are mutually exclusive") - } - if !v4 && !v6 { - v4, v6 = true, true - } - st, err := localClient.Status(ctx) - if err != nil { - return err - } - ips := st.TailscaleIPs - if of != "" { - ip, _, err := tailscaleIPFromArg(ctx, of) - if err != nil { - return err - } - peer, ok := peerMatchingIP(st, ip) - if !ok { - return fmt.Errorf("no peer found with IP %v", ip) - } - ips = peer.TailscaleIPs - } - if len(ips) == 0 { - return fmt.Errorf("no current Tailscale IPs; state: %v", st.BackendState) - } - - if ipArgs.want1 { - ips = ips[:1] - } - match := false - for _, ip := range ips { - if ip.Is4() && v4 || ip.Is6() && v6 { - match = true - outln(ip) - } - } - if !match { - if ipArgs.want4 { - return errors.New("no Tailscale IPv4 address") - } - if ipArgs.want6 { - return errors.New("no Tailscale IPv6 address") - } - } - return nil -} - -func peerMatchingIP(st *ipnstate.Status, ipStr string) (ps *ipnstate.PeerStatus, ok bool) { - ip, err := netip.ParseAddr(ipStr) - if err != nil { - return - } - for _, ps = range st.Peer { - for _, pip := range ps.TailscaleIPs { - if ip == pip { - return ps, true - } - } - } - if ps := st.Self; ps != nil { - for _, pip := range ps.TailscaleIPs { - if ip == pip { - return ps, true - } - } - } - return nil, false -} diff --git a/cmd/tailscale/cli/licenses.go b/cmd/tailscale/cli/licenses.go deleted file mode 100644 index bede827edf693..0000000000000 --- a/cmd/tailscale/cli/licenses.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/licenses" -) - -var licensesCmd = &ffcli.Command{ - Name: "licenses", - ShortUsage: "tailscale licenses", - ShortHelp: "Get open source license information", - LongHelp: "Get open source license information", - Exec: runLicenses, -} - -func runLicenses(ctx context.Context, args []string) error { - url := licenses.LicensesURL() - outln(` -Tailscale wouldn't be possible without the contributions of thousands of open -source developers. To see the open source packages included in Tailscale and -their respective license information, visit: - - ` + url) - return nil -} diff --git a/cmd/tailscale/cli/login.go b/cmd/tailscale/cli/login.go deleted file mode 100644 index fb5b786920660..0000000000000 --- a/cmd/tailscale/cli/login.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "flag" - - "github.com/peterbourgon/ff/v3/ffcli" -) - -var loginArgs upArgsT - -var loginCmd = &ffcli.Command{ - Name: "login", - ShortUsage: "tailscale login [flags]", - ShortHelp: "Log in to a Tailscale account", - LongHelp: `"tailscale login" logs this machine in to your Tailscale network. -This command is currently in alpha and may change in the future.`, - FlagSet: func() *flag.FlagSet { - return newUpFlagSet(effectiveGOOS(), &loginArgs, "login") - }(), - Exec: func(ctx context.Context, args []string) error { - if err := localClient.SwitchToEmptyProfile(ctx); err != nil { - return err - } - return runUp(ctx, "login", args, loginArgs) - }, -} diff --git a/cmd/tailscale/cli/logout.go b/cmd/tailscale/cli/logout.go deleted file mode 100644 index 0c2007a66ab1b..0000000000000 --- a/cmd/tailscale/cli/logout.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "fmt" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" -) - -var logoutCmd = &ffcli.Command{ - Name: "logout", - ShortUsage: "tailscale logout", - ShortHelp: "Disconnect from Tailscale and expire current node key", - - LongHelp: strings.TrimSpace(` -"tailscale logout" brings the network down and invalidates -the current node key, forcing a future use of it to cause -a reauthentication. -`), - Exec: runLogout, -} - -func runLogout(ctx context.Context, args []string) error { - if len(args) > 0 { - return fmt.Errorf("too many non-flag arguments: %q", args) - } - return localClient.Logout(ctx) -} diff --git a/cmd/tailscale/cli/metrics.go b/cmd/tailscale/cli/metrics.go deleted file mode 100644 index d5fe9ad81cb70..0000000000000 --- a/cmd/tailscale/cli/metrics.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/atomicfile" -) - -var metricsCmd = &ffcli.Command{ - Name: "metrics", - ShortHelp: "Show Tailscale metrics", - LongHelp: strings.TrimSpace(` - -The 'tailscale metrics' command shows Tailscale user-facing metrics (as opposed -to internal metrics printed by 'tailscale debug metrics'). - -For more information about Tailscale metrics, refer to -https://tailscale.com/s/client-metrics - -`), - ShortUsage: "tailscale metrics [flags]", - UsageFunc: usageFuncNoDefaultValues, - Exec: runMetricsNoSubcommand, - Subcommands: []*ffcli.Command{ - { - Name: "print", - ShortUsage: "tailscale metrics print", - Exec: runMetricsPrint, - ShortHelp: "Prints current metric values in the Prometheus text exposition format", - }, - { - Name: "write", - ShortUsage: "tailscale metrics write ", - Exec: runMetricsWrite, - ShortHelp: "Writes metric values to a file", - LongHelp: strings.TrimSpace(` - -The 'tailscale metrics write' command writes metric values to a text file provided as its -only argument. It's meant to be used alongside Prometheus node exporter, allowing Tailscale -metrics to be consumed and exported by the textfile collector. - -As an example, to export Tailscale metrics on an Ubuntu system running node exporter, you -can regularly run 'tailscale metrics write /var/lib/prometheus/node-exporter/tailscaled.prom' -using cron or a systemd timer. - - `), - }, - }, -} - -// runMetricsNoSubcommand prints metric values if no subcommand is specified. -func runMetricsNoSubcommand(ctx context.Context, args []string) error { - if len(args) > 0 { - return fmt.Errorf("tailscale metrics: unknown subcommand: %s", args[0]) - } - - return runMetricsPrint(ctx, args) -} - -// runMetricsPrint prints metric values to stdout. -func runMetricsPrint(ctx context.Context, args []string) error { - out, err := localClient.UserMetrics(ctx) - if err != nil { - return err - } - Stdout.Write(out) - return nil -} - -// runMetricsWrite writes metric values to a file. -func runMetricsWrite(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("usage: tailscale metrics write ") - } - path := args[0] - out, err := localClient.UserMetrics(ctx) - if err != nil { - return err - } - return atomicfile.WriteFile(path, out, 0644) -} diff --git a/cmd/tailscale/cli/nc.go b/cmd/tailscale/cli/nc.go deleted file mode 100644 index 4ea62255412ea..0000000000000 --- a/cmd/tailscale/cli/nc.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "strconv" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/cmd/tailscale/cli/ffcomplete" -) - -var ncCmd = &ffcli.Command{ - Name: "nc", - ShortUsage: "tailscale nc ", - ShortHelp: "Connect to a port on a host, connected to stdin/stdout", - Exec: runNC, -} - -func init() { - ffcomplete.Args(ncCmd, func(args []string) ([]string, ffcomplete.ShellCompDirective, error) { - if len(args) > 1 { - return nil, ffcomplete.ShellCompDirectiveNoFileComp, nil - } - return completeHostOrIP(ffcomplete.LastArg(args)) - }) -} - -func completeHostOrIP(arg string) ([]string, ffcomplete.ShellCompDirective, error) { - st, err := localClient.Status(context.Background()) - if err != nil { - return nil, 0, err - } - nodes := make([]string, 0, len(st.Peer)) - for _, node := range st.Peer { - nodes = append(nodes, strings.TrimSuffix(node.DNSName, ".")) - } - return nodes, ffcomplete.ShellCompDirectiveNoFileComp, nil -} - -func runNC(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) != 2 { - return errors.New("usage: tailscale nc ") - } - - hostOrIP, portStr := args[0], args[1] - port, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - return fmt.Errorf("invalid port number %q", portStr) - } - - // TODO(bradfitz): also add UDP too, via flag? - c, err := localClient.DialTCP(ctx, hostOrIP, uint16(port)) - if err != nil { - return fmt.Errorf("Dial(%q, %v): %w", hostOrIP, port, err) - } - defer c.Close() - errc := make(chan error, 1) - go func() { - _, err := io.Copy(os.Stdout, c) - errc <- err - }() - go func() { - _, err := io.Copy(c, os.Stdin) - errc <- err - }() - return <-errc -} diff --git a/cmd/tailscale/cli/netcheck.go b/cmd/tailscale/cli/netcheck.go deleted file mode 100644 index 312475eced978..0000000000000 --- a/cmd/tailscale/cli/netcheck.go +++ /dev/null @@ -1,245 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "io" - "log" - "net/http" - "sort" - "strings" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/net/netcheck" - "tailscale.com/net/netmon" - "tailscale.com/net/portmapper" - "tailscale.com/net/tlsdial" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" -) - -var netcheckCmd = &ffcli.Command{ - Name: "netcheck", - ShortUsage: "tailscale netcheck", - ShortHelp: "Print an analysis of local network conditions", - Exec: runNetcheck, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("netcheck") - fs.StringVar(&netcheckArgs.format, "format", "", `output format; empty (for human-readable), "json" or "json-line"`) - fs.DurationVar(&netcheckArgs.every, "every", 0, "if non-zero, do an incremental report with the given frequency") - fs.BoolVar(&netcheckArgs.verbose, "verbose", false, "verbose logs") - return fs - })(), -} - -var netcheckArgs struct { - format string - every time.Duration - verbose bool -} - -func runNetcheck(ctx context.Context, args []string) error { - logf := logger.WithPrefix(log.Printf, "portmap: ") - netMon, err := netmon.New(logf) - if err != nil { - return err - } - - // Ensure that we close the portmapper after running a netcheck; this - // will release any port mappings created. - pm := portmapper.NewClient(logf, netMon, nil, nil, nil) - defer pm.Close() - - c := &netcheck.Client{ - NetMon: netMon, - PortMapper: pm, - UseDNSCache: false, // always resolve, don't cache - } - if netcheckArgs.verbose { - c.Logf = logger.WithPrefix(log.Printf, "netcheck: ") - c.Verbose = true - } else { - c.Logf = logger.Discard - } - - if strings.HasPrefix(netcheckArgs.format, "json") { - fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface") - } - - if err := c.Standalone(ctx, envknob.String("TS_DEBUG_NETCHECK_UDP_BIND")); err != nil { - fmt.Fprintln(Stderr, "netcheck: UDP test failure:", err) - } - - dm, err := localClient.CurrentDERPMap(ctx) - noRegions := dm != nil && len(dm.Regions) == 0 - if noRegions { - log.Printf("No DERP map from tailscaled; using default.") - } - if err != nil || noRegions { - hc := &http.Client{ - Transport: tlsdial.NewTransport(), - Timeout: 10 * time.Second, - } - dm, err = prodDERPMap(ctx, hc) - if err != nil { - log.Println("Failed to fetch a DERP map, so netcheck cannot continue. Check your Internet connection.") - return err - } - } - for { - t0 := time.Now() - report, err := c.GetReport(ctx, dm, nil) - d := time.Since(t0) - if netcheckArgs.verbose { - c.Logf("GetReport took %v; err=%v", d.Round(time.Millisecond), err) - } - if err != nil { - return fmt.Errorf("netcheck: %w", err) - } - if err := printReport(dm, report); err != nil { - return err - } - if netcheckArgs.every == 0 { - return nil - } - time.Sleep(netcheckArgs.every) - } -} - -func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error { - var j []byte - var err error - switch netcheckArgs.format { - case "": - case "json": - j, err = json.MarshalIndent(report, "", "\t") - case "json-line": - j, err = json.Marshal(report) - default: - return fmt.Errorf("unknown output format %q", netcheckArgs.format) - } - if err != nil { - return err - } - if j != nil { - j = append(j, '\n') - Stdout.Write(j) - return nil - } - - printf("\nReport:\n") - printf("\t* Time: %v\n", report.Now.Format(time.RFC3339Nano)) - printf("\t* UDP: %v\n", report.UDP) - if report.GlobalV4.IsValid() { - printf("\t* IPv4: yes, %s\n", report.GlobalV4) - } else { - printf("\t* IPv4: (no addr found)\n") - } - if report.GlobalV6.IsValid() { - printf("\t* IPv6: yes, %s\n", report.GlobalV6) - } else if report.IPv6 { - printf("\t* IPv6: (no addr found)\n") - } else if report.OSHasIPv6 { - printf("\t* IPv6: no, but OS has support\n") - } else { - printf("\t* IPv6: no, unavailable in OS\n") - } - printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP) - printf("\t* PortMapping: %v\n", portMapping(report)) - if report.CaptivePortal != "" { - printf("\t* CaptivePortal: %v\n", report.CaptivePortal) - } - - // When DERP latency checking failed, - // magicsock will try to pick the DERP server that - // most of your other nodes are also using - if len(report.RegionLatency) == 0 { - printf("\t* Nearest DERP: unknown (no response to latency probes)\n") - } else { - if report.PreferredDERP != 0 { - printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName) - } else { - printf("\t* Nearest DERP: [none]\n") - } - printf("\t* DERP latency:\n") - var rids []int - for rid := range dm.Regions { - rids = append(rids, rid) - } - sort.Slice(rids, func(i, j int) bool { - l1, ok1 := report.RegionLatency[rids[i]] - l2, ok2 := report.RegionLatency[rids[j]] - if ok1 != ok2 { - return ok1 // defined things sort first - } - if !ok1 { - return rids[i] < rids[j] - } - return l1 < l2 - }) - for _, rid := range rids { - d, ok := report.RegionLatency[rid] - var latency string - if ok { - latency = d.Round(time.Millisecond / 10).String() - } - r := dm.Regions[rid] - var derpNum string - if netcheckArgs.verbose { - derpNum = fmt.Sprintf("derp%d, ", rid) - } - printf("\t\t- %3s: %-7s (%s%s)\n", r.RegionCode, latency, derpNum, r.RegionName) - } - } - return nil -} - -func portMapping(r *netcheck.Report) string { - if !r.AnyPortMappingChecked() { - return "not checked" - } - var got []string - if r.UPnP.EqualBool(true) { - got = append(got, "UPnP") - } - if r.PMP.EqualBool(true) { - got = append(got, "NAT-PMP") - } - if r.PCP.EqualBool(true) { - got = append(got, "PCP") - } - return strings.Join(got, ", ") -} - -func prodDERPMap(ctx context.Context, httpc *http.Client) (*tailcfg.DERPMap, error) { - log.Printf("attempting to fetch a DERPMap from %s", ipn.DefaultControlURL) - req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil) - if err != nil { - return nil, fmt.Errorf("create prodDERPMap request: %w", err) - } - res, err := httpc.Do(req) - if err != nil { - return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err) - } - defer res.Body.Close() - b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) - if err != nil { - return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err) - } - if res.StatusCode != 200 { - return nil, fmt.Errorf("fetch prodDERPMap: %v: %s", res.Status, b) - } - var derpMap tailcfg.DERPMap - if err = json.Unmarshal(b, &derpMap); err != nil { - return nil, fmt.Errorf("fetch prodDERPMap: %w", err) - } - return &derpMap, nil -} diff --git a/cmd/tailscale/cli/network-lock.go b/cmd/tailscale/cli/network-lock.go deleted file mode 100644 index 45f989f1057a7..0000000000000 --- a/cmd/tailscale/cli/network-lock.go +++ /dev/null @@ -1,853 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "flag" - "fmt" - "os" - "strconv" - "strings" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tka" - "tailscale.com/tsconst" - "tailscale.com/types/key" - "tailscale.com/types/tkatype" -) - -var netlockCmd = &ffcli.Command{ - Name: "lock", - ShortUsage: "tailscale lock [arguments...]", - ShortHelp: "Manage tailnet lock", - LongHelp: "Manage tailnet lock", - Subcommands: []*ffcli.Command{ - nlInitCmd, - nlStatusCmd, - nlAddCmd, - nlRemoveCmd, - nlSignCmd, - nlDisableCmd, - nlDisablementKDFCmd, - nlLogCmd, - nlLocalDisableCmd, - nlRevokeKeysCmd, - }, - 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:]) - } - if len(args) > 0 { - return fmt.Errorf("tailscale lock: unknown subcommand: %s", args[0]) - } - - return runNetworkLockStatus(ctx, args) -} - -var nlInitArgs struct { - numDisablements int - disablementForSupport bool - confirm bool -} - -var nlInitCmd = &ffcli.Command{ - Name: "init", - ShortUsage: "tailscale lock init [--gen-disablement-for-support] --gen-disablements N ...", - ShortHelp: "Initialize tailnet lock", - LongHelp: strings.TrimSpace(` - -The 'tailscale lock init' command initializes tailnet lock for the -entire tailnet. The tailnet lock keys specified are those initially -trusted to sign nodes or to make further changes to tailnet lock. - -You can identify the tailnet lock key for a node you wish to trust by -running 'tailscale lock' on that node, and copying the node's tailnet -lock key. - -To disable tailnet lock, use the 'tailscale lock disable' command -along with one of the disablement secrets. -The number of disablement secrets to be generated is specified using the ---gen-disablements flag. Initializing tailnet lock requires at least -one disablement. - -If --gen-disablement-for-support is specified, an additional disablement secret -will be generated and transmitted to Tailscale, which support can use to disable -tailnet lock. We recommend setting this flag. - -`), - Exec: runNetworkLockInit, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("lock init") - fs.IntVar(&nlInitArgs.numDisablements, "gen-disablements", 1, "number of disablement secrets to generate") - fs.BoolVar(&nlInitArgs.disablementForSupport, "gen-disablement-for-support", false, "generates and transmits a disablement secret for Tailscale support") - fs.BoolVar(&nlInitArgs.confirm, "confirm", false, "do not prompt for confirmation") - return fs - })(), -} - -func runNetworkLockInit(ctx context.Context, args []string) error { - st, err := localClient.NetworkLockStatus(ctx) - if err != nil { - return fixTailscaledConnectError(err) - } - if st.Enabled { - return errors.New("tailnet lock is already enabled") - } - - // Parse initially-trusted keys & disablement values. - keys, disablementValues, err := parseNLArgs(args, true, true) - if err != nil { - return err - } - - // Common mistake: Not specifying the current node's key as one of the trusted keys. - foundSelfKey := false - for _, k := range keys { - keyID, err := k.ID() - if err != nil { - return err - } - if bytes.Equal(keyID, st.PublicKey.KeyID()) { - foundSelfKey = true - break - } - } - if !foundSelfKey { - return errors.New("the tailnet lock key of the current node must be one of the trusted keys during initialization") - } - - fmt.Println("You are initializing tailnet lock with the following trusted signing keys:") - for _, k := range keys { - fmt.Printf(" - tlpub:%x (%s key)\n", k.Public, k.Kind.String()) - } - fmt.Println() - - if !nlInitArgs.confirm { - fmt.Printf("%d disablement secrets will be generated.\n", nlInitArgs.numDisablements) - if nlInitArgs.disablementForSupport { - fmt.Println("A disablement secret will be generated and transmitted to Tailscale support.") - } - - genSupportFlag := "" - if nlInitArgs.disablementForSupport { - genSupportFlag = "--gen-disablement-for-support " - } - fmt.Println("\nIf this is correct, please re-run this command with the --confirm flag:") - fmt.Printf("\t%s lock init --confirm --gen-disablements %d %s%s", os.Args[0], nlInitArgs.numDisablements, genSupportFlag, strings.Join(args, " ")) - fmt.Println() - return nil - } - - var successMsg strings.Builder - - fmt.Fprintf(&successMsg, "%d disablement secrets have been generated and are printed below. Take note of them now, they WILL NOT be shown again.\n", nlInitArgs.numDisablements) - for range nlInitArgs.numDisablements { - var secret [32]byte - if _, err := rand.Read(secret[:]); err != nil { - return err - } - fmt.Fprintf(&successMsg, "\tdisablement-secret:%X\n", secret[:]) - disablementValues = append(disablementValues, tka.DisablementKDF(secret[:])) - } - - var supportDisablement []byte - if nlInitArgs.disablementForSupport { - supportDisablement = make([]byte, 32) - if _, err := rand.Read(supportDisablement); err != nil { - return err - } - disablementValues = append(disablementValues, tka.DisablementKDF(supportDisablement)) - fmt.Fprintln(&successMsg, "A disablement secret for Tailscale support has been generated and transmitted to Tailscale.") - } - - // The state returned by NetworkLockInit likely doesn't contain the initialized state, - // because that has to tick through from netmaps. - if _, err := localClient.NetworkLockInit(ctx, keys, disablementValues, supportDisablement); err != nil { - return err - } - - fmt.Print(successMsg.String()) - fmt.Println("Initialization complete.") - return nil -} - -var nlStatusArgs struct { - json bool -} - -var nlStatusCmd = &ffcli.Command{ - Name: "status", - ShortUsage: "tailscale lock status", - ShortHelp: "Outputs the state of tailnet lock", - LongHelp: "Outputs the state of tailnet lock", - Exec: runNetworkLockStatus, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("lock status") - fs.BoolVar(&nlStatusArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") - return fs - })(), -} - -func runNetworkLockStatus(ctx context.Context, args []string) error { - if len(args) > 0 { - return fmt.Errorf("tailscale lock status: unexpected argument") - } - - st, err := localClient.NetworkLockStatus(ctx) - if err != nil { - return fixTailscaledConnectError(err) - } - - if nlStatusArgs.json { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(st) - } - - if st.Enabled { - fmt.Println("Tailnet lock is ENABLED.") - } else { - fmt.Println("Tailnet lock is NOT enabled.") - } - fmt.Println() - - if st.Enabled && st.NodeKey != nil && !st.PublicKey.IsZero() { - if st.NodeKeySigned { - fmt.Println("This node is accessible under tailnet lock. Node signature:") - fmt.Println(st.NodeKeySignature.String()) - } else { - fmt.Println("This node is LOCKED OUT by tailnet-lock, and action is required to establish connectivity.") - fmt.Printf("Run the following command on a node with a trusted key:\n\ttailscale lock sign %v %s\n", st.NodeKey, st.PublicKey.CLIString()) - } - fmt.Println() - } - - if !st.PublicKey.IsZero() { - fmt.Printf("This node's tailnet-lock key: %s\n", st.PublicKey.CLIString()) - fmt.Println() - } - - if st.Enabled && len(st.TrustedKeys) > 0 { - fmt.Println("Trusted signing keys:") - for _, k := range st.TrustedKeys { - var line strings.Builder - line.WriteString("\t") - line.WriteString(k.Key.CLIString()) - line.WriteString("\t") - line.WriteString(fmt.Sprint(k.Votes)) - line.WriteString("\t") - 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()) - } - } - - if st.Enabled && len(st.FilteredPeers) > 0 { - fmt.Println() - fmt.Println("The following nodes are locked out by tailnet lock and cannot connect to other nodes:") - for _, p := range st.FilteredPeers { - var line strings.Builder - line.WriteString("\t") - line.WriteString(p.Name) - line.WriteString("\t") - for i, addr := range p.TailscaleIPs { - line.WriteString(addr.String()) - if i < len(p.TailscaleIPs)-1 { - line.WriteString(",") - } - } - line.WriteString("\t") - line.WriteString(string(p.StableID)) - line.WriteString("\t") - line.WriteString(p.NodeKey.String()) - fmt.Println(line.String()) - } - } - - return nil -} - -var nlAddCmd = &ffcli.Command{ - Name: "add", - ShortUsage: "tailscale lock add ...", - ShortHelp: "Adds one or more trusted signing keys to tailnet lock", - LongHelp: "Adds one or more trusted signing keys to tailnet lock", - Exec: func(ctx context.Context, args []string) error { - return runNetworkLockModify(ctx, args, nil) - }, -} - -var nlRemoveArgs struct { - resign bool -} - -var nlRemoveCmd = &ffcli.Command{ - Name: "remove", - ShortUsage: "tailscale lock 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: 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 -// values/secrets. -// The keys encoded in args should be specified using their key.NLPublic.MarshalText -// representation with an optional '?' suffix. -// Disablement values or secrets must be encoded in hex with a prefix of 'disablement:' or -// 'disablement-secret:'. -// -// If any element could not be parsed, -// a nil slice is returned along with an appropriate error. -func parseNLArgs(args []string, parseKeys, parseDisablements bool) (keys []tka.Key, disablements [][]byte, err error) { - for i, a := range args { - if parseDisablements && (strings.HasPrefix(a, "disablement:") || strings.HasPrefix(a, "disablement-secret:")) { - b, err := hex.DecodeString(a[strings.Index(a, ":")+1:]) - if err != nil { - return nil, nil, fmt.Errorf("parsing disablement %d: %v", i+1, err) - } - disablements = append(disablements, b) - continue - } - - if !parseKeys { - return nil, nil, fmt.Errorf("parsing argument %d: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", i+1, a) - } - - var nlpk key.NLPublic - spl := strings.SplitN(a, "?", 2) - if err := nlpk.UnmarshalText([]byte(spl[0])); err != nil { - return nil, nil, fmt.Errorf("parsing key %d: %v", i+1, err) - } - - k := tka.Key{ - Kind: tka.Key25519, - Public: nlpk.Verifier(), - Votes: 1, - } - if len(spl) > 1 { - votes, err := strconv.Atoi(spl[1]) - if err != nil { - return nil, nil, fmt.Errorf("parsing key %d votes: %v", i+1, err) - } - k.Votes = uint(votes) - } - keys = append(keys, k) - } - return keys, disablements, nil -} - -func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) error { - st, err := localClient.NetworkLockStatus(ctx) - if err != nil { - return fixTailscaledConnectError(err) - } - if !st.Enabled { - return errors.New("tailnet lock is not enabled") - } - - addKeys, _, err := parseNLArgs(addArgs, true, false) - if err != nil { - return err - } - removeKeys, _, err := parseNLArgs(removeArgs, true, false) - if err != nil { - return err - } - - if err := localClient.NetworkLockModify(ctx, addKeys, removeKeys); err != nil { - return err - } - return nil -} - -var nlSignCmd = &ffcli.Command{ - Name: "sign", - ShortUsage: "tailscale lock sign []\ntailscale lock 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 - -If any of the key arguments begin with "file:", the key is retrieved from -the file at the path specified in the argument suffix.`, - Exec: runNetworkLockSign, -} - -func runNetworkLockSign(ctx context.Context, args []string) error { - // If any of the arguments start with "file:", replace that argument - // with the contents of the file. We do this early, before the check - // to see if the first argument is an auth key. - for i, arg := range args { - if filename, ok := strings.CutPrefix(arg, "file:"); ok { - b, err := os.ReadFile(filename) - if err != nil { - return err - } - args[i] = strings.TrimSpace(string(b)) - } - } - - if len(args) > 0 && strings.HasPrefix(args[0], "tskey-auth-") { - return runTskeyWrapCmd(ctx, args) - } - - var ( - nodeKey key.NodePublic - rotationKey key.NLPublic - ) - - if len(args) == 0 || len(args) > 2 { - return errors.New("usage: tailscale lock sign []") - } - if err := nodeKey.UnmarshalText([]byte(args[0])); err != nil { - return fmt.Errorf("decoding node-key: %w", err) - } - if len(args) > 1 { - if err := rotationKey.UnmarshalText([]byte(args[1])); err != nil { - return fmt.Errorf("decoding rotation-key: %w", err) - } - } - - err := localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier())) - // Provide a better help message for when someone clicks through the signing flow - // on the wrong device. - if err != nil && strings.Contains(err.Error(), tsconst.TailnetLockNotTrustedMsg) { - fmt.Fprintln(Stderr, "Error: Signing is not available on this device because it does not have a trusted tailnet lock key.") - fmt.Fprintln(Stderr) - fmt.Fprintln(Stderr, "Try again on a signing device instead. Tailnet admins can see signing devices on the admin panel.") - fmt.Fprintln(Stderr) - } - return err -} - -var nlDisableCmd = &ffcli.Command{ - Name: "disable", - ShortUsage: "tailscale lock disable ", - ShortHelp: "Consumes a disablement secret to shut down tailnet lock for the tailnet", - LongHelp: strings.TrimSpace(` - -The 'tailscale lock disable' command uses the specified disablement -secret to disable tailnet lock. - -If tailnet lock is re-enabled, new disablement secrets can be generated. - -Once this secret is used, it has been distributed -to all nodes in the tailnet and should be considered public. - -`), - Exec: runNetworkLockDisable, -} - -func runNetworkLockDisable(ctx context.Context, args []string) error { - _, secrets, err := parseNLArgs(args, false, true) - if err != nil { - return err - } - if len(secrets) != 1 { - return errors.New("usage: tailscale lock disable ") - } - return localClient.NetworkLockDisable(ctx, secrets[0]) -} - -var nlLocalDisableCmd = &ffcli.Command{ - Name: "local-disable", - ShortUsage: "tailscale lock local-disable", - ShortHelp: "Disables tailnet lock for this node only", - LongHelp: strings.TrimSpace(` - -The 'tailscale lock local-disable' command disables tailnet lock for only -the current node. - -If the current node is locked out, this does not mean that it can initiate -connections in a tailnet with tailnet lock enabled. Rather, this means -that the current node will accept traffic from other nodes in the tailnet -that are locked out. - -`), - Exec: runNetworkLockLocalDisable, -} - -func runNetworkLockLocalDisable(ctx context.Context, args []string) error { - return localClient.NetworkLockForceLocalDisable(ctx) -} - -var nlDisablementKDFCmd = &ffcli.Command{ - Name: "disablement-kdf", - ShortUsage: "tailscale lock disablement-kdf ", - ShortHelp: "Computes a disablement value from a disablement secret (advanced users only)", - LongHelp: "Computes a disablement value from a disablement secret (advanced users only)", - Exec: runNetworkLockDisablementKDF, -} - -func runNetworkLockDisablementKDF(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("usage: tailscale lock disablement-kdf ") - } - secret, err := hex.DecodeString(args[0]) - if err != nil { - return err - } - fmt.Printf("disablement:%x\n", tka.DisablementKDF(secret)) - return nil -} - -var nlLogArgs struct { - limit int - json bool -} - -var nlLogCmd = &ffcli.Command{ - Name: "log", - ShortUsage: "tailscale lock log [--limit N]", - ShortHelp: "List changes applied to tailnet lock", - LongHelp: "List changes applied to tailnet lock", - Exec: runNetworkLockLog, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("lock log") - fs.IntVar(&nlLogArgs.limit, "limit", 50, "max number of updates to list") - fs.BoolVar(&nlLogArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") - return fs - })(), -} - -func nlDescribeUpdate(update ipnstate.NetworkLockUpdate, color bool) (string, error) { - terminalYellow := "" - terminalClear := "" - if color { - terminalYellow = "\x1b[33m" - terminalClear = "\x1b[0m" - } - - var stanza strings.Builder - printKey := func(key *tka.Key, prefix string) { - fmt.Fprintf(&stanza, "%sType: %s\n", prefix, key.Kind.String()) - if keyID, err := key.ID(); err == nil { - fmt.Fprintf(&stanza, "%sKeyID: %x\n", prefix, keyID) - } else { - // Older versions of the client shouldn't explode when they encounter an - // unknown key type. - fmt.Fprintf(&stanza, "%sKeyID: \n", prefix, err) - } - if key.Meta != nil { - fmt.Fprintf(&stanza, "%sMetadata: %+v\n", prefix, key.Meta) - } - } - - var aum tka.AUM - if err := aum.Unserialize(update.Raw); err != nil { - return "", fmt.Errorf("decoding: %w", err) - } - - fmt.Fprintf(&stanza, "%supdate %x (%s)%s\n", terminalYellow, update.Hash, update.Change, terminalClear) - - switch update.Change { - case tka.AUMAddKey.String(): - printKey(aum.Key, "") - case tka.AUMRemoveKey.String(): - fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID) - - case tka.AUMUpdateKey.String(): - fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID) - if aum.Votes != nil { - fmt.Fprintf(&stanza, "Votes: %d\n", aum.Votes) - } - if aum.Meta != nil { - fmt.Fprintf(&stanza, "Metadata: %+v\n", aum.Meta) - } - - case tka.AUMCheckpoint.String(): - fmt.Fprintln(&stanza, "Disablement values:") - for _, v := range aum.State.DisablementSecrets { - fmt.Fprintf(&stanza, " - %x\n", v) - } - fmt.Fprintln(&stanza, "Keys:") - for _, k := range aum.State.Keys { - printKey(&k, " ") - } - - default: - // Print a JSON encoding of the AUM as a fallback. - e := json.NewEncoder(&stanza) - e.SetIndent("", "\t") - if err := e.Encode(aum); err != nil { - return "", err - } - stanza.WriteRune('\n') - } - - return stanza.String(), nil -} - -func runNetworkLockLog(ctx context.Context, args []string) error { - updates, err := localClient.NetworkLockLog(ctx, nlLogArgs.limit) - if err != nil { - return fixTailscaledConnectError(err) - } - if nlLogArgs.json { - enc := json.NewEncoder(Stdout) - enc.SetIndent("", " ") - return enc.Encode(updates) - } - - out, useColor := colorableOutput() - - for _, update := range updates { - stanza, err := nlDescribeUpdate(update, useColor) - if err != nil { - return err - } - fmt.Fprintln(out, stanza) - } - 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 -} - -var nlRevokeKeysArgs struct { - cosign bool - finish bool - forkFrom string -} - -var nlRevokeKeysCmd = &ffcli.Command{ - Name: "revoke-keys", - ShortUsage: "tailscale lock revoke-keys ...\n revoke-keys [--cosign] [--finish] ", - ShortHelp: "Revoke compromised tailnet-lock keys", - LongHelp: `Retroactively revoke the specified tailnet lock keys (tlpub:abc). - -Revoked keys are prevented from being used in the future. Any nodes previously signed -by revoked keys lose their authorization and must be signed again. - -Revocation is a multi-step process that requires several signing nodes to ` + "`--cosign`" + ` the revocation. Use ` + "`tailscale lock remove`" + ` instead if the key has not been compromised. - -1. To start, run ` + "`tailscale revoke-keys `" + ` with the tailnet lock keys to revoke. -2. Re-run the ` + "`--cosign`" + ` command output by ` + "`revoke-keys`" + ` on other signing nodes. Use the - most recent command output on the next signing node in sequence. -3. Once the number of ` + "`--cosign`" + `s is greater than the number of keys being revoked, - run the command one final time with ` + "`--finish`" + ` instead of ` + "`--cosign`" + `.`, - Exec: runNetworkLockRevokeKeys, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("lock revoke-keys") - fs.BoolVar(&nlRevokeKeysArgs.cosign, "cosign", false, "continue generating the recovery using the tailnet lock key on this device and the provided recovery blob") - fs.BoolVar(&nlRevokeKeysArgs.finish, "finish", false, "finish the recovery process by transmitting the revocation") - fs.StringVar(&nlRevokeKeysArgs.forkFrom, "fork-from", "", "parent AUM hash to rewrite from (advanced users only)") - return fs - })(), -} - -func runNetworkLockRevokeKeys(ctx context.Context, args []string) error { - // First step in the process - if !nlRevokeKeysArgs.cosign && !nlRevokeKeysArgs.finish { - removeKeys, _, err := parseNLArgs(args, true, false) - if err != nil { - return err - } - - keyIDs := make([]tkatype.KeyID, len(removeKeys)) - for i, k := range removeKeys { - keyIDs[i], err = k.ID() - if err != nil { - return fmt.Errorf("generating keyID: %v", err) - } - } - - var forkFrom tka.AUMHash - if nlRevokeKeysArgs.forkFrom != "" { - if len(nlRevokeKeysArgs.forkFrom) == (len(forkFrom) * 2) { - // Hex-encoded: like the output of the lock log command. - b, err := hex.DecodeString(nlRevokeKeysArgs.forkFrom) - if err != nil { - return fmt.Errorf("invalid fork-from hash: %v", err) - } - copy(forkFrom[:], b) - } else { - if err := forkFrom.UnmarshalText([]byte(nlRevokeKeysArgs.forkFrom)); err != nil { - return fmt.Errorf("invalid fork-from hash: %v", err) - } - } - } - - aumBytes, err := localClient.NetworkLockGenRecoveryAUM(ctx, keyIDs, forkFrom) - if err != nil { - return fmt.Errorf("generation of recovery AUM failed: %w", err) - } - - fmt.Printf(`Run the following command on another machine with a trusted tailnet lock key: - %s lock revoke-keys --cosign %X -`, os.Args[0], aumBytes) - return nil - } - - // If we got this far, we need to co-sign the AUM and/or transmit it for distribution. - b, err := hex.DecodeString(args[0]) - if err != nil { - return fmt.Errorf("parsing hex: %v", err) - } - var recoveryAUM tka.AUM - if err := recoveryAUM.Unserialize(b); err != nil { - return fmt.Errorf("decoding recovery AUM: %v", err) - } - - if nlRevokeKeysArgs.cosign { - aumBytes, err := localClient.NetworkLockCosignRecoveryAUM(ctx, recoveryAUM) - if err != nil { - return fmt.Errorf("co-signing recovery AUM failed: %w", err) - } - - fmt.Printf(`Co-signing completed successfully. - -To accumulate an additional signature, run the following command on another machine with a trusted tailnet lock key: - %s lock revoke-keys --cosign %X - -Alternatively if you are done with co-signing, complete recovery by running the following command: - %s lock revoke-keys --finish %X -`, os.Args[0], aumBytes, os.Args[0], aumBytes) - } - - if nlRevokeKeysArgs.finish { - if err := localClient.NetworkLockSubmitRecoveryAUM(ctx, recoveryAUM); err != nil { - return fmt.Errorf("submitting recovery AUM failed: %w", err) - } - fmt.Println("Recovery completed.") - } - - return nil -} diff --git a/cmd/tailscale/cli/ping.go b/cmd/tailscale/cli/ping.go deleted file mode 100644 index 3a909f30dee86..0000000000000 --- a/cmd/tailscale/cli/ping.go +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "log" - "net" - "net/netip" - "os" - "strings" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" - "tailscale.com/cmd/tailscale/cli/ffcomplete" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" -) - -var pingCmd = &ffcli.Command{ - Name: "ping", - ShortUsage: "tailscale ping ", - ShortHelp: "Ping a host at the Tailscale layer, see how it routed", - LongHelp: strings.TrimSpace(` - -The 'tailscale ping' command pings a peer node from the Tailscale layer -and reports which route it took for each response. The first ping or -so will likely go over DERP (Tailscale's TCP relay protocol) while NAT -traversal finds a direct path through. - -If 'tailscale ping' works but a normal ping does not, that means one -side's operating system firewall is blocking packets; 'tailscale ping' -does not inject packets into either side's TUN devices. - -By default, 'tailscale ping' stops after 10 pings or once a direct -(non-DERP) path has been established, whichever comes first. - -The provided hostname must resolve to or be a Tailscale IP -(e.g. 100.x.y.z) or a subnet IP advertised by a Tailscale -relay node. - -`), - Exec: runPing, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("ping") - fs.BoolVar(&pingArgs.verbose, "verbose", false, "verbose output") - fs.BoolVar(&pingArgs.untilDirect, "until-direct", true, "stop once a direct path is established") - fs.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through WireGuard, but not either host OS stack)") - fs.BoolVar(&pingArgs.icmp, "icmp", false, "do a ICMP-level ping (through WireGuard, but not the local host OS stack)") - fs.BoolVar(&pingArgs.peerAPI, "peerapi", false, "try hitting the peer's peerapi HTTP server") - fs.IntVar(&pingArgs.num, "c", 10, "max number of pings to send. 0 for infinity.") - fs.DurationVar(&pingArgs.timeout, "timeout", 5*time.Second, "timeout before giving up on a ping") - fs.IntVar(&pingArgs.size, "size", 0, "size of the ping message (disco pings only). 0 for minimum size.") - return fs - })(), -} - -func init() { - ffcomplete.Args(pingCmd, func(args []string) ([]string, ffcomplete.ShellCompDirective, error) { - if len(args) > 1 { - return nil, ffcomplete.ShellCompDirectiveNoFileComp, nil - } - return completeHostOrIP(ffcomplete.LastArg(args)) - }) -} - -var pingArgs struct { - num int - size int - untilDirect bool - verbose bool - tsmp bool - icmp bool - peerAPI bool - timeout time.Duration -} - -func pingType() tailcfg.PingType { - if pingArgs.tsmp { - return tailcfg.PingTSMP - } - if pingArgs.icmp { - return tailcfg.PingICMP - } - if pingArgs.peerAPI { - return tailcfg.PingPeerAPI - } - return tailcfg.PingDisco -} - -func runPing(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: tailscale ping ") - } - 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 pingArgs.verbose && ip != hostOrIP { - log.Printf("lookup %q => %q", hostOrIP, ip) - } - - n := 0 - anyPong := false - for { - n++ - ctx, cancel := context.WithTimeout(ctx, pingArgs.timeout) - pr, err := localClient.PingWithOpts(ctx, netip.MustParseAddr(ip), pingType(), tailscale.PingOpts{Size: pingArgs.size}) - cancel() - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - printf("ping %q timed out\n", ip) - if n == pingArgs.num { - if !anyPong { - return errors.New("no reply") - } - return nil - } - continue - } - return err - } - if pr.Err != "" { - if pr.IsLocalIP { - outln(pr.Err) - return nil - } - return errors.New(pr.Err) - } - latency := time.Duration(pr.LatencySeconds * float64(time.Second)).Round(time.Millisecond) - via := pr.Endpoint - if pr.DERPRegionID != 0 { - via = fmt.Sprintf("DERP(%s)", pr.DERPRegionCode) - } - if via == "" { - // TODO(bradfitz): populate the rest of ipnstate.PingResult for TSMP queries? - // For now just say which protocol it used. - via = string(pingType()) - } - if pingArgs.peerAPI { - printf("hit peerapi of %s (%s) at %s in %s\n", pr.NodeIP, pr.NodeName, pr.PeerAPIURL, latency) - return nil - } - anyPong = true - extra := "" - if pr.PeerAPIPort != 0 { - extra = fmt.Sprintf(", %d", pr.PeerAPIPort) - } - printf("pong from %s (%s%s) via %v in %v\n", pr.NodeName, pr.NodeIP, extra, via, latency) - if pingArgs.tsmp || pingArgs.icmp { - return nil - } - if pr.Endpoint != "" && pingArgs.untilDirect { - return nil - } - time.Sleep(time.Second) - - if n == pingArgs.num { - if !anyPong { - return errors.New("no reply") - } - if pingArgs.untilDirect { - return errors.New("direct connection not established") - } - return nil - } - } -} - -func tailscaleIPFromArg(ctx context.Context, hostOrIP string) (ip string, self bool, err error) { - // If the argument is an IP address, use it directly without any resolution. - if net.ParseIP(hostOrIP) != nil { - return hostOrIP, false, nil - } - - // Otherwise, try to resolve it first from the network peer list. - st, err := localClient.Status(ctx) - if err != nil { - return "", false, err - } - match := func(ps *ipnstate.PeerStatus) bool { - return strings.EqualFold(hostOrIP, dnsOrQuoteHostname(st, ps)) || hostOrIP == ps.DNSName - } - for _, ps := range st.Peer { - if match(ps) { - if len(ps.TailscaleIPs) == 0 { - return "", false, errors.New("node found but lacks an IP") - } - return ps.TailscaleIPs[0].String(), false, nil - } - } - if match(st.Self) && len(st.Self.TailscaleIPs) > 0 { - return st.Self.TailscaleIPs[0].String(), true, nil - } - - // Finally, use DNS. - var res net.Resolver - if addrs, err := res.LookupHost(ctx, hostOrIP); err != nil { - return "", false, fmt.Errorf("error looking up IP of %q: %v", hostOrIP, err) - } else if len(addrs) == 0 { - return "", false, fmt.Errorf("no IPs found for %q", hostOrIP) - } else { - return addrs[0], false, nil - } -} diff --git a/cmd/tailscale/cli/risks.go b/cmd/tailscale/cli/risks.go deleted file mode 100644 index acb50e723c585..0000000000000 --- a/cmd/tailscale/cli/risks.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "errors" - "flag" - "fmt" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "tailscale.com/util/testenv" -) - -var ( - riskTypes []string - riskLoseSSH = registerRiskType("lose-ssh") - riskMacAppConnector = registerRiskType("mac-app-connector") - riskAll = registerRiskType("all") -) - -const riskMacAppConnectorMessage = ` -You are trying to configure an app connector on macOS, which is not officially supported due to system limitations. This may result in performance and reliability issues. - -Do not use a macOS app connector for any mission-critical purposes. For the best experience, Linux is the only recommended platform for app connectors. -` - -func registerRiskType(riskType string) string { - riskTypes = append(riskTypes, riskType) - return riskType -} - -// registerAcceptRiskFlag registers the --accept-risk flag. Accepted risks are accounted for -// in presentRiskToUser. -func registerAcceptRiskFlag(f *flag.FlagSet, acceptedRisks *string) { - f.StringVar(acceptedRisks, "accept-risk", "", "accept risk and skip confirmation for risk types: "+strings.Join(riskTypes, ",")) -} - -// isRiskAccepted reports whether riskType is in the comma-separated list of -// risks in acceptedRisks. -func isRiskAccepted(riskType, acceptedRisks string) bool { - for _, r := range strings.Split(acceptedRisks, ",") { - if r == riskType || r == riskAll { - return true - } - } - return false -} - -var errAborted = errors.New("aborted, no changes made") - -// riskAbortTimeSeconds is the number of seconds to wait after displaying the -// risk message before continuing with the operation. -// It is used by the presentRiskToUser function below. -const riskAbortTimeSeconds = 5 - -// presentRiskToUser displays the risk message and waits for the user to cancel. -// It returns errorAborted if the user aborts. In tests it returns errAborted -// immediately unless the risk has been explicitly accepted. -func presentRiskToUser(riskType, riskMessage, acceptedRisks string) error { - if isRiskAccepted(riskType, acceptedRisks) { - return nil - } - if testenv.InTest() { - return errAborted - } - outln(riskMessage) - printf("To skip this warning, use --accept-risk=%s\n", riskType) - - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, syscall.SIGINT) - var msgLen int - for left := riskAbortTimeSeconds; left > 0; left-- { - msg := fmt.Sprintf("\rContinuing in %d seconds...", left) - msgLen = len(msg) - printf(msg) - select { - case <-interrupt: - printf("\r%s\r", strings.Repeat("x", msgLen+1)) - return errAborted - case <-time.After(time.Second): - continue - } - } - printf("\r%s\r", strings.Repeat(" ", msgLen)) - return errAborted -} diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go deleted file mode 100644 index 443a404abcbf7..0000000000000 --- a/cmd/tailscale/cli/serve_legacy.go +++ /dev/null @@ -1,857 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "net" - "net/url" - "os" - "path" - "path/filepath" - "reflect" - "runtime" - "sort" - "strconv" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/version" -) - -var serveCmd = func() *ffcli.Command { - se := &serveEnv{lc: &localClient} - // previously used to serve legacy newFunnelCommand unless useWIPCode is true - // change is limited to make a revert easier and full cleanup to come after the relase. - // TODO(tylersmalley): cleanup and removal of newServeLegacyCommand as of 2023-10-16 - return newServeV2Command(se, serve) -} - -// newServeLegacyCommand returns a new "serve" subcommand using e as its environment. -func newServeLegacyCommand(e *serveEnv) *ffcli.Command { - return &ffcli.Command{ - Name: "serve", - ShortHelp: "Serve content and local servers", - ShortUsage: strings.Join([]string{ - "tailscale serve http: [off]", - "tailscale serve https: [off]", - "tailscale serve tcp: tcp://localhost: [off]", - "tailscale serve tls-terminated-tcp: tcp://localhost: [off]", - "tailscale serve status [--json]", - "tailscale serve reset", - }, "\n"), - LongHelp: strings.TrimSpace(` -*** 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. - -You can also choose to enable the Tailscale Funnel with: -'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 https:443 / http://127.0.0.1:3000 - - Or, using the default port (443): - $ tailscale serve https / http://127.0.0.1:3000 - - - To serve a single file or a directory of files: - $ tailscale serve https / /home/alice/blog/index.html - $ tailscale serve https /images/ /home/alice/blog/images - - - To serve simple static text: - $ tailscale serve https:8080 / text:"Hello, world!" - - - To serve over HTTP (tailnet only): - $ tailscale serve http:80 / http://127.0.0.1:3000 - - Or, using the default port (80): - $ tailscale serve http / http://127.0.0.1:3000 - - - 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, - Subcommands: []*ffcli.Command{ - { - Name: "status", - Exec: e.runServeStatus, - ShortHelp: "Show current serve/funnel status", - FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) { - fs.BoolVar(&e.json, "json", false, "output JSON") - }), - }, - { - Name: "reset", - Exec: e.runServeReset, - ShortHelp: "Reset current serve/funnel config", - FlagSet: e.newFlags("serve-reset", nil), - }, - }, - } -} - -// errHelp is standard error text that prompts users to -// run `serve --help` for information on how to use serve. -var errHelp = errors.New("try `tailscale serve --help` for usage info") - -func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.FlagSet { - onError, out := flag.ExitOnError, Stderr - if e.testFlagOut != nil { - onError, out = flag.ContinueOnError, e.testFlagOut - } - fs := flag.NewFlagSet(name, onError) - fs.SetOutput(out) - if setup != nil { - setup(fs) - } - return fs -} - -// localServeClient is an interface conforming to the subset of -// tailscale.LocalClient. It includes only the methods used by the -// serve command. -// -// The purpose of this interface is to allow tests to provide a mock. -type localServeClient interface { - StatusWithoutPeers(context.Context) (*ipnstate.Status, error) - GetServeConfig(context.Context) (*ipn.ServeConfig, error) - SetServeConfig(context.Context, *ipn.ServeConfig) error - QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) - WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error) - IncrementCounter(ctx context.Context, name string, delta int) error -} - -// serveEnv is the environment the serve command runs within. All I/O should be -// done via serveEnv methods so that it can be faked out for tests. -// Calls to localClient should be done via the lc field, which is an interface -// that can be faked out for tests. -// -// It also contains the flags, as registered with newServeCommand. -type serveEnv struct { - // v1 flags - json bool // output JSON (status only for now) - - // v2 specific flags - bg bool // background mode - setPath string // serve path - https uint // HTTP port - http uint // HTTP port - tcp uint // TCP port - tlsTerminatedTCP uint // a TLS terminated TCP port - subcmd serveMode // subcommand - yes bool // update without prompt - - lc localServeClient // localClient interface, specific to serve - - // optional stuff for tests: - testFlagOut io.Writer - testStdout io.Writer - testStderr io.Writer -} - -// getSelfDNSName returns the DNS name of the current node. -// The trailing dot is removed. -// Returns an error if local client status fails. -func (e *serveEnv) getSelfDNSName(ctx context.Context) (string, error) { - st, err := e.getLocalClientStatusWithoutPeers(ctx) - if err != nil { - return "", fmt.Errorf("getting client status: %w", err) - } - return strings.TrimSuffix(st.Self.DNSName, "."), nil -} - -// getLocalClientStatusWithoutPeers returns the Status of the local client -// without any peers in the response. -// -// Returns error if unable to reach tailscaled or if self node is nil. -// -// Exits if status is not running or starting. -func (e *serveEnv) getLocalClientStatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { - st, err := e.lc.StatusWithoutPeers(ctx) - if err != nil { - return nil, fixTailscaledConnectError(err) - } - description, ok := isRunningOrStarting(st) - if !ok { - fmt.Fprintf(Stderr, "%s\n", description) - os.Exit(1) - } - if st.Self == nil { - return nil, errors.New("no self node") - } - return st, nil -} - -// runServe is the entry point for the "serve" subcommand, managing Web -// serve config types like proxy, path, and text. -// -// Examples: -// - tailscale serve http / http://localhost:3000 -// - 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 - } - - // Undocumented debug command (not using ffcli subcommands) to set raw - // configs from stdin for now (2022-11-13). - if len(args) == 1 && args[0] == "set-raw" { - valb, err := io.ReadAll(os.Stdin) - if err != nil { - return err - } - sc := new(ipn.ServeConfig) - if err := json.Unmarshal(valb, sc); err != nil { - return fmt.Errorf("invalid JSON: %w", err) - } - return e.lc.SetServeConfig(ctx, sc) - } - - srcType, srcPortStr, found := strings.Cut(args[0], ":") - if !found { - if srcType == "https" && srcPortStr == "" { - // Default https port to 443. - srcPortStr = "443" - } else if srcType == "http" && srcPortStr == "" { - // Default http port to 80. - srcPortStr = "80" - } else { - return flag.ErrHelp - } - } - - turnOff := "off" == args[len(args)-1] - - if len(args) < 2 || ((srcType == "https" || srcType == "http") && !turnOff && len(args) < 3) { - fmt.Fprintf(Stderr, "error: invalid number of arguments\n\n") - return errHelp - } - - if srcType == "https" && !turnOff { - // Running serve with https requires that the tailnet has enabled - // https cert provisioning. Send users through an interactive flow - // to enable this if not already done. - // - // TODO(sonia,tailscale/corp#10577): The interactive feature flow - // is behind a control flag. If the tailnet doesn't have the flag - // on, enableFeatureInteractive will error. For now, we hide that - // error and maintain the previous behavior (prior to 2023-08-15) - // of letting them edit the serve config before enabling certs. - e.enableFeatureInteractive(ctx, "serve", tailcfg.CapabilityHTTPS) - } - - srcPort, err := parseServePort(srcPortStr) - if err != nil { - return fmt.Errorf("invalid port %q: %w", srcPortStr, err) - } - - switch srcType { - case "https", "http": - mount, err := cleanMountPoint(args[1]) - if err != nil { - return err - } - if turnOff { - return e.handleWebServeRemove(ctx, srcPort, mount) - } - useTLS := srcType == "https" - return e.handleWebServe(ctx, srcPort, useTLS, 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(Stderr, "error: invalid serve type %q\n", srcType) - fmt.Fprint(Stderr, "must be one of: http:, https:, tcp: or tls-terminated-tcp:\n\n", srcType) - return errHelp - } -} - -// handleWebServe handles the "tailscale serve (http/https):..." subcommand. It -// configures the serve config to forward HTTPS connections to the given source. -// -// Examples: -// - tailscale serve http / http://localhost:3000 -// - 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, useTLS bool, mount, source string) error { - h := new(ipn.HTTPHandler) - - 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(source) { - fmt.Fprintf(Stderr, "error: path must be absolute\n\n") - return errHelp - } - source = filepath.Clean(source) - fi, err := os.Stat(source) - if err != nil { - fmt.Fprintf(Stderr, "error: invalid path: %v\n\n", err) - return errHelp - } - if fi.IsDir() && !strings.HasSuffix(mount, "/") { - // dir mount points must end in / - // for relative file links to work - mount += "/" - } - h.Path = source - } - - 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) - } - dnsName, err := e.getSelfDNSName(ctx) - if err != nil { - return err - } - if sc.IsTCPForwardingOnPort(srvPort) { - fmt.Fprintf(Stderr, "error: cannot serve web; already serving TCP\n") - return errHelp - } - - sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS) - - if !reflect.DeepEqual(cursc, sc) { - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - } - - return nil -} - -// 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 := range len(s) { - if s[i] < '0' || s[i] > '9' { - return false - } - } - 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 - } - if sc == nil { - return errors.New("error: serve config does not exist") - } - dnsName, err := e.getSelfDNSName(ctx) - if err != nil { - return err - } - if sc.IsTCPForwardingOnPort(srvPort) { - return errors.New("cannot remove web handler; currently serving TCP") - } - hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) - if !sc.WebHandlerExists(hp, mount) { - return errors.New("error: handler does not exist") - } - sc.RemoveWebHandler(dnsName, srvPort, []string{mount}, false) - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - return nil -} - -func cleanMountPoint(mount string) (string, error) { - if mount == "" { - return "", errors.New("mount point cannot be empty") - } - mount = cleanMinGWPathConversionIfNeeded(mount) - if !strings.HasPrefix(mount, "/") { - mount = "/" + mount - } - c := path.Clean(mount) - if mount == c || mount == c+"/" { - return mount, nil - } - return "", fmt.Errorf("invalid mount point %q", mount) -} - -// cleanMinGWPathConversionIfNeeded strips the EXEPATH prefix from the given -// path if the path is a MinGW(ish) (Windows) shell arg. -// -// MinGW(ish) (Windows) shells perform POSIX-to-Windows path conversion -// converting the leading "/" of any shell arg to the EXEPATH, which mangles the -// mount point. Strip the EXEPATH prefix if it exists. #7963 -// -// "/C:/Program Files/Git/foo" -> "/foo" -func cleanMinGWPathConversionIfNeeded(path string) string { - // Only do this on Windows. - if runtime.GOOS != "windows" { - return path - } - if _, ok := os.LookupEnv("MSYSTEM"); ok { - exepath := filepath.ToSlash(os.Getenv("EXEPATH")) - path = strings.TrimPrefix(path, exepath) - } - return path -} - -func expandProxyTarget(source string) (string, error) { - if !strings.Contains(source, "://") { - source = "http://" + source - } - u, err := url.ParseRequestURI(source) - if err != nil { - return "", fmt.Errorf("parsing url: %w", err) - } - switch u.Scheme { - case "http", "https", "https+insecure": - // ok - 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 { - case "localhost", "127.0.0.1": - host = "127.0.0.1" - default: - return "", fmt.Errorf("only localhost or 127.0.0.1 proxies are currently supported") - } - url := u.Scheme + "://" + host - if u.Port() != "" { - url += ":" + u.Port() - } - url += u.Path - return url, nil -} - -// 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(Stderr, "error: invalid TCP source %q\n\n", dest) - return errHelp - } - - dstURL, err := url.Parse(dest) - if err != nil { - fmt.Fprintf(Stderr, "error: invalid TCP source %q: %v\n\n", dest, err) - return errHelp - } - host, dstPortStr, err := net.SplitHostPort(dstURL.Host) - if err != nil { - fmt.Fprintf(Stderr, "error: invalid TCP source %q: %v\n\n", dest, err) - return errHelp - } - - switch host { - case "localhost", "127.0.0.1": - // ok - default: - fmt.Fprintf(Stderr, "error: invalid TCP source %q\n", dest) - fmt.Fprint(Stderr, "must be one of: localhost or 127.0.0.1\n\n", dest) - return errHelp - } - - if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil { - fmt.Fprintf(Stderr, "error: invalid port %q\n\n", dstPortStr) - return 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) - } - - dnsName, err := e.getSelfDNSName(ctx) - if err != nil { - return err - } - - sc.SetTCPForwarding(srcPort, fwdAddr, terminateTLS, dnsName) - - if !reflect.DeepEqual(cursc, sc) { - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - } - - return nil -} - -// 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 { - sc.RemoveTCPForwarding(src) - 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 -// - tailscale status --json -// -// TODO(tyler,marwan,sonia): `status` should also report foreground configs, -// currently only reports background config. -func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { - sc, err := e.lc.GetServeConfig(ctx) - if err != nil { - return err - } - if e.json { - j, err := json.MarshalIndent(sc, "", " ") - if err != nil { - return err - } - j = append(j, '\n') - 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 - } - st, err := e.getLocalClientStatusWithoutPeers(ctx) - if err != nil { - return err - } - if sc.IsTCPForwardingAny() { - if err := printTCPStatusTree(ctx, sc, st); err != nil { - return err - } - printf("\n") - } - for hp := range sc.Web { - err := e.printWebStatusTree(sc, hp) - if err != nil { - return err - } - printf("\n") - } - printFunnelWarning(sc) - return nil -} - -func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status) error { - dnsName := strings.TrimSuffix(st.Self.DNSName, ".") - for p, h := range sc.TCP { - if h.TCPForward == "" { - continue - } - hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(p)))) - tlsStatus := "TLS over TCP" - if h.TerminateTLS != "" { - tlsStatus = "TLS terminated" - } - fStatus := "tailnet only" - if sc.AllowFunnel[hp] { - fStatus = "Funnel on" - } - printf("|-- tcp://%s (%s, %s)\n", hp, tlsStatus, fStatus) - for _, a := range st.TailscaleIPs { - ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p))) - printf("|-- tcp://%s\n", ipp) - } - printf("|--> tcp://%s\n", h.TCPForward) - } - return nil -} - -func (e *serveEnv) printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) error { - // No-op if no serve config - if sc == nil { - return nil - } - fStatus := "tailnet only" - if sc.AllowFunnel[hp] { - fStatus = "Funnel on" - } - host, portStr, _ := net.SplitHostPort(string(hp)) - - port, err := parseServePort(portStr) - if err != nil { - return fmt.Errorf("invalid port %q: %w", portStr, err) - } - - scheme := "https" - if sc.IsServingHTTP(port) { - scheme = "http" - } - - portPart := ":" + portStr - if scheme == "http" && portStr == "80" || - scheme == "https" && portStr == "443" { - portPart = "" - } - if scheme == "http" { - hostname, _, _ := strings.Cut(host, ".") - printf("%s://%s%s (%s)\n", scheme, hostname, portPart, fStatus) - } - printf("%s://%s%s (%s)\n", scheme, host, portPart, fStatus) - srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) { - switch { - case h.Path != "": - return "path", h.Path - case h.Proxy != "": - return "proxy", h.Proxy - case h.Text != "": - return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\"" - } - return "", "" - } - - var mounts []string - for k := range sc.Web[hp].Handlers { - mounts = append(mounts, k) - } - sort.Slice(mounts, func(i, j int) bool { - return len(mounts[i]) < len(mounts[j]) - }) - maxLen := len(mounts[len(mounts)-1]) - - for _, m := range mounts { - h := sc.Web[hp].Handlers[m] - t, d := srvTypeAndDesc(h) - printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d) - } - - return nil -} - -func elipticallyTruncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max-3] + "..." -} - -// runServeReset clears out the current serve config. -// -// Usage: -// - tailscale serve reset -func (e *serveEnv) runServeReset(ctx context.Context, args []string) error { - if len(args) != 0 { - return flag.ErrHelp - } - sc := new(ipn.ServeConfig) - return e.lc.SetServeConfig(ctx, sc) -} - -// parseServePort parses a port number from a string and returns it as a -// uint16. It returns an error if the port number is invalid or zero. -func parseServePort(s string) (uint16, error) { - p, err := strconv.ParseUint(s, 10, 16) - if err != nil { - return 0, err - } - if p == 0 { - return 0, errors.New("port number must be non-zero") - } - return uint16(p), nil -} - -// enableFeatureInteractive sends the node's user through an interactive -// flow to enable a feature, such as Funnel, on their tailnet. -// -// hasRequiredCapabilities should be provided as a function that checks -// whether a slice of node capabilities encloses the necessary values -// needed to use the feature. -// -// If err is returned empty, the feature has been successfully enabled. -// -// If err is returned non-empty, the client failed to query the control -// server for information about how to enable the feature. -// -// If the feature cannot be enabled, enableFeatureInteractive terminates -// the CLI process. -// -// 2023-08-09: The only valid feature values are "serve" and "funnel". -// This can be moved to some CLI lib when expanded past serve/funnel. -func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string, caps ...tailcfg.NodeCapability) (err error) { - st, err := e.getLocalClientStatusWithoutPeers(ctx) - if err != nil { - return fmt.Errorf("getting client status: %w", err) - } - if st.Self == nil { - return errors.New("no self node") - } - hasCaps := func() bool { - for _, c := range caps { - if !st.Self.HasCap(c) { - return false - } - } - return true - } - if hasCaps() { - return nil // already enabled - } - info, err := e.lc.QueryFeature(ctx, feature) - if err != nil { - return err - } - if info.Complete { - return nil // already enabled - } - if info.Text != "" { - fmt.Fprintln(Stdout, "\n"+info.Text) - } - if info.URL != "" { - fmt.Fprintln(Stdout, "\n "+info.URL+"\n") - } - if !info.ShouldWait { - e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_not_awaiting_enablement", feature), 1) - // The feature has not been enabled yet, - // but the CLI should not block on user action. - // Once info.Text is printed, exit the CLI. - os.Exit(0) - } - e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_awaiting_enablement", feature), 1) - // Block until feature is enabled. - watchCtx, cancelWatch := context.WithCancel(ctx) - defer cancelWatch() - watcher, err := e.lc.WatchIPNBus(watchCtx, 0) - if err != nil { - // If we fail to connect to the IPN notification bus, - // don't block. We still present the URL in the CLI, - // then close the process. Swallow the error. - log.Fatalf("lost connection to tailscaled: %v", err) - e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1) - return err - } - defer watcher.Close() - for { - n, err := watcher.Next() - if err != nil { - // Stop blocking if we error. - // Let the user finish enablement then rerun their - // command themselves. - log.Fatalf("lost connection to tailscaled: %v", err) - e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1) - return err - } - if nm := n.NetMap; nm != nil && nm.SelfNode.Valid() { - gotAll := true - for _, c := range caps { - if !nm.SelfNode.HasCap(c) { - // The feature is not yet enabled. - // Continue blocking until it is. - gotAll = false - break - } - } - if gotAll { - e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enabled", feature), 1) - fmt.Fprintln(Stdout, "Success.") - return nil - } - } - } -} diff --git a/cmd/tailscale/cli/serve_legacy_test.go b/cmd/tailscale/cli/serve_legacy_test.go deleted file mode 100644 index 2eb982ca0a1f7..0000000000000 --- a/cmd/tailscale/cli/serve_legacy_test.go +++ /dev/null @@ -1,942 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "reflect" - "runtime" - "strings" - "testing" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/types/logger" -) - -func TestCleanMountPoint(t *testing.T) { - tests := []struct { - mount string - want string - wantErr bool - }{ - {"foo", "/foo", false}, // missing prefix - {"/foo/", "/foo/", false}, // keep trailing slash - {"////foo", "", true}, // too many slashes - {"/foo//", "", true}, // too many slashes - {"", "", true}, // empty - {"https://tailscale.com", "", true}, // not a path - } - for _, tt := range tests { - mp, err := cleanMountPoint(tt.mount) - if err != nil && tt.wantErr { - continue - } - if err != nil { - t.Fatal(err) - } - - if mp != tt.want { - t.Fatalf("got %q, want %q", mp, tt.want) - } - } -} - -func TestServeConfigMutations(t *testing.T) { - tstest.Replace(t, &Stderr, io.Discard) - tstest.Replace(t, &Stdout, io.Discard) - - // Stateful mutations, starting from an empty config. - type step struct { - command []string // serve args; nil means no command to run (only reset) - reset bool // if true, reset all ServeConfig state - 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) { - _, _, s.line, _ = runtime.Caller(1) - steps = append(steps, s) - } - - // funnel - add(step{reset: true}) - add(step{ - command: cmd("funnel 443 on"), - want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}}, - }) - add(step{ - command: cmd("funnel 443 on"), - want: nil, // nothing to save - }) - add(step{ - command: cmd("funnel 443 off"), - want: &ipn.ServeConfig{}, - }) - add(step{ - command: cmd("funnel 443 off"), - want: nil, // nothing to save - }) - add(step{ - command: cmd("funnel"), - wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), - }) - - // https - add(step{reset: true}) - add(step{ // allow omitting port (default to 80) - command: cmd("http / http://localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - }, - }) - add(step{ // support non Funnel port - command: cmd("http:9999 /abc http://localhost:3001"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {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("http:9999 /abc off"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - }, - }) - add(step{ - command: cmd("http:8080 /abc http://127.0.0.1:3001"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - "foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }) - - // https - add(step{reset: true}) - add(step{ - command: cmd("https:443 / http://localhost:0"), // invalid port, too low - wantErr: anyErr(), - }) - add(step{ - command: cmd("https:443 / http://localhost:65536"), // invalid port, too high - wantErr: anyErr(), - }) - add(step{ - command: cmd("https:443 / http://somehost:3000"), // invalid host - wantErr: anyErr(), - }) - add(step{ - command: cmd("https:443 / 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("https:9999 /abc off"), - 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{ - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }) - add(step{ - command: cmd("https:10000 / text:hi"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {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:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://127.0.0.1:3001"}, - }}, - "foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Text: "hi"}, - }}, - }, - }, - }) - add(step{ - 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("https:10000 / off"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }) - add(step{ - command: cmd("https:443 / off"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }) - add(step{ - command: cmd("https:8443 /abc off"), - want: &ipn.ServeConfig{}, - }) - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/bar": {Proxy: "https://127.0.0.1:8443"}, - }}, - }, - }, - }) - add(step{ - command: cmd("https:443 bar https://127.0.0.1:8443"), - want: nil, // nothing to save - }) - add(step{ // try resetting using reset command - command: cmd("reset"), - want: &ipn.ServeConfig{}, - }) - add(step{ - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "https+insecure://127.0.0.1:3001"}, - }}, - }, - }, - }) - add(step{reset: true}) - add(step{ - command: cmd("https:443 /foo 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{ - "/foo": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - }, - }) - add(step{ // test a second handler on the same port - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/foo": {Proxy: "http://127.0.0.1:3000"}, - }}, - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/foo": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - }, - }) - 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(errHelp, "errHelp"), - }) - add(step{ // !somehost, must be localhost or 127.0.0.1 - command: cmd("tls-terminated-tcp:443 tcp://somehost:5432"), - wantErr: exactErr(errHelp, "errHelp"), - }) - add(step{ // bad target port, too low - command: cmd("tls-terminated-tcp:443 tcp://somehost:0"), - wantErr: exactErr(errHelp, "errHelp"), - }) - add(step{ // bad target port, too high - command: cmd("tls-terminated-tcp:443 tcp://somehost:65536"), - wantErr: exactErr(errHelp, "errHelp"), - }) - add(step{ - command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "127.0.0.1:5432", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }) - add(step{ - command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "127.0.0.1:8443", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }) - add(step{ - command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"), - want: nil, // nothing to save - }) - add(step{ - command: cmd("tls-terminated-tcp:443 tcp://localhost:8444"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "127.0.0.1:8444", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }) - add(step{ - 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", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }) - add(step{reset: true}) - add(step{ - command: cmd("tls-terminated-tcp:443 tcp://localhost:123"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "127.0.0.1:123", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }) - add(step{ // handler doesn't exist, so we get an error - command: cmd("tls-terminated-tcp:8443 off"), - wantErr: anyErr(), - }) - add(step{ - command: cmd("tls-terminated-tcp:443 off"), - want: &ipn.ServeConfig{}, - }) - - // text - add(step{reset: true}) - add(step{ - command: cmd("https:443 / text:hello"), - 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{ - "/": {Text: "hello"}, - }}, - }, - }, - }) - - // path - td := t.TempDir() - writeFile := func(suffix, contents string) { - if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil { - t.Fatal(err) - } - } - add(step{reset: true}) - writeFile("foo", "this is foo") - add(step{ - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Path: filepath.Join(td, "foo")}, - }}, - }, - }, - }) - os.MkdirAll(filepath.Join(td, "subdir"), 0700) - writeFile("subdir/file-a", "this is A") - add(step{ - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Path: filepath.Join(td, "foo")}, - "/some/where": {Path: filepath.Join(td, "subdir/file-a")}, - }}, - }, - }, - }) - add(step{ // bad path - command: cmd("https:443 / bad/path"), - wantErr: exactErr(errHelp, "errHelp"), - }) - add(step{reset: true}) - add(step{ - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Path: filepath.Join(td, "subdir/")}, - }}, - }, - }, - }) - add(step{ - command: cmd("https:443 / off"), - want: &ipn.ServeConfig{}, - }) - - // combos - add(step{reset: true}) - add(step{ - command: cmd("https:443 / 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{ - 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}}, - 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{ // serving on secondary port doesn't change funnel - 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}}, - 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:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/bar": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }) - add(step{ // turn funnel on for secondary port - 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}}, - 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:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/bar": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }) - add(step{ // turn funnel off for primary port 443 - 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}}, - 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:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/bar": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }) - add(step{ // remove secondary port - 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}}, - 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{ // start a tcp forwarder on 8443 - 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"}}, - 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{ // remove primary port http handler - 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("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("funnel 8443 off"), - want: &ipn.ServeConfig{}, - }) - - // tricky steps - add(step{reset: true}) - add(step{ // a directory with a trailing slash mount point - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/dir/": {Path: filepath.Join(td, "subdir/")}, - }}, - }, - }, - }) - add(step{ // this should overwrite the previous one - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/dir": {Path: filepath.Join(td, "foo")}, - }}, - }, - }, - }) - add(step{reset: true}) // reset and do the opposite - add(step{ // a file without a trailing slash mount point - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/dir": {Path: filepath.Join(td, "foo")}, - }}, - }, - }, - }) - add(step{ // this should overwrite the previous one - 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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/dir/": {Path: filepath.Join(td, "subdir/")}, - }}, - }, - }, - }) - - // error states - add(step{reset: true}) - add(step{ // tcp forward 5432 on serve port 443 - command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 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("https:443 / localhost:3000"), - wantErr: exactErr(errHelp, "errHelp"), - }) - add(step{reset: true}) - add(step{ // start a web handler on port 443 - command: cmd("https:443 / 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{ // 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 - } - if st.command == nil { - continue - } - t.Logf("Executing step #%d, line %v: %q ... ", i, st.line, st.command) - - var stdout bytes.Buffer - var flagOut bytes.Buffer - e := &serveEnv{ - lc: lc, - testFlagOut: &flagOut, - testStdout: &stdout, - testStderr: io.Discard, - } - lastCount := lc.setCount - var cmd *ffcli.Command - var args []string - if st.command[0] == "funnel" { - cmd = newFunnelCommand(e) - args = st.command[1:] - } else { - cmd = newServeLegacyCommand(e) - args = st.command - } - if cmd.FlagSet == nil { - cmd.FlagSet = flag.NewFlagSet(cmd.Name, flag.ContinueOnError) - cmd.FlagSet.SetOutput(Stdout) - } - err := cmd.ParseAndRun(context.Background(), args) - if flagOut.Len() > 0 { - t.Logf("flag package output: %q", flagOut.Bytes()) - } - if err != nil { - if st.wantErr == nil { - t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, err) - } - if bad := st.wantErr(err); bad != "" { - t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, bad) - } - continue - } - if st.wantErr != nil { - t.Fatalf("step #%d, line %v: got success (saved=%v), but wanted an error", i, st.line, lc.config != nil) - } - var got *ipn.ServeConfig = nil - if lc.setCount > lastCount { - got = lc.config - } - if !reflect.DeepEqual(got, st.want) { - t.Fatalf("[%d] %v: bad state. got:\n%v\n\nwant:\n%v\n", - i, st.command, logger.AsJSON(got), logger.AsJSON(st.want)) - // NOTE: asJSON will omit empty fields, which might make - // result in bad state got/want diffs being the same, even - // though the actual state is different. Use below to debug: - // t.Fatalf("[%d] %v: bad state. got:\n%+v\n\nwant:\n%+v\n", - // i, st.command, got, st.want) - } - } -} - -func TestVerifyFunnelEnabled(t *testing.T) { - tstest.Replace(t, &Stderr, io.Discard) - tstest.Replace(t, &Stdout, io.Discard) - - lc := &fakeLocalServeClient{} - var stdout bytes.Buffer - var flagOut bytes.Buffer - e := &serveEnv{ - lc: lc, - testFlagOut: &flagOut, - testStdout: &stdout, - testStderr: io.Discard, - } - - tests := []struct { - name string - // queryFeatureResponse is the mock response desired from the - // call made to lc.QueryFeature by verifyFunnelEnabled. - queryFeatureResponse mockQueryFeatureResponse - caps []tailcfg.NodeCapability // optionally set at fakeStatus.Capabilities - wantErr string - wantPanic string - }{ - { - name: "enabled", - queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{Complete: true}, err: nil}, - wantErr: "", // no error, success - }, - { - name: "fallback-to-non-interactive-flow", - queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")}, - wantErr: "Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.", - }, - { - name: "fallback-flow-missing-acl-rule", - queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")}, - caps: []tailcfg.NodeCapability{tailcfg.CapabilityHTTPS}, - wantErr: `Funnel not available; "funnel" node attribute not set. See https://tailscale.com/s/no-funnel.`, - }, - { - name: "fallback-flow-enabled", - queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")}, - caps: []tailcfg.NodeCapability{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel, "https://tailscale.com/cap/funnel-ports?ports=80,443,8080-8090"}, - wantErr: "", // no error, success - }, - { - name: "not-allowed-to-enable", - queryFeatureResponse: mockQueryFeatureResponse{resp: &tailcfg.QueryFeatureResponse{ - Complete: false, - Text: "You don't have permission to enable this feature.", - ShouldWait: false, - }, err: nil}, - wantErr: "", - wantPanic: "unexpected call to os.Exit(0) during test", // os.Exit(0) should be called to end process - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - lc.setQueryFeatureResponse(tt.queryFeatureResponse) - - if tt.caps != nil { - cm := make(tailcfg.NodeCapMap) - for _, c := range tt.caps { - cm[c] = nil - } - tstest.Replace(t, &fakeStatus.Self.CapMap, cm) - } - - defer func() { - r := recover() - var gotPanic string - if r != nil { - gotPanic = fmt.Sprint(r) - } - if gotPanic != tt.wantPanic { - t.Errorf("wrong panic; got=%s, want=%s", gotPanic, tt.wantPanic) - } - }() - gotErr := e.verifyFunnelEnabled(ctx, 443) - var got string - if gotErr != nil { - got = gotErr.Error() - } - if got != tt.wantErr { - t.Errorf("wrong error; got=%s, want=%s", gotErr, tt.wantErr) - } - }) - } -} - -// fakeLocalServeClient is a fake tailscale.LocalClient for tests. -// It's not a full implementation, just enough to test the serve command. -// -// The fake client is stateful, and is used to test manipulating -// ServeConfig state. This implementation cannot be used concurrently. -type fakeLocalServeClient struct { - config *ipn.ServeConfig - setCount int // counts calls to SetServeConfig - queryFeatureResponse *mockQueryFeatureResponse // mock response to QueryFeature calls -} - -// fakeStatus is a fake ipnstate.Status value for tests. -// It's not a full implementation, just enough to test the serve command. -// -// It returns a state that's running, with a fake DNSName and the Funnel -// node attribute capability. -var fakeStatus = &ipnstate.Status{ - BackendState: ipn.Running.String(), - Self: &ipnstate.PeerStatus{ - DNSName: "foo.test.ts.net", - CapMap: tailcfg.NodeCapMap{ - tailcfg.NodeAttrFunnel: nil, - tailcfg.CapabilityFunnelPorts + "?ports=443,8443": nil, - }, - }, -} - -func (lc *fakeLocalServeClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { - return fakeStatus, nil -} - -func (lc *fakeLocalServeClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) { - return lc.config.Clone(), nil -} - -func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error { - lc.setCount += 1 - lc.config = config.Clone() - return nil -} - -type mockQueryFeatureResponse struct { - resp *tailcfg.QueryFeatureResponse - err error -} - -func (lc *fakeLocalServeClient) setQueryFeatureResponse(resp mockQueryFeatureResponse) { - lc.queryFeatureResponse = &resp -} - -func (lc *fakeLocalServeClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) { - if resp := lc.queryFeatureResponse; resp != nil { - // If we're testing QueryFeature, use the response value set for the test. - return resp.resp, resp.err - } - return &tailcfg.QueryFeatureResponse{Complete: true}, nil // fallback to already enabled -} - -func (lc *fakeLocalServeClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error) { - return nil, nil // unused in tests -} - -func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name string, delta int) error { - return nil // unused in tests -} - -// exactError returns an error checker that wants exactly the provided want error. -// If optName is non-empty, it's used in the error message. -func exactErr(want error, optName ...string) func(error) string { - return func(got error) string { - if got == want { - return "" - } - if len(optName) > 0 { - return fmt.Sprintf("got error %v, want %v", got, optName[0]) - } - return fmt.Sprintf("got error %v, want %v", got, want) - } -} - -// anyErr returns an error checker that wants any error. -func anyErr() func(error) string { - return func(got error) string { - return "" - } -} - -func cmd(s string) []string { - return strings.Fields(s) -} diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go deleted file mode 100644 index 009a61198dad8..0000000000000 --- a/cmd/tailscale/cli/serve_v2.go +++ /dev/null @@ -1,834 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "math" - "net" - "net/url" - "os" - "os/signal" - "path" - "path/filepath" - "sort" - "strconv" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/util/mak" - "tailscale.com/version" -) - -type execFunc func(ctx context.Context, args []string) error - -type commandInfo struct { - Name string - ShortHelp string - LongHelp string -} - -var serveHelpCommon = strings.TrimSpace(` - can be a file, directory, text, or most commonly the location to a service running on the -local machine. The location to the location service can be expressed as a port number (e.g., 3000), -a partial URL (e.g., localhost:3000), or a full URL including a path (e.g., http://localhost:3000/foo). - -EXAMPLES - - Expose an HTTP server running at 127.0.0.1:3000 in the foreground: - $ tailscale %[1]s 3000 - - - Expose an HTTP server running at 127.0.0.1:3000 in the background: - $ tailscale %[1]s --bg 3000 - - - Expose an HTTPS server with invalid or self-signed certificates at https://localhost:8443 - $ tailscale %[1]s https+insecure://localhost:8443 - -For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases -`) - -type serveMode int - -const ( - serve serveMode = iota - funnel -) - -type serveType int - -const ( - serveTypeHTTPS serveType = iota - serveTypeHTTP - serveTypeTCP - serveTypeTLSTerminatedTCP -) - -var infoMap = map[serveMode]commandInfo{ - serve: { - Name: "serve", - ShortHelp: "Serve content and local servers on your tailnet", - LongHelp: strings.Join([]string{ - "Tailscale Serve enables you to share a local server securely within your tailnet.\n", - "To share a local server on the internet, use `tailscale funnel`\n\n", - }, "\n"), - }, - funnel: { - Name: "funnel", - ShortHelp: "Serve content and local servers on the internet", - LongHelp: strings.Join([]string{ - "Funnel enables you to share a local server on the internet using Tailscale.\n", - "To share only within your tailnet, use `tailscale serve`\n\n", - }, "\n"), - }, -} - -// errHelpFunc is standard error text that prompts users to -// run `$subcmd --help` for information on how to use serve. -var errHelpFunc = func(m serveMode) error { - return fmt.Errorf("try `tailscale %s --help` for usage info", infoMap[m].Name) -} - -// newServeV2Command returns a new "serve" subcommand using e as its environment. -func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command { - if subcmd != serve && subcmd != funnel { - log.Fatalf("newServeDevCommand called with unknown subcmd %q", subcmd) - } - - info := infoMap[subcmd] - - return &ffcli.Command{ - Name: info.Name, - ShortHelp: info.ShortHelp, - ShortUsage: strings.Join([]string{ - fmt.Sprintf("tailscale %s ", info.Name), - fmt.Sprintf("tailscale %s status [--json]", info.Name), - fmt.Sprintf("tailscale %s reset", info.Name), - }, "\n"), - LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name), - Exec: e.runServeCombined(subcmd), - - FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) { - fs.BoolVar(&e.bg, "bg", false, "Run the command as a background process (default false)") - fs.StringVar(&e.setPath, "set-path", "", "Appends the specified path to the base URL for accessing the underlying service") - fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)") - if subcmd == serve { - fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port") - } - fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port") - fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port") - fs.BoolVar(&e.yes, "yes", false, "Update without interactive prompts (default false)") - }), - UsageFunc: usageFuncNoDefaultValues, - Subcommands: []*ffcli.Command{ - { - Name: "status", - ShortUsage: "tailscale " + info.Name + " status [--json]", - Exec: e.runServeStatus, - ShortHelp: "View current " + info.Name + " configuration", - FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) { - fs.BoolVar(&e.json, "json", false, "output JSON") - }), - }, - { - Name: "reset", - ShortUsage: "tailscale " + info.Name + " reset", - ShortHelp: "Reset current " + info.Name + " config", - Exec: e.runServeReset, - FlagSet: e.newFlags("serve-reset", nil), - }, - }, - } -} - -func (e *serveEnv) validateArgs(subcmd serveMode, args []string) error { - if translation, ok := isLegacyInvocation(subcmd, args); ok { - fmt.Fprint(e.stderr(), "Error: the CLI for serve and funnel has changed.") - if translation != "" { - fmt.Fprint(e.stderr(), " You can run the following command instead:\n") - fmt.Fprintf(e.stderr(), "\t- %s\n", translation) - } - fmt.Fprint(e.stderr(), "\nPlease see https://tailscale.com/kb/1242/tailscale-serve for more information.\n") - return errHelpFunc(subcmd) - } - if len(args) == 0 { - return flag.ErrHelp - } - if len(args) > 2 { - fmt.Fprintf(e.stderr(), "Error: invalid number of arguments (%d)\n", len(args)) - return errHelpFunc(subcmd) - } - turnOff := args[len(args)-1] == "off" - if len(args) == 2 && !turnOff { - fmt.Fprintln(e.stderr(), "Error: invalid argument format") - return errHelpFunc(subcmd) - } - - // Given the two checks above, we can assume there - // are only 1 or 2 arguments which is valid. - return nil -} - -// runServeCombined is the entry point for the "tailscale {serve,funnel}" commands. -func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { - e.subcmd = subcmd - - return func(ctx context.Context, args []string) error { - // Undocumented debug command (not using ffcli subcommands) to set raw - // configs from stdin for now (2022-11-13). - if len(args) == 1 && args[0] == "set-raw" { - valb, err := io.ReadAll(os.Stdin) - if err != nil { - return err - } - sc := new(ipn.ServeConfig) - if err := json.Unmarshal(valb, sc); err != nil { - return fmt.Errorf("invalid JSON: %w", err) - } - return e.lc.SetServeConfig(ctx, sc) - } - - if err := e.validateArgs(subcmd, args); err != nil { - return err - } - - ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) - defer cancel() - - funnel := subcmd == funnel - if funnel { - // verify node has funnel capabilities - if err := e.verifyFunnelEnabled(ctx, 443); err != nil { - return err - } - } - - mount, err := cleanURLPath(e.setPath) - if err != nil { - return fmt.Errorf("failed to clean the mount point: %w", err) - } - - srvType, srvPort, err := srvTypeAndPortFromFlags(e) - if err != nil { - fmt.Fprintf(e.stderr(), "error: %v\n\n", err) - return errHelpFunc(subcmd) - } - - sc, err := e.lc.GetServeConfig(ctx) - if err != nil { - return fmt.Errorf("error getting serve config: %w", err) - } - - // nil if no config - if sc == nil { - sc = new(ipn.ServeConfig) - } - st, err := e.getLocalClientStatusWithoutPeers(ctx) - if err != nil { - return fmt.Errorf("getting client status: %w", err) - } - dnsName := strings.TrimSuffix(st.Self.DNSName, ".") - - // set parent serve config to always be persisted - // at the top level, but a nested config might be - // the one that gets manipulated depending on - // foreground or background. - parentSC := sc - - turnOff := "off" == args[len(args)-1] - if !turnOff && srvType == serveTypeHTTPS { - // Running serve with https requires that the tailnet has enabled - // https cert provisioning. Send users through an interactive flow - // to enable this if not already done. - // - // TODO(sonia,tailscale/corp#10577): The interactive feature flow - // is behind a control flag. If the tailnet doesn't have the flag - // on, enableFeatureInteractive will error. For now, we hide that - // error and maintain the previous behavior (prior to 2023-08-15) - // of letting them edit the serve config before enabling certs. - if err := e.enableFeatureInteractive(ctx, "serve", tailcfg.CapabilityHTTPS); err != nil { - return fmt.Errorf("error enabling https feature: %w", err) - } - } - - var watcher *tailscale.IPNBusWatcher - wantFg := !e.bg && !turnOff - if wantFg { - // validate the config before creating a WatchIPNBus session - if err := e.validateConfig(parentSC, srvPort, srvType); err != nil { - return err - } - - // if foreground mode, create a WatchIPNBus session - // and use the nested config for all following operations - // TODO(marwan-at-work): nested-config validations should happen here or previous to this point. - watcher, err = e.lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys) - if err != nil { - return err - } - defer watcher.Close() - n, err := watcher.Next() - if err != nil { - return err - } - if n.SessionID == "" { - return errors.New("missing SessionID") - } - fsc := &ipn.ServeConfig{} - mak.Set(&sc.Foreground, n.SessionID, fsc) - sc = fsc - } - - var msg string - if turnOff { - err = e.unsetServe(sc, dnsName, srvType, srvPort, mount) - } else { - if err := e.validateConfig(parentSC, srvPort, srvType); err != nil { - return err - } - err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel) - msg = e.messageForPort(sc, st, dnsName, srvType, srvPort) - } - if err != nil { - fmt.Fprintf(e.stderr(), "error: %v\n\n", err) - return errHelpFunc(subcmd) - } - - if err := e.lc.SetServeConfig(ctx, parentSC); err != nil { - if tailscale.IsPreconditionsFailedError(err) { - fmt.Fprintln(e.stderr(), "Another client is changing the serve config; please try again.") - } - return err - } - - if msg != "" { - fmt.Fprintln(e.stdout(), msg) - } - - if watcher != nil { - for { - _, err = watcher.Next() - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { - return nil - } - return err - } - } - } - - return nil - } -} - -const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration" - -func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error { - sc, isFg := sc.FindConfig(port) - if sc == nil { - return nil - } - if isFg { - return errors.New("foreground already exists under this port") - } - if !e.bg { - return fmt.Errorf(backgroundExistsMsg, infoMap[e.subcmd].Name, wantServe.String(), port) - } - existingServe := serveFromPortHandler(sc.TCP[port]) - if wantServe != existingServe { - return fmt.Errorf("want %q but port is already serving %q", wantServe, existingServe) - } - return nil -} - -func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType { - switch { - case tcp.HTTP: - return serveTypeHTTP - case tcp.HTTPS: - return serveTypeHTTPS - case tcp.TerminateTLS != "": - return serveTypeTLSTerminatedTCP - case tcp.TCPForward != "": - return serveTypeTCP - default: - return -1 - } -} - -func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error { - // update serve config based on the type - switch srvType { - case serveTypeHTTPS, serveTypeHTTP: - useTLS := srvType == serveTypeHTTPS - err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target) - if err != nil { - return fmt.Errorf("failed apply web serve: %w", err) - } - case serveTypeTCP, serveTypeTLSTerminatedTCP: - if e.setPath != "" { - return fmt.Errorf("cannot mount a path for TCP serve") - } - - err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target) - if err != nil { - return fmt.Errorf("failed to apply TCP serve: %w", err) - } - default: - return fmt.Errorf("invalid type %q", srvType) - } - - // update the serve config based on if funnel is enabled - e.applyFunnel(sc, dnsName, srvPort, allowFunnel) - - return nil -} - -var ( - msgFunnelAvailable = "Available on the internet:" - msgServeAvailable = "Available within your tailnet:" - msgRunningInBackground = "%s started and running in the background." - msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off" - msgToExit = "Press Ctrl+C to exit." -) - -// messageForPort returns a message for the given port based on the -// serve config and status. -func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16) string { - var output strings.Builder - - hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) - - if sc.AllowFunnel[hp] == true { - output.WriteString(msgFunnelAvailable) - } else { - output.WriteString(msgServeAvailable) - } - output.WriteString("\n\n") - - scheme := "https" - if sc.IsServingHTTP(srvPort) { - scheme = "http" - } - - portPart := ":" + fmt.Sprint(srvPort) - if scheme == "http" && srvPort == 80 || - scheme == "https" && srvPort == 443 { - portPart = "" - } - - srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) { - switch { - case h.Path != "": - return "path", h.Path - case h.Proxy != "": - return "proxy", h.Proxy - case h.Text != "": - return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\"" - } - return "", "" - } - - if sc.Web[hp] != nil { - var mounts []string - - for k := range sc.Web[hp].Handlers { - mounts = append(mounts, k) - } - sort.Slice(mounts, func(i, j int) bool { - return len(mounts[i]) < len(mounts[j]) - }) - - for _, m := range mounts { - h := sc.Web[hp].Handlers[m] - t, d := srvTypeAndDesc(h) - output.WriteString(fmt.Sprintf("%s://%s%s%s\n", scheme, dnsName, portPart, m)) - output.WriteString(fmt.Sprintf("%s %-5s %s\n\n", "|--", t, d)) - } - } else if sc.TCP[srvPort] != nil { - h := sc.TCP[srvPort] - - tlsStatus := "TLS over TCP" - if h.TerminateTLS != "" { - tlsStatus = "TLS terminated" - } - - output.WriteString(fmt.Sprintf("%s://%s%s\n", scheme, dnsName, portPart)) - output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus)) - for _, a := range st.TailscaleIPs { - ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort))) - output.WriteString(fmt.Sprintf("|-- tcp://%s\n", ipp)) - } - output.WriteString(fmt.Sprintf("|--> tcp://%s\n", h.TCPForward)) - } - - if !e.bg { - output.WriteString(msgToExit) - return output.String() - } - - subCmd := infoMap[e.subcmd].Name - subCmdUpper := strings.ToUpper(string(subCmd[0])) + subCmd[1:] - - output.WriteString(fmt.Sprintf(msgRunningInBackground, subCmdUpper)) - output.WriteString("\n") - output.WriteString(fmt.Sprintf(msgDisableProxy, subCmd, srvType.String(), srvPort)) - - return output.String() -} - -func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string) error { - h := new(ipn.HTTPHandler) - - switch { - case strings.HasPrefix(target, "text:"): - text := strings.TrimPrefix(target, "text:") - if text == "" { - return errors.New("unable to serve; text cannot be an empty string") - } - h.Text = text - case filepath.IsAbs(target): - if version.IsMacAppStore() || version.IsMacSys() { - // The Tailscale network extension cannot serve arbitrary paths on macOS due to sandbox restrictions (2024-03-26) - return errors.New("Path serving is not supported on macOS due to sandbox restrictions. To use Tailscale Serve on macOS, switch to the open-source tailscaled distribution. See https://tailscale.com/kb/1065/macos-variants for more information.") - } - - target = filepath.Clean(target) - fi, err := os.Stat(target) - if err != nil { - return errors.New("invalid path") - } - - // TODO: need to understand this further - if fi.IsDir() && !strings.HasSuffix(mount, "/") { - // dir mount points must end in / - // for relative file links to work - mount += "/" - } - h.Path = target - default: - t, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure"}, "http") - if err != nil { - return err - } - h.Proxy = t - } - - // TODO: validation needs to check nested foreground configs - if sc.IsTCPForwardingOnPort(srvPort) { - return errors.New("cannot serve web; already serving TCP") - } - - sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS) - - return nil -} - -func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType serveType, srcPort uint16, target string) error { - var terminateTLS bool - switch srcType { - case serveTypeTCP: - terminateTLS = false - case serveTypeTLSTerminatedTCP: - terminateTLS = true - default: - return fmt.Errorf("invalid TCP target %q", target) - } - - targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp") - if err != nil { - return fmt.Errorf("unable to expand target: %v", err) - } - - dstURL, err := url.Parse(targetURL) - if err != nil { - return fmt.Errorf("invalid TCP target %q: %v", target, err) - } - - // TODO: needs to account for multiple configs from foreground mode - if sc.IsServingWeb(srcPort) { - return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort) - } - - sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, dnsName) - - return nil -} - -func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint16, allowFunnel bool) { - hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) - - // TODO: Should we return an error? Should not be possible. - // nil if no config - if sc == nil { - sc = new(ipn.ServeConfig) - } - - if _, exists := sc.AllowFunnel[hp]; exists && !allowFunnel { - fmt.Fprintf(e.stderr(), "Removing Funnel for %s:%s\n", dnsName, hp) - } - sc.SetFunnel(dnsName, srvPort, allowFunnel) -} - -// unsetServe removes the serve config for the given serve port. -func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string) error { - switch srvType { - case serveTypeHTTPS, serveTypeHTTP: - err := e.removeWebServe(sc, dnsName, srvPort, mount) - if err != nil { - return fmt.Errorf("failed to remove web serve: %w", err) - } - case serveTypeTCP, serveTypeTLSTerminatedTCP: - err := e.removeTCPServe(sc, srvPort) - if err != nil { - return fmt.Errorf("failed to remove TCP serve: %w", err) - } - default: - return fmt.Errorf("invalid type %q", srvType) - } - - // TODO(tylersmalley): remove funnel - - return nil -} - -func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, err error) { - sourceMap := map[serveType]uint{ - serveTypeHTTP: e.http, - serveTypeHTTPS: e.https, - serveTypeTCP: e.tcp, - serveTypeTLSTerminatedTCP: e.tlsTerminatedTCP, - } - - var srcTypeCount int - - for k, v := range sourceMap { - if v != 0 { - if v > math.MaxUint16 { - return 0, 0, fmt.Errorf("port number %d is too high for %s flag", v, srvType) - } - srcTypeCount++ - srvType = k - srvPort = uint16(v) - } - } - - if srcTypeCount > 1 { - return 0, 0, fmt.Errorf("cannot serve multiple types for a single mount point") - } else if srcTypeCount == 0 { - srvType = serveTypeHTTPS - srvPort = 443 - } - - return srvType, srvPort, nil -} - -// isLegacyInvocation helps transition customers who have been using the beta -// CLI to the newer API by returning a translation from the old command to the new command. -// The second result is a boolean that only returns true if the given arguments is a valid -// legacy invocation. If the given args are in the old format but are not valid, it will -// return false and expects the new code path has enough validations to reject the request. -func isLegacyInvocation(subcmd serveMode, args []string) (string, bool) { - if subcmd == funnel { - if len(args) != 2 { - return "", false - } - _, err := strconv.ParseUint(args[0], 10, 16) - return "", err == nil && (args[1] == "on" || args[1] == "off") - } - turnOff := len(args) > 1 && args[len(args)-1] == "off" - if turnOff { - args = args[:len(args)-1] - } - if len(args) == 0 { - return "", false - } - - srcType, srcPortStr, found := strings.Cut(args[0], ":") - if !found { - if srcType == "https" && srcPortStr == "" { - // Default https port to 443. - srcPortStr = "443" - } else if srcType == "http" && srcPortStr == "" { - // Default http port to 80. - srcPortStr = "80" - } else { - return "", false - } - } - - var wantLength int - switch srcType { - case "https", "http": - wantLength = 3 - case "tcp", "tls-terminated-tcp": - wantLength = 2 - default: - // return non-legacy, and let new code handle validation. - return "", false - } - // The length is either exactlly the same as in "https / " - // or target is omitted as in "https / off" where omit the off at - // the top. - if len(args) != wantLength && !(turnOff && len(args) == wantLength-1) { - return "", false - } - - cmd := []string{"tailscale", "serve", "--bg"} - switch srcType { - case "https": - // In the new code, we default to https:443, - // so we don't need to pass the flag explicitly. - if srcPortStr != "443" { - cmd = append(cmd, fmt.Sprintf("--https %s", srcPortStr)) - } - case "http": - cmd = append(cmd, fmt.Sprintf("--http %s", srcPortStr)) - case "tcp", "tls-terminated-tcp": - cmd = append(cmd, fmt.Sprintf("--%s %s", srcType, srcPortStr)) - } - - var mount string - if srcType == "https" || srcType == "http" { - mount = args[1] - if _, err := cleanMountPoint(mount); err != nil { - return "", false - } - if mount != "/" { - cmd = append(cmd, "--set-path "+mount) - } - } - - // If there's no "off" there must always be a target destination. - // If there is "off", target is optional so check if it exists - // first before appending it. - hasTarget := !turnOff || (turnOff && len(args) == wantLength) - if hasTarget { - dest := args[len(args)-1] - if strings.Contains(dest, " ") { - dest = strconv.Quote(dest) - } - cmd = append(cmd, dest) - } - if turnOff { - cmd = append(cmd, "off") - } - - return strings.Join(cmd, " "), true -} - -// removeWebServe removes a web handler from the serve config -// and removes funnel if no remaining mounts exist for the serve port. -// The srvPort argument is the serving port and the mount argument is -// the mount point or registered path to remove. -func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, mount string) error { - if sc.IsTCPForwardingOnPort(srvPort) { - return errors.New("cannot remove web handler; currently serving TCP") - } - - portStr := strconv.Itoa(int(srvPort)) - hp := ipn.HostPort(net.JoinHostPort(dnsName, portStr)) - - var targetExists bool - var mounts []string - // mount is deduced from e.setPath but it is ambiguous as - // to whether the user explicitly passed "/" or it was defaulted to. - if e.setPath == "" { - targetExists = sc.Web[hp] != nil && len(sc.Web[hp].Handlers) > 0 - if targetExists { - for mount := range sc.Web[hp].Handlers { - mounts = append(mounts, mount) - } - } - } else { - targetExists = sc.WebHandlerExists(hp, mount) - mounts = []string{mount} - } - - if !targetExists { - return errors.New("error: handler does not exist") - } - - if len(mounts) > 1 { - msg := fmt.Sprintf("Are you sure you want to delete %d handlers under port %s?", len(mounts), portStr) - if !e.yes && !promptYesNo(msg) { - return nil - } - } - - sc.RemoveWebHandler(dnsName, srvPort, mounts, true) - return nil -} - -// removeTCPServe removes the TCP forwarding configuration for the -// given srvPort, or serving port. -func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error { - if sc == nil { - return nil - } - if sc.GetTCPPortHandler(src) == nil { - return errors.New("error: serve config does not exist") - } - if sc.IsServingWeb(src) { - return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src) - } - sc.RemoveTCPForwarding(src) - return nil -} - -// cleanURLPath ensures the path is clean and has a leading "/". -func cleanURLPath(urlPath string) (string, error) { - if urlPath == "" { - return "/", nil - } - - // TODO(tylersmalley) verify still needed with path being a flag - urlPath = cleanMinGWPathConversionIfNeeded(urlPath) - if !strings.HasPrefix(urlPath, "/") { - urlPath = "/" + urlPath - } - - c := path.Clean(urlPath) - if urlPath == c || urlPath == c+"/" { - return urlPath, nil - } - return "", fmt.Errorf("invalid mount point %q", urlPath) -} - -func (s serveType) String() string { - switch s { - case serveTypeHTTP: - return "http" - case serveTypeHTTPS: - return "https" - case serveTypeTCP: - return "tcp" - case serveTypeTLSTerminatedTCP: - return "tls-terminated-tcp" - default: - return "unknownServeType" - } -} - -func (e *serveEnv) stdout() io.Writer { - if e.testStdout != nil { - return e.testStdout - } - return Stdout -} - -func (e *serveEnv) stderr() io.Writer { - if e.testStderr != nil { - return e.testStderr - } - return Stderr -} diff --git a/cmd/tailscale/cli/serve_v2_test.go b/cmd/tailscale/cli/serve_v2_test.go deleted file mode 100644 index 5768127ad0421..0000000000000 --- a/cmd/tailscale/cli/serve_v2_test.go +++ /dev/null @@ -1,1289 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "strconv" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" -) - -func TestServeDevConfigMutations(t *testing.T) { - // step is a stateful mutation within a group - type step struct { - command []string // serve args; nil means no command to run (only reset) - want *ipn.ServeConfig // non-nil means we want a save of this value - wantErr func(error) (badErrMsg string) // nil means no error is wanted - } - - // group is a group of steps that share the same - // config mutation, but always starts from an empty config - type group struct { - name string - steps []step - } - - // creaet a temporary directory for path-based destinations - td := t.TempDir() - writeFile := func(suffix, contents string) { - if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil { - t.Fatal(err) - } - } - writeFile("foo", "this is foo") - err := os.MkdirAll(filepath.Join(td, "subdir"), 0700) - if err != nil { - t.Fatal(err) - } - writeFile("subdir/file-a", "this is subdir") - - groups := [...]group{ - { - name: "using_port_number", - steps: []step{{ - command: cmd("funnel --bg 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"}, - }}, - }, - AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, - }, - }}, - }, - { - name: "funnel_background", - steps: []step{{ - command: cmd("funnel --bg 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://localhost:3000"}, - }}, - }, - AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, - }, - }}, - }, - { - name: "serve_background", - steps: []step{{ - command: cmd("serve --bg 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://localhost:3000"}, - }}, - }, - }, - }}, - }, - { - name: "set_path_bg", - steps: []step{{ - command: cmd("serve --set-path=/ --bg 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://localhost:3000"}, - }}, - }, - }, - }}, - }, - { - name: "http_listener", - steps: []step{{ - command: cmd("serve --bg --http=80 localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }}, - }, - { - name: "https_listener_valid_port", - steps: []step{{ - command: cmd("serve --bg --https=8443 localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }}, - }, - { - name: "multiple_http_with_off", - steps: []step{ - { - command: cmd("serve --http=80 --bg http://localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - { // support non Funnel port - command: cmd("serve --bg --http=9999 --set-path=/abc http://localhost:3001"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - "foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://localhost:3001"}, - }}, - }, - }, - }, - { // turn off one handler - command: cmd("serve --bg --http=9999 --set-path=/abc off"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - { // add another handler - command: cmd("serve --bg --http=8080 --set-path=/abc http://127.0.0.1:3001"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - "foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }, - }, - }, - { - name: "invalid_port_too_low", - steps: []step{{ - command: cmd("serve --https=443 --bg http://localhost:0"), // invalid port, too low - wantErr: anyErr(), - }}, - }, - { - name: "invalid_port_too_high", - steps: []step{{ - command: cmd("serve --https=443 --bg http://localhost:65536"), // invalid port, too high - wantErr: anyErr(), - }}, - }, - { - name: "invalid_mount_port_too_high", - steps: []step{{ - command: cmd("serve --https=65536 --bg http://localhost:3000"), // invalid port, too high - wantErr: anyErr(), - }}, - }, - { - name: "invalid_host", - steps: []step{{ - command: cmd("serve --https=443 --bg http://somehost:3000"), // invalid host - wantErr: anyErr(), - }}, - }, - { - name: "invalid_scheme", - steps: []step{{ - command: cmd("serve --https=443 --bg httpz://127.0.0.1"), // invalid scheme - wantErr: anyErr(), - }}, - }, - { - name: "turn_off_https", - steps: []step{ - { - command: cmd("serve --bg --https=443 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://localhost:3000"}, - }}, - }, - }, - }, - { - command: cmd("serve --bg --https=9999 --set-path=/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://localhost:3000"}, - }}, - "foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://localhost:3001"}, - }}, - }, - }, - }, - { - command: cmd("serve --bg --https=9999 --set-path=/abc off"), - 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://localhost:3000"}, - }}, - }, - }, - }, - { - command: cmd("serve --bg --https=8443 --set-path=/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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/abc": {Proxy: "http://127.0.0.1:3001"}, - }}, - }, - }, - }, - }, - }, - { - name: "https_text_bg", - steps: []step{{ - command: cmd("serve --bg --https=10000 text:hi"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{10000: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Text: "hi"}, - }}, - }, - }, - }}, - }, - { - name: "handler_not_found", - steps: []step{{ - command: cmd("serve --https=443 --set-path=/foo off"), - want: nil, // nothing to save - wantErr: anyErr(), - }}, - }, - { - name: "clean_mount", // "bar" becomes "/bar" - steps: []step{{ - command: cmd("serve --bg --https=443 --set-path=bar https://127.0.0.1:8443"), - 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{ - "/bar": {Proxy: "https://127.0.0.1:8443"}, - }}, - }, - }, - }}, - }, - { - name: "serve_reset", - steps: []step{ - { - command: cmd("serve --bg --https=443 --set-path=bar https://127.0.0.1:8443"), - 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{ - "/bar": {Proxy: "https://127.0.0.1:8443"}, - }}, - }, - }, - }, - { - command: cmd("serve reset"), - want: &ipn.ServeConfig{}, - }, - }, - }, - { - name: "https_insecure", - steps: []step{{ - command: cmd("serve --bg --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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "https+insecure://127.0.0.1:3001"}, - }}, - }, - }, - }}, - }, - { - name: "two_ports_same_dest", - steps: []step{ - { - command: cmd("serve --bg --https=443 --set-path=/foo 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{ - "/foo": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - { - command: cmd("serve --bg --https=8443 --set-path=/foo localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/foo": {Proxy: "http://localhost:3000"}, - }}, - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/foo": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - }, - }, - { - name: "path_in_dest", - steps: []step{{ - command: cmd("serve --bg --https=443 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"}, - }}, - }, - }, - }}, - }, - { - name: "unknown_host_tcp", - steps: []step{{ - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:5432"), - wantErr: exactErrMsg(errHelp), - }}, - }, - { - name: "tcp_port_too_low", - steps: []step{{ - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:0"), - wantErr: exactErrMsg(errHelp), - }}, - }, - { - name: "tcp_port_too_high", - steps: []step{{ - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"), - wantErr: exactErrMsg(errHelp), - }}, - }, - { - name: "tcp_shorthand", - steps: []step{{ - command: cmd("serve --tls-terminated-tcp=443 --bg 5432"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "127.0.0.1:5432", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }}, - }, - { - name: "tls_terminated_tcp", - steps: []step{ - { - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "localhost:5432", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }, - { - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8443"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "127.0.0.1:8443", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }, - }, - }, - { - name: "tcp_off", - steps: []step{ - { - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:123"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "localhost:123", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }, - { // handler doesn't exist - command: cmd("serve --tls-terminated-tcp=8443 off"), - wantErr: anyErr(), - }, - { - command: cmd("serve --tls-terminated-tcp=443 off"), - want: &ipn.ServeConfig{}, - }, - }, - }, - { - name: "text", - steps: []step{{ - command: cmd("serve --https=443 --bg text:hello"), - 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{ - "/": {Text: "hello"}, - }}, - }, - }, - }}, - }, - { - name: "path", - steps: []step{ - { - command: cmd("serve --https=443 --bg " + filepath.Join(td, "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{ - "/": {Path: filepath.Join(td, "foo")}, - }}, - }, - }, - }, - { - command: cmd("serve --bg --https=443 --set-path=/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{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Path: filepath.Join(td, "foo")}, - "/some/where": {Path: filepath.Join(td, "subdir/file-a")}, - }}, - }, - }, - }, - }, - }, - { - name: "bad_path", - steps: []step{{ - command: cmd("serve --bg --https=443 bad/path"), - wantErr: exactErrMsg(errHelp), - }}, - }, - { - name: "path_off", - steps: []step{ - { - command: cmd("serve --bg --https=443 " + filepath.Join(td, "subdir")), - 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{ - "/": {Path: filepath.Join(td, "subdir/")}, - }}, - }, - }, - }, - { - command: cmd("serve --bg --https=443 off"), - want: &ipn.ServeConfig{}, - }, - }, - }, - { - name: "combos", - steps: []step{ - { - command: cmd("serve --bg 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://localhost:3000"}, - }}, - }, - }, - }, - { // enable funnel for primary port - command: cmd("funnel --bg localhost:3000"), - want: &ipn.ServeConfig{ - AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, - 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://localhost:3000"}, - }}, - }, - }, - }, - { // serving on secondary port doesn't change funnel on primary port - command: cmd("serve --bg --https=8443 --set-path=/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}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/bar": {Proxy: "http://localhost:3001"}, - }}, - }, - }, - }, - { // turn funnel on for secondary port - command: cmd("funnel --bg --https=8443 --set-path=/bar localhost:3001"), - 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}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/bar": {Proxy: "http://localhost:3001"}, - }}, - }, - }, - }, - { // turn funnel off for primary port 443 - command: cmd("serve --bg localhost:3000"), - 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}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - "foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{ - "/bar": {Proxy: "http://localhost:3001"}, - }}, - }, - }, - }, - { // remove secondary port - command: cmd("serve --bg --https=8443 --set-path=/bar off"), - 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://localhost:3000"}, - }}, - }, - }, - }, - { // start a tcp forwarder on 8443 - command: cmd("serve --bg --tcp=8443 tcp://localhost:5432"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "localhost:5432"}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - { // remove primary port http handler - command: cmd("serve off"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "localhost:5432"}}, - }, - }, - { // remove tcp forwarder - command: cmd("serve --tls-terminated-tcp=8443 off"), - want: &ipn.ServeConfig{}, - }, - }, - }, - { - name: "tricky_steps", - steps: []step{ - { // a directory with a trailing slash mount point - command: cmd("serve --bg --https=443 --set-path=/dir " + filepath.Join(td, "subdir")), - 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{ - "/dir/": {Path: filepath.Join(td, "subdir/")}, - }}, - }, - }, - }, - { // this should overwrite the previous one - command: cmd("serve --bg --https=443 --set-path=/dir " + filepath.Join(td, "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{ - "/dir": {Path: filepath.Join(td, "foo")}, - }}, - }, - }, - }, - { // reset and do opposite - command: cmd("serve reset"), - want: &ipn.ServeConfig{}, - }, - { // a file without a trailing slash mount point - command: cmd("serve --bg --https=443 --set-path=/dir " + filepath.Join(td, "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{ - "/dir": {Path: filepath.Join(td, "foo")}, - }}, - }, - }, - }, - { // this should overwrite the previous one - command: cmd("serve --bg --https=443 --set-path=/dir " + filepath.Join(td, "subdir")), - 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{ - "/dir/": {Path: filepath.Join(td, "subdir/")}, - }}, - }, - }, - }, - }, - }, - { - name: "cannot_override_tcp_with_http", - steps: []step{ - { // tcp forward 5432 on serve port 443 - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: { - TCPForward: "localhost:5432", - TerminateTLS: "foo.test.ts.net", - }, - }, - }, - }, - { - command: cmd("serve --https=443 --bg localhost:3000"), - wantErr: anyErr(), - }, - }, - }, - { - name: "cannot_override_http_with_tcp", - steps: []step{ - { - command: cmd("serve --https=443 --bg 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://localhost:3000"}, - }}, - }, - }, - }, - { // try to start a tcp forwarder on the same serve port - command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"), - wantErr: anyErr(), - }, - }, - }, - { - name: "turn_off_multiple_handlers", - steps: []step{ - { - command: cmd("serve --https=4545 --set-path=/foo --bg localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{ - "/foo": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - { - command: cmd("serve --https=4545 --set-path=/bar --bg localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{ - "/foo": {Proxy: "http://localhost:3000"}, - "/bar": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - { - command: cmd("serve --https=4545 --bg --yes localhost:3000 off"), - want: &ipn.ServeConfig{}, - }, - }, - }, - { - name: "no_http_with_funnel", - steps: []step{ - { - command: cmd("funnel --http=80 3000"), - // error parsing commandline arguments: flag provided but not defined: -http - wantErr: anyErr(), - }, - }, - }, - { - name: "forground_with_bg_conflict", - steps: []step{ - { - command: cmd("serve --bg --http=3000 localhost:3000"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{3000: {HTTP: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:3000": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://localhost:3000"}, - }}, - }, - }, - }, - { - command: cmd("serve --http=3000 localhost:3000"), - wantErr: exactErrMsg(fmt.Errorf(backgroundExistsMsg, "serve", "http", 3000)), - }, - }, - }, - } - - for _, group := range groups { - t.Run(group.name, func(t *testing.T) { - lc := &fakeLocalServeClient{} - for i, st := range group.steps { - var stderr bytes.Buffer - var stdout bytes.Buffer - var flagOut bytes.Buffer - e := &serveEnv{ - lc: lc, - testFlagOut: &flagOut, - testStdout: &stdout, - testStderr: &stderr, - } - lastCount := lc.setCount - var cmd *ffcli.Command - var args []string - - mode := serve - if st.command[0] == "funnel" { - mode = funnel - } - cmd = newServeV2Command(e, mode) - args = st.command[1:] - - err := cmd.ParseAndRun(context.Background(), args) - if flagOut.Len() > 0 { - t.Logf("flag package output: %q", flagOut.Bytes()) - } - if err != nil { - if st.wantErr == nil { - t.Fatalf("step #%d: unexpected error: %v", i, err) - } - if bad := st.wantErr(err); bad != "" { - t.Fatalf("step #%d: unexpected error: %v", i, bad) - } - continue - } - if st.wantErr != nil { - t.Fatalf("step #%d: got success (saved=%v), but wanted an error", i, lc.config != nil) - } - var got *ipn.ServeConfig = nil - if lc.setCount > lastCount { - got = lc.config - } - if !reflect.DeepEqual(got, st.want) { - gotbts, _ := json.MarshalIndent(got, "", "\t") - wantbts, _ := json.MarshalIndent(st.want, "", "\t") - t.Fatalf("step: %d, cmd: %v, diff:\n%s", i, st.command, cmp.Diff(string(gotbts), string(wantbts))) - - } - } - }) - - } -} - -func TestValidateConfig(t *testing.T) { - tests := [...]struct { - name string - desc string - cfg *ipn.ServeConfig - servePort uint16 - serveType serveType - bg bool - wantErr bool - }{ - { - name: "nil_config", - desc: "when config is nil, all requests valid", - cfg: nil, - servePort: 3000, - serveType: serveTypeHTTPS, - }, - { - name: "new_bg_tcp", - desc: "no error when config exists but we're adding a new bg tcp port", - cfg: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {HTTPS: true}, - }, - }, - bg: true, - servePort: 10000, - serveType: serveTypeHTTPS, - }, - { - name: "override_bg_tcp", - desc: "no error when overwriting previous port under the same serve type", - cfg: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "http://localhost:4545"}, - }, - }, - bg: true, - servePort: 443, - serveType: serveTypeTCP, - }, - { - name: "override_bg_tcp", - desc: "error when overwriting previous port under a different serve type", - cfg: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {HTTPS: true}, - }, - }, - bg: true, - servePort: 443, - serveType: serveTypeHTTP, - wantErr: true, - }, - { - name: "new_fg_port", - desc: "no error when serving a new foreground port", - cfg: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {HTTPS: true}, - }, - Foreground: map[string]*ipn.ServeConfig{ - "abc123": { - TCP: map[uint16]*ipn.TCPPortHandler{ - 3000: {HTTPS: true}, - }, - }, - }, - }, - servePort: 4040, - serveType: serveTypeTCP, - }, - { - name: "same_fg_port", - desc: "error when overwriting a previous fg port", - cfg: &ipn.ServeConfig{ - Foreground: map[string]*ipn.ServeConfig{ - "abc123": { - TCP: map[uint16]*ipn.TCPPortHandler{ - 3000: {HTTPS: true}, - }, - }, - }, - }, - servePort: 3000, - serveType: serveTypeTCP, - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - se := serveEnv{bg: tc.bg} - err := se.validateConfig(tc.cfg, tc.servePort, tc.serveType) - if err == nil && tc.wantErr { - t.Fatal("expected an error but got nil") - } - if err != nil && !tc.wantErr { - t.Fatalf("expected no error but got: %v", err) - } - }) - } - -} - -func TestSrcTypeFromFlags(t *testing.T) { - tests := []struct { - name string - env *serveEnv - expectedType serveType - expectedPort uint16 - expectedErr bool - }{ - { - name: "only http set", - env: &serveEnv{http: 80}, - expectedType: serveTypeHTTP, - expectedPort: 80, - expectedErr: false, - }, - { - name: "only https set", - env: &serveEnv{https: 10000}, - expectedType: serveTypeHTTPS, - expectedPort: 10000, - expectedErr: false, - }, - { - name: "only tcp set", - env: &serveEnv{tcp: 8000}, - expectedType: serveTypeTCP, - expectedPort: 8000, - expectedErr: false, - }, - { - name: "only tls-terminated-tcp set", - env: &serveEnv{tlsTerminatedTCP: 8080}, - expectedType: serveTypeTLSTerminatedTCP, - expectedPort: 8080, - expectedErr: false, - }, - { - name: "defaults to https, port 443", - env: &serveEnv{}, - expectedType: serveTypeHTTPS, - expectedPort: 443, - expectedErr: false, - }, - { - name: "multiple types set", - env: &serveEnv{http: 80, https: 443}, - expectedPort: 0, - expectedErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - srcType, srcPort, err := srvTypeAndPortFromFlags(tt.env) - if (err != nil) != tt.expectedErr { - t.Errorf("Expected error: %v, got: %v", tt.expectedErr, err) - } - if srcType != tt.expectedType { - t.Errorf("Expected srcType: %s, got: %s", tt.expectedType.String(), srcType) - } - if srcPort != tt.expectedPort { - t.Errorf("Expected srcPort: %d, got: %d", tt.expectedPort, srcPort) - } - }) - } -} - -func TestCleanURLPath(t *testing.T) { - tests := []struct { - input string - expected string - wantErr bool - }{ - {input: "", expected: "/"}, - {input: "/", expected: "/"}, - {input: "/foo", expected: "/foo"}, - {input: "/foo/", expected: "/foo/"}, - {input: "/../bar", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - actual, err := cleanURLPath(tt.input) - - if tt.wantErr == true && err == nil { - t.Errorf("Expected an error but got none") - return - } - - if tt.wantErr == false && err != nil { - t.Errorf("Got an error, but didn't expect one: %v", err) - return - } - - if actual != tt.expected { - t.Errorf("Got: %q; expected: %q", actual, tt.expected) - } - }) - } -} - -func TestMessageForPort(t *testing.T) { - tests := []struct { - name string - subcmd serveMode - serveConfig *ipn.ServeConfig - status *ipnstate.Status - dnsName string - srvType serveType - srvPort uint16 - expected string - }{ - { - name: "funnel-https", - subcmd: funnel, - serveConfig: &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"}, - }, - }, - }, - AllowFunnel: map[ipn.HostPort]bool{ - "foo.test.ts.net:443": true, - }, - }, - status: &ipnstate.Status{}, - dnsName: "foo.test.ts.net", - srvType: serveTypeHTTPS, - srvPort: 443, - expected: strings.Join([]string{ - msgFunnelAvailable, - "", - "https://foo.test.ts.net/", - "|-- proxy http://127.0.0.1:3000", - "", - fmt.Sprintf(msgRunningInBackground, "Funnel"), - fmt.Sprintf(msgDisableProxy, "funnel", "https", 443), - }, "\n"), - }, - { - name: "serve-http", - subcmd: serve, - serveConfig: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {HTTP: true}, - }, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:80": { - Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }, - }, - }, - }, - status: &ipnstate.Status{}, - dnsName: "foo.test.ts.net", - srvType: serveTypeHTTP, - srvPort: 80, - expected: strings.Join([]string{ - msgServeAvailable, - "", - "https://foo.test.ts.net:80/", - "|-- proxy http://127.0.0.1:3000", - "", - fmt.Sprintf(msgRunningInBackground, "Serve"), - fmt.Sprintf(msgDisableProxy, "serve", "http", 80), - }, "\n"), - }, - } - - for _, tt := range tests { - e := &serveEnv{bg: true, subcmd: tt.subcmd} - - t.Run(tt.name, func(t *testing.T) { - actual := e.messageForPort(tt.serveConfig, tt.status, tt.dnsName, tt.srvType, tt.srvPort) - - if actual == "" { - t.Errorf("Got empty message") - } - - if actual != tt.expected { - t.Errorf("\nGot: %q\nExpected: %q", actual, tt.expected) - } - }) - } -} - -func TestIsLegacyInvocation(t *testing.T) { - tests := []struct { - subcmd serveMode - args []string - expected bool - translation string - }{ - { - subcmd: serve, - args: []string{"https", "/", "localhost:3000"}, - expected: true, - translation: "tailscale serve --bg localhost:3000", - }, - { - subcmd: serve, - args: []string{"https", "/", "localhost:3000", "off"}, - expected: true, - translation: "tailscale serve --bg localhost:3000 off", - }, - { - subcmd: serve, - args: []string{"https", "/", "off"}, - expected: true, - translation: "tailscale serve --bg off", - }, - { - subcmd: serve, - args: []string{"https:4545", "/foo", "off"}, - expected: true, - translation: "tailscale serve --bg --https 4545 --set-path /foo off", - }, - { - subcmd: serve, - args: []string{"https:4545", "/foo", "localhost:9090", "off"}, - expected: true, - translation: "tailscale serve --bg --https 4545 --set-path /foo localhost:9090 off", - }, - { - subcmd: serve, - args: []string{"https:8443", "/", "localhost:3000"}, - expected: true, - translation: "tailscale serve --bg --https 8443 localhost:3000", - }, - { - subcmd: serve, - args: []string{"http", "/", "localhost:3000"}, - expected: true, - translation: "tailscale serve --bg --http 80 localhost:3000", - }, - { - subcmd: serve, - args: []string{"http:80", "/", "localhost:3000"}, - expected: true, - translation: "tailscale serve --bg --http 80 localhost:3000", - }, - { - subcmd: serve, - args: []string{"https:10000", "/motd.txt", `text:Hello, world!`}, - expected: true, - translation: `tailscale serve --bg --https 10000 --set-path /motd.txt "text:Hello, world!"`, - }, - { - subcmd: serve, - args: []string{"tcp:2222", "tcp://localhost:22"}, - expected: true, - translation: "tailscale serve --bg --tcp 2222 tcp://localhost:22", - }, - { - subcmd: serve, - args: []string{"tls-terminated-tcp:443", "tcp://localhost:80"}, - expected: true, - translation: "tailscale serve --bg --tls-terminated-tcp 443 tcp://localhost:80", - }, - { - subcmd: funnel, - args: []string{"443", "on"}, - expected: true, - }, - { - subcmd: funnel, - args: []string{"443", "off"}, - expected: true, - }, - - { - subcmd: serve, - args: []string{"3000"}, - expected: false, - }, - { - subcmd: serve, - args: []string{"localhost:3000"}, - expected: false, - }, - } - - for idx, tt := range tests { - t.Run(strconv.Itoa(idx), func(t *testing.T) { - gotTranslation, actual := isLegacyInvocation(tt.subcmd, tt.args) - - if actual != tt.expected { - t.Fatalf("got: %v; expected: %v", actual, tt.expected) - } - - if gotTranslation != tt.translation { - t.Fatalf("expected translaction to be %q but got %q", tt.translation, gotTranslation) - } - }) - } -} - -// exactErrMsg returns an error checker that wants exactly the provided want error. -// If optName is non-empty, it's used in the error message. -func exactErrMsg(want error) func(error) string { - return func(got error) string { - if got.Error() == want.Error() { - return "" - } - return fmt.Sprintf("\ngot: %v\nwant: %v\n", got, want) - } -} diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go deleted file mode 100644 index e8e5f0c51e15b..0000000000000 --- a/cmd/tailscale/cli/set.go +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "net/netip" - "os/exec" - "runtime" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/web" - "tailscale.com/clientupdate" - "tailscale.com/cmd/tailscale/cli/ffcomplete" - "tailscale.com/ipn" - "tailscale.com/net/netutil" - "tailscale.com/net/tsaddr" - "tailscale.com/safesocket" - "tailscale.com/types/opt" - "tailscale.com/types/views" - "tailscale.com/version" -) - -var setCmd = &ffcli.Command{ - Name: "set", - ShortUsage: "tailscale set [flags]", - ShortHelp: "Change specified preferences", - LongHelp: `"tailscale set" allows changing specific preferences. - -Unlike "tailscale up", this command does not require the complete set of desired settings. - -Only settings explicitly mentioned will be set. There are no default values.`, - FlagSet: setFlagSet, - Exec: runSet, - UsageFunc: usageFuncNoDefaultValues, -} - -type setArgsT struct { - acceptRoutes bool - acceptDNS bool - exitNodeIP string - exitNodeAllowLANAccess bool - shieldsUp bool - runSSH bool - runWebClient bool - hostname string - advertiseRoutes string - advertiseDefaultRoute bool - advertiseConnector bool - opUser string - acceptedRisks string - profileName string - forceDaemon bool - updateCheck bool - updateApply bool - postureChecking bool - snat bool - statefulFiltering bool - netfilterMode string -} - -func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { - setf := newFlagSet("set") - - setf.StringVar(&setArgs.profileName, "nickname", "", "nickname for the current account") - setf.BoolVar(&setArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes") - setf.BoolVar(&setArgs.acceptDNS, "accept-dns", false, "accept DNS configuration from the admin panel") - setf.StringVar(&setArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node") - setf.BoolVar(&setArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node") - setf.BoolVar(&setArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") - setf.BoolVar(&setArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy") - setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") - setf.StringVar(&setArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes") - setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") - setf.BoolVar(&setArgs.advertiseConnector, "advertise-connector", false, "offer to be an app connector for domain specific internet traffic for the tailnet") - setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates") - setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version") - setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, hidden+"allow management plane to gather device posture information") - setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252") - - ffcomplete.Flag(setf, "exit-node", func(args []string) ([]string, ffcomplete.ShellCompDirective, error) { - st, err := localClient.Status(context.Background()) - if err != nil { - return nil, 0, err - } - nodes := make([]string, 0, len(st.Peer)) - for _, node := range st.Peer { - if !node.ExitNodeOption { - continue - } - nodes = append(nodes, strings.TrimSuffix(node.DNSName, ".")) - } - return nodes, ffcomplete.ShellCompDirectiveNoFileComp, nil - }) - - if safesocket.GOOSUsesPeerCreds(goos) { - setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") - } - switch goos { - case "linux": - setf.BoolVar(&setArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes") - setf.BoolVar(&setArgs.statefulFiltering, "stateful-filtering", false, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, etc.)") - setf.StringVar(&setArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)") - case "windows": - setf.BoolVar(&setArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)") - } - - registerAcceptRiskFlag(setf, &setArgs.acceptedRisks) - return setf -} - -var ( - setArgs setArgsT - setFlagSet = newSetFlagSet(effectiveGOOS(), &setArgs) -) - -func runSet(ctx context.Context, args []string) (retErr error) { - if len(args) > 0 { - fatalf("too many non-flag arguments: %q", args) - } - - st, err := localClient.Status(ctx) - if err != nil { - return err - } - - // Note that even though we set the values here regardless of whether the - // user passed the flag, the value is only used if the user passed the flag. - // See updateMaskedPrefsFromUpOrSetFlag. - maskedPrefs := &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - ProfileName: setArgs.profileName, - RouteAll: setArgs.acceptRoutes, - CorpDNS: setArgs.acceptDNS, - ExitNodeAllowLANAccess: setArgs.exitNodeAllowLANAccess, - ShieldsUp: setArgs.shieldsUp, - RunSSH: setArgs.runSSH, - RunWebClient: setArgs.runWebClient, - Hostname: setArgs.hostname, - OperatorUser: setArgs.opUser, - NoSNAT: !setArgs.snat, - ForceDaemon: setArgs.forceDaemon, - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: setArgs.updateCheck, - Apply: opt.NewBool(setArgs.updateApply), - }, - AppConnector: ipn.AppConnectorPrefs{ - Advertise: setArgs.advertiseConnector, - }, - PostureChecking: setArgs.postureChecking, - NoStatefulFiltering: opt.NewBool(!setArgs.statefulFiltering), - }, - } - - if effectiveGOOS() == "linux" { - nfMode, warning, err := netfilterModeFromFlag(setArgs.netfilterMode) - if err != nil { - return err - } - if warning != "" { - warnf(warning) - } - maskedPrefs.Prefs.NetfilterMode = nfMode - } - - if setArgs.exitNodeIP != "" { - if err := maskedPrefs.Prefs.SetExitNodeIP(setArgs.exitNodeIP, st); err != nil { - var e ipn.ExitNodeLocalIPError - if errors.As(err, &e) { - return fmt.Errorf("%w; did you mean --advertise-exit-node?", err) - } - return err - } - } - - warnOnAdvertiseRouts(ctx, &maskedPrefs.Prefs) - var advertiseExitNodeSet, advertiseRoutesSet bool - setFlagSet.Visit(func(f *flag.Flag) { - updateMaskedPrefsFromUpOrSetFlag(maskedPrefs, f.Name) - switch f.Name { - case "advertise-exit-node": - advertiseExitNodeSet = true - case "advertise-routes": - advertiseRoutesSet = true - } - }) - if maskedPrefs.IsEmpty() { - return flag.ErrHelp - } - - curPrefs, err := localClient.GetPrefs(ctx) - if err != nil { - return err - } - if maskedPrefs.AdvertiseRoutesSet { - maskedPrefs.AdvertiseRoutes, err = calcAdvertiseRoutesForSet(advertiseExitNodeSet, advertiseRoutesSet, curPrefs, setArgs) - if err != nil { - return err - } - } - - if runtime.GOOS == "darwin" && maskedPrefs.AppConnector.Advertise { - if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, setArgs.acceptedRisks); err != nil { - return err - } - } - - if maskedPrefs.RunSSHSet { - wantSSH, haveSSH := maskedPrefs.RunSSH, curPrefs.RunSSH - if err := presentSSHToggleRisk(wantSSH, haveSSH, setArgs.acceptedRisks); err != nil { - return err - } - } - if maskedPrefs.AutoUpdateSet.ApplySet { - if !clientupdate.CanAutoUpdate() { - return errors.New("automatic updates are not supported on this platform") - } - // On macsys, tailscaled will set the Sparkle auto-update setting. It - // does not use clientupdate. - if version.IsMacSysExt() { - apply := "0" - if maskedPrefs.AutoUpdate.Apply.EqualBool(true) { - apply = "1" - } - out, err := exec.Command("defaults", "write", "io.tailscale.ipn.macsys", "SUAutomaticallyUpdate", apply).CombinedOutput() - if err != nil { - return fmt.Errorf("failed to enable automatic updates: %v, %q", err, out) - } - } - } - checkPrefs := curPrefs.Clone() - checkPrefs.ApplyEdits(maskedPrefs) - if err := localClient.CheckPrefs(ctx, checkPrefs); err != nil { - return err - } - - _, err = localClient.EditPrefs(ctx, maskedPrefs) - if err != nil { - return err - } - - if setArgs.runWebClient && len(st.TailscaleIPs) > 0 { - printf("\nWeb interface now running at %s:%d", st.TailscaleIPs[0], web.ListenPort) - } - - return nil -} - -// calcAdvertiseRoutesForSet returns the new value for Prefs.AdvertiseRoutes based on the -// current value, the flags passed to "tailscale set". -// advertiseExitNodeSet is whether the --advertise-exit-node flag was set. -// advertiseRoutesSet is whether the --advertise-routes flag was set. -// curPrefs is the current Prefs. -// setArgs is the parsed command-line arguments. -func calcAdvertiseRoutesForSet(advertiseExitNodeSet, advertiseRoutesSet bool, curPrefs *ipn.Prefs, setArgs setArgsT) (routes []netip.Prefix, err error) { - if advertiseExitNodeSet && advertiseRoutesSet { - return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute) - - } - if advertiseRoutesSet { - return netutil.CalcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode()) - } - if advertiseExitNodeSet { - alreadyAdvertisesExitNode := curPrefs.AdvertisesExitNode() - if alreadyAdvertisesExitNode == setArgs.advertiseDefaultRoute { - return curPrefs.AdvertiseRoutes, nil - } - routes = tsaddr.FilterPrefixesCopy(views.SliceOf(curPrefs.AdvertiseRoutes), func(p netip.Prefix) bool { - return p.Bits() != 0 - }) - if setArgs.advertiseDefaultRoute { - routes = append(routes, tsaddr.AllIPv4(), tsaddr.AllIPv6()) - } - return routes, nil - } - return nil, nil -} diff --git a/cmd/tailscale/cli/set_test.go b/cmd/tailscale/cli/set_test.go deleted file mode 100644 index 15305c3ce3ed3..0000000000000 --- a/cmd/tailscale/cli/set_test.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "net/netip" - "reflect" - "testing" - - "tailscale.com/ipn" - "tailscale.com/net/tsaddr" - "tailscale.com/types/ptr" -) - -func TestCalcAdvertiseRoutesForSet(t *testing.T) { - pfx := netip.MustParsePrefix - tests := []struct { - name string - setExit *bool - setRoutes *string - was []netip.Prefix - want []netip.Prefix - }{ - { - name: "empty", - }, - { - name: "advertise-exit", - setExit: ptr.To(true), - want: tsaddr.ExitRoutes(), - }, - { - name: "advertise-exit/already-routes", - was: []netip.Prefix{pfx("34.0.0.0/16")}, - setExit: ptr.To(true), - want: []netip.Prefix{pfx("34.0.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - }, - { - name: "advertise-exit/already-exit", - was: tsaddr.ExitRoutes(), - setExit: ptr.To(true), - want: tsaddr.ExitRoutes(), - }, - { - name: "stop-advertise-exit", - was: tsaddr.ExitRoutes(), - setExit: ptr.To(false), - want: nil, - }, - { - name: "stop-advertise-exit/with-routes", - was: []netip.Prefix{pfx("34.0.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - setExit: ptr.To(false), - want: []netip.Prefix{pfx("34.0.0.0/16")}, - }, - { - name: "advertise-routes", - setRoutes: ptr.To("10.0.0.0/24,192.168.0.0/16"), - want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16")}, - }, - { - name: "advertise-routes/already-exit", - was: tsaddr.ExitRoutes(), - setRoutes: ptr.To("10.0.0.0/24,192.168.0.0/16"), - want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - }, - { - name: "advertise-routes/already-diff-routes", - was: []netip.Prefix{pfx("34.0.0.0/16")}, - setRoutes: ptr.To("10.0.0.0/24,192.168.0.0/16"), - want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16")}, - }, - { - name: "stop-advertise-routes", - was: []netip.Prefix{pfx("34.0.0.0/16")}, - setRoutes: ptr.To(""), - want: nil, - }, - { - name: "stop-advertise-routes/already-exit", - was: []netip.Prefix{pfx("34.0.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - setRoutes: ptr.To(""), - want: tsaddr.ExitRoutes(), - }, - { - name: "advertise-routes-and-exit", - setExit: ptr.To(true), - setRoutes: ptr.To("10.0.0.0/24,192.168.0.0/16"), - want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - }, - { - name: "advertise-routes-and-exit/already-exit", - was: tsaddr.ExitRoutes(), - setExit: ptr.To(true), - setRoutes: ptr.To("10.0.0.0/24,192.168.0.0/16"), - want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - }, - { - name: "advertise-routes-and-exit/already-routes", - was: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16")}, - setExit: ptr.To(true), - setRoutes: ptr.To("10.0.0.0/24,192.168.0.0/16"), - want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - curPrefs := &ipn.Prefs{ - AdvertiseRoutes: tc.was, - } - sa := setArgsT{} - if tc.setExit != nil { - sa.advertiseDefaultRoute = *tc.setExit - } - if tc.setRoutes != nil { - sa.advertiseRoutes = *tc.setRoutes - } - got, err := calcAdvertiseRoutesForSet(tc.setExit != nil, tc.setRoutes != nil, curPrefs, sa) - if err != nil { - t.Fatal(err) - } - tsaddr.SortPrefixes(got) - tsaddr.SortPrefixes(tc.want) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("got %v, want %v", got, tc.want) - } - }) - } -} diff --git a/cmd/tailscale/cli/ssh.go b/cmd/tailscale/cli/ssh.go deleted file mode 100644 index 68a6193af9d1e..0000000000000 --- a/cmd/tailscale/cli/ssh.go +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - "context" - "errors" - "fmt" - "log" - "net/netip" - "os" - "os/user" - "path/filepath" - "runtime" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/envknob" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/tsaddr" - "tailscale.com/paths" - "tailscale.com/version" -) - -var sshCmd = &ffcli.Command{ - Name: "ssh", - ShortUsage: "tailscale ssh [user@] [args...]", - ShortHelp: "SSH to a Tailscale machine", - LongHelp: strings.TrimSpace(` - -The 'tailscale ssh' command is an optional wrapper around the system 'ssh' -command that's useful in some cases. Tailscale SSH does not require its use; -most users running the Tailscale SSH server will prefer to just use the normal -'ssh' command or their normal SSH client. - -The 'tailscale ssh' wrapper adds a few things: - -* It resolves the destination server name in its arguments using MagicDNS, - even if --accept-dns=false. -* It works in userspace-networking mode, by supplying a ProxyCommand to the - system 'ssh' command that connects via a pipe through tailscaled. -* It automatically checks the destination server's SSH host key against the - node's SSH host key as advertised via the Tailscale coordination server. -`), - Exec: runSSH, -} - -func runSSH(ctx context.Context, args []string) error { - if runtime.GOOS == "darwin" && version.IsMacAppStore() && !envknob.UseWIPCode() { - return errors.New("The 'tailscale ssh' subcommand is not available on macOS builds distributed through the App Store or TestFlight.\nInstall the Standalone variant of Tailscale (download it from https://pkgs.tailscale.com), or use the regular 'ssh' client instead.") - } - if len(args) == 0 { - return errors.New("usage: tailscale ssh [user@]") - } - arg, argRest := args[0], args[1:] - username, host, ok := strings.Cut(arg, "@") - if !ok { - host = arg - lu, err := user.Current() - if err != nil { - return nil - } - username = lu.Username - } - - st, err := localClient.Status(ctx) - if err != nil { - return err - } - - // hostForSSH is the hostname we'll tell OpenSSH we're - // connecting to, so we have to maintain fewer entries in the - // known_hosts files. - hostForSSH := host - if v, ok := nodeDNSNameFromArg(st, host); ok { - hostForSSH = v - } - - ssh, err := findSSH() - if err != nil { - // TODO(bradfitz): use Go's crypto/ssh client instead - // of failing. But for now: - return fmt.Errorf("no system 'ssh' command found: %w", err) - } - tailscaleBin, err := os.Executable() - if err != nil { - return err - } - knownHostsFile, err := writeKnownHosts(st) - if err != nil { - return err - } - - argv := []string{ssh} - - if envknob.Bool("TS_DEBUG_SSH_EXEC") { - argv = append(argv, "-vvv") - } - argv = append(argv, - // Only trust SSH hosts that we know about. - "-o", fmt.Sprintf("UserKnownHostsFile %q", knownHostsFile), - "-o", "UpdateHostKeys no", - "-o", "StrictHostKeyChecking yes", - "-o", "CanonicalizeHostname no", // https://github.com/tailscale/tailscale/issues/10348 - ) - - // MagicDNS is usually working on macOS anyway and they're not in userspace - // mode, so 'nc' isn't very useful. - if runtime.GOOS != "darwin" { - socketArg := "" - if localClient.Socket != "" && localClient.Socket != paths.DefaultTailscaledSocket() { - socketArg = fmt.Sprintf("--socket=%q", localClient.Socket) - } - - argv = append(argv, - "-o", fmt.Sprintf("ProxyCommand %q %s nc %%h %%p", - tailscaleBin, - socketArg, - )) - } - - // Explicitly rebuild the user@host argument rather than - // passing it through. In general, the use of OpenSSH's ssh - // binary is a crutch for now. We don't want to be - // Hyrum-locked into passing through all OpenSSH flags to the - // OpenSSH client forever. We try to make our flags and args - // be compatible, but only a subset. The "tailscale ssh" - // command should be a simple and portable one. If they want - // to use a different one, we'll later be making stock ssh - // work well by default too. (doing things like automatically - // setting known_hosts, etc) - argv = append(argv, username+"@"+hostForSSH) - - argv = append(argv, argRest...) - - if envknob.Bool("TS_DEBUG_SSH_EXEC") { - log.Printf("Running: %q, %q ...", ssh, argv) - } - - return execSSH(ssh, argv) -} - -func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) { - confDir, err := os.UserConfigDir() - if err != nil { - return "", err - } - tsConfDir := filepath.Join(confDir, "tailscale") - if err := os.MkdirAll(tsConfDir, 0700); err != nil { - return "", err - } - knownHostsFile = filepath.Join(tsConfDir, "ssh_known_hosts") - want := genKnownHosts(st) - if cur, err := os.ReadFile(knownHostsFile); err != nil || !bytes.Equal(cur, want) { - if err := os.WriteFile(knownHostsFile, want, 0644); err != nil { - return "", err - } - } - return knownHostsFile, nil -} - -func genKnownHosts(st *ipnstate.Status) []byte { - var buf bytes.Buffer - for _, k := range st.Peers() { - ps := st.Peer[k] - for _, hk := range ps.SSH_HostKeys { - hostKey := strings.TrimSpace(hk) - if strings.ContainsAny(hostKey, "\n\r") { // invalid - continue - } - fmt.Fprintf(&buf, "%s %s\n", ps.DNSName, hostKey) - } - } - return buf.Bytes() -} - -// nodeDNSNameFromArg returns the PeerStatus.DNSName value from a peer -// in st that matches the input arg which can be a base name, full -// DNS name, or an IP. -func nodeDNSNameFromArg(st *ipnstate.Status, arg string) (dnsName string, ok bool) { - if arg == "" { - return - } - argIP, _ := netip.ParseAddr(arg) - for _, ps := range st.Peer { - dnsName = ps.DNSName - if argIP.IsValid() { - for _, ip := range ps.TailscaleIPs { - if ip == argIP { - return dnsName, true - } - } - continue - } - if strings.EqualFold(strings.TrimSuffix(arg, "."), strings.TrimSuffix(dnsName, ".")) { - return dnsName, true - } - if base, _, ok := strings.Cut(ps.DNSName, "."); ok && strings.EqualFold(base, arg) { - return dnsName, true - } - } - return "", false -} - -// getSSHClientEnvVar returns the "SSH_CLIENT" environment variable -// for the current process group, if any. -var getSSHClientEnvVar = func() string { - return "" -} - -// isSSHOverTailscale checks if the invocation is in a SSH session over Tailscale. -// It is used to detect if the user is about to take an action that might result in them -// disconnecting from the machine (e.g. disabling SSH) -func isSSHOverTailscale() bool { - sshClient := getSSHClientEnvVar() - if sshClient == "" { - return false - } - ipStr, _, ok := strings.Cut(sshClient, " ") - if !ok { - return false - } - ip, err := netip.ParseAddr(ipStr) - if err != nil { - return false - } - return tsaddr.IsTailscaleIP(ip) -} diff --git a/cmd/tailscale/cli/ssh_exec.go b/cmd/tailscale/cli/ssh_exec.go deleted file mode 100644 index 10e52903dea64..0000000000000 --- a/cmd/tailscale/cli/ssh_exec.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !js && !windows - -package cli - -import ( - "errors" - "os" - "os/exec" - "syscall" -) - -func findSSH() (string, error) { - return exec.LookPath("ssh") -} - -func execSSH(ssh string, argv []string) error { - if err := syscall.Exec(ssh, argv, os.Environ()); err != nil { - return err - } - return errors.New("unreachable") -} diff --git a/cmd/tailscale/cli/ssh_exec_js.go b/cmd/tailscale/cli/ssh_exec_js.go deleted file mode 100644 index 40effc7cafc7e..0000000000000 --- a/cmd/tailscale/cli/ssh_exec_js.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "errors" -) - -func findSSH() (string, error) { - return "", errors.New("Not implemented") -} - -func execSSH(ssh string, argv []string) error { - return errors.New("Not implemented") -} diff --git a/cmd/tailscale/cli/ssh_exec_windows.go b/cmd/tailscale/cli/ssh_exec_windows.go deleted file mode 100644 index e249afe667401..0000000000000 --- a/cmd/tailscale/cli/ssh_exec_windows.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "errors" - "os" - "os/exec" - "path/filepath" -) - -func findSSH() (string, error) { - // use C:\Windows\System32\OpenSSH\ssh.exe since unexpected behavior - // occurred with ssh.exe provided by msys2/cygwin and other environments. - if systemRoot := os.Getenv("SystemRoot"); systemRoot != "" { - exe := filepath.Join(systemRoot, "System32", "OpenSSH", "ssh.exe") - if st, err := os.Stat(exe); err == nil && !st.IsDir() { - return exe, nil - } - } - return exec.LookPath("ssh") -} - -func execSSH(ssh string, argv []string) error { - // Don't use syscall.Exec on Windows, it's not fully implemented. - cmd := exec.Command(ssh, argv[1:]...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - var ee *exec.ExitError - err := cmd.Run() - if errors.As(err, &ee) { - os.Exit(ee.ExitCode()) - } - return err -} diff --git a/cmd/tailscale/cli/ssh_unix.go b/cmd/tailscale/cli/ssh_unix.go deleted file mode 100644 index 71c0caaa69ad5..0000000000000 --- a/cmd/tailscale/cli/ssh_unix.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !wasm && !windows && !plan9 - -package cli - -import ( - "bytes" - "os" - "path/filepath" - "runtime" - "strconv" - - "golang.org/x/sys/unix" -) - -func init() { - getSSHClientEnvVar = func() string { - if os.Getenv("SUDO_USER") == "" { - // No sudo, just check the env. - return os.Getenv("SSH_CLIENT") - } - if runtime.GOOS != "linux" { - // TODO(maisem): implement this for other platforms. It's not clear - // if there is a way to get the environment for a given process on - // darwin and bsd. - return "" - } - // SID is the session ID of the user's login session. - // It is also the process ID of the original shell that the user logged in with. - // We only need to check the environment of that process. - sid, err := unix.Getsid(os.Getpid()) - if err != nil { - return "" - } - b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(sid), "environ")) - if err != nil { - return "" - } - prefix := []byte("SSH_CLIENT=") - for _, env := range bytes.Split(b, []byte{0}) { - if bytes.HasPrefix(env, prefix) { - return string(env[len(prefix):]) - } - } - return "" - } -} diff --git a/cmd/tailscale/cli/status.go b/cmd/tailscale/cli/status.go deleted file mode 100644 index e4dccc247fd54..0000000000000 --- a/cmd/tailscale/cli/status.go +++ /dev/null @@ -1,338 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "bytes" - "cmp" - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "net" - "net/http" - "net/netip" - "os" - "strconv" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "github.com/toqueteos/webbrowser" - "golang.org/x/net/idna" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/netmon" - "tailscale.com/util/dnsname" -) - -var statusCmd = &ffcli.Command{ - Name: "status", - ShortUsage: "tailscale status [--active] [--web] [--json]", - ShortHelp: "Show state of tailscaled and its connections", - LongHelp: strings.TrimSpace(` - -JSON FORMAT - -Warning: this format has changed between releases and might change more -in the future. - -For a description of the fields, see the "type Status" declaration at: - -https://github.com/tailscale/tailscale/blob/main/ipn/ipnstate/ipnstate.go - -(and be sure to select branch/tag that corresponds to the version - of Tailscale you're running) - -`), - Exec: runStatus, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("status") - fs.BoolVar(&statusArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") - fs.BoolVar(&statusArgs.web, "web", false, "run webserver with HTML showing status") - fs.BoolVar(&statusArgs.active, "active", false, "filter output to only peers with active sessions (not applicable to web mode)") - fs.BoolVar(&statusArgs.self, "self", true, "show status of local machine") - fs.BoolVar(&statusArgs.peers, "peers", true, "show status of peers") - fs.StringVar(&statusArgs.listen, "listen", "127.0.0.1:8384", "listen address for web mode; use port 0 for automatic") - fs.BoolVar(&statusArgs.browser, "browser", true, "Open a browser in web mode") - return fs - })(), -} - -var statusArgs struct { - json bool // JSON output mode - web bool // run webserver - listen string // in web mode, webserver address to listen on, empty means auto - browser bool // in web mode, whether to open browser - active bool // in CLI mode, filter output to only peers with active sessions - self bool // in CLI mode, show status of local machine - peers bool // in CLI mode, show status of peer machines -} - -func runStatus(ctx context.Context, args []string) error { - if len(args) > 0 { - return errors.New("unexpected non-flag arguments to 'tailscale status'") - } - getStatus := localClient.Status - if !statusArgs.peers { - getStatus = localClient.StatusWithoutPeers - } - st, err := getStatus(ctx) - if err != nil { - return fixTailscaledConnectError(err) - } - if statusArgs.json { - if statusArgs.active { - for peer, ps := range st.Peer { - if !ps.Active { - delete(st.Peer, peer) - } - } - } - j, err := json.MarshalIndent(st, "", " ") - if err != nil { - return err - } - printf("%s", j) - return nil - } - if statusArgs.web { - ln, err := net.Listen("tcp", statusArgs.listen) - if err != nil { - return err - } - statusURL := netmon.HTTPOfListener(ln) - printf("Serving Tailscale status at %v ...\n", statusURL) - go func() { - <-ctx.Done() - ln.Close() - }() - if statusArgs.browser { - go webbrowser.Open(statusURL) - } - err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.RequestURI != "/" { - http.NotFound(w, r) - return - } - st, err := localClient.Status(ctx) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - st.WriteHTML(w) - })) - if ctx.Err() != nil { - return ctx.Err() - } - return err - } - - printHealth := func() { - printf("# Health check:\n") - for _, m := range st.Health { - printf("# - %s\n", m) - } - } - - description, ok := isRunningOrStarting(st) - if !ok { - // print health check information if we're in a weird state, as it might - // provide context about why we're in that weird state. - if len(st.Health) > 0 && (st.BackendState == ipn.Starting.String() || st.BackendState == ipn.NoState.String()) { - printHealth() - outln() - } - outln(description) - os.Exit(1) - } - - var buf bytes.Buffer - f := func(format string, a ...any) { fmt.Fprintf(&buf, format, a...) } - printPS := func(ps *ipnstate.PeerStatus) { - f("%-15s %-20s %-12s %-7s ", - firstIPString(ps.TailscaleIPs), - dnsOrQuoteHostname(st, ps), - ownerLogin(st, ps), - ps.OS, - ) - relay := ps.Relay - anyTraffic := ps.TxBytes != 0 || ps.RxBytes != 0 - var offline string - if !ps.Online { - offline = "; offline" - } - if !ps.Active { - if ps.ExitNode { - f("idle; exit node" + offline) - } else if ps.ExitNodeOption { - f("idle; offers exit node" + offline) - } else if anyTraffic { - f("idle" + offline) - } else if !ps.Online { - f("offline") - } else { - f("-") - } - } else { - f("active; ") - if ps.ExitNode { - f("exit node; ") - } else if ps.ExitNodeOption { - f("offers exit node; ") - } - if relay != "" && ps.CurAddr == "" { - f("relay %q", relay) - } else if ps.CurAddr != "" { - f("direct %s", ps.CurAddr) - } - if !ps.Online { - f("; offline") - } - } - if anyTraffic { - f(", tx %d rx %d", ps.TxBytes, ps.RxBytes) - } - f("\n") - } - - if statusArgs.self && st.Self != nil { - printPS(st.Self) - } - - locBasedExitNode := false - if statusArgs.peers { - var peers []*ipnstate.PeerStatus - for _, peer := range st.Peers() { - ps := st.Peer[peer] - if ps.ShareeNode { - continue - } - if ps.Location != nil && ps.ExitNodeOption && !ps.ExitNode { - // Location based exit nodes are only shown with the - // `exit-node list` command. - locBasedExitNode = true - continue - } - peers = append(peers, ps) - } - ipnstate.SortPeers(peers) - for _, ps := range peers { - if statusArgs.active && !ps.Active { - continue - } - printPS(ps) - } - } - Stdout.Write(buf.Bytes()) - if locBasedExitNode { - outln() - printf("# To see the full list of exit nodes, including location-based exit nodes, run `tailscale exit-node list` \n") - } - if len(st.Health) > 0 { - outln() - printHealth() - } - printFunnelStatus(ctx) - return nil -} - -// printFunnelStatus prints the status of the funnel, if it's running. -// It prints nothing if the funnel is not running. -func printFunnelStatus(ctx context.Context) { - sc, err := localClient.GetServeConfig(ctx) - if err != nil { - outln() - printf("# Funnel:\n") - printf("# - Unable to get Funnel status: %v\n", err) - return - } - if !sc.IsFunnelOn() { - return - } - outln() - printf("# Funnel on:\n") - for hp, on := range sc.AllowFunnel { - if !on { // if present, should be on - continue - } - sni, portStr, _ := net.SplitHostPort(string(hp)) - p, _ := strconv.ParseUint(portStr, 10, 16) - isTCP := sc.IsTCPForwardingOnPort(uint16(p)) - url := "https://" - if isTCP { - url = "tcp://" - } - url += sni - if isTCP || p != 443 { - url += ":" + portStr - } - printf("# - %s\n", url) - } - outln() -} - -// isRunningOrStarting reports whether st is in state Running or Starting. -// It also returns a description of the status suitable to display to a user. -func isRunningOrStarting(st *ipnstate.Status) (description string, ok bool) { - switch st.BackendState { - default: - return fmt.Sprintf("unexpected state: %s", st.BackendState), false - case ipn.Stopped.String(): - return "Tailscale is stopped.", false - case ipn.NeedsLogin.String(): - s := "Logged out." - if st.AuthURL != "" { - s += fmt.Sprintf("\nLog in at: %s", st.AuthURL) - } - return s, false - case ipn.NeedsMachineAuth.String(): - return "Machine is not yet approved by tailnet admin.", false - case ipn.Running.String(), ipn.Starting.String(): - return st.BackendState, true - } -} - -func dnsOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string { - baseName := dnsname.TrimSuffix(ps.DNSName, st.MagicDNSSuffix) - if baseName != "" { - if strings.HasPrefix(baseName, "xn-") { - if u, err := idna.ToUnicode(baseName); err == nil { - return fmt.Sprintf("%s (%s)", baseName, u) - } - } - return baseName - } - return fmt.Sprintf("(%q)", dnsname.SanitizeHostname(ps.HostName)) -} - -func ownerLogin(st *ipnstate.Status, ps *ipnstate.PeerStatus) string { - // We prioritize showing the name of the sharer as the owner of a node if - // it's different from the node's user. This is less surprising: if user B - // from a company shares user's C node from the same company with user A who - // don't know user C, user A might be surprised to see user C listed in - // their netmap. We've historically (2021-01..2023-08) always shown the - // sharer's name in the UI. Perhaps we want to show both here? But the CLI's - // a bit space constrained. - uid := cmp.Or(ps.AltSharerUserID, ps.UserID) - if uid.IsZero() { - return "-" - } - u, ok := st.User[uid] - if !ok { - return fmt.Sprint(uid) - } - if i := strings.Index(u.LoginName, "@"); i != -1 { - return u.LoginName[:i+1] - } - return u.LoginName -} - -func firstIPString(v []netip.Addr) string { - if len(v) == 0 { - return "" - } - return v[0].String() -} diff --git a/cmd/tailscale/cli/switch.go b/cmd/tailscale/cli/switch.go deleted file mode 100644 index 731492daaa976..0000000000000 --- a/cmd/tailscale/cli/switch.go +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "flag" - "fmt" - "os" - "strings" - "text/tabwriter" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/cmd/tailscale/cli/ffcomplete" - "tailscale.com/ipn" -) - -var switchCmd = &ffcli.Command{ - Name: "switch", - ShortUsage: "tailscale switch ", - ShortHelp: "Switches to a different Tailscale account", - LongHelp: `"tailscale switch" switches between logged in accounts. You can -use the ID that's returned from 'tailnet switch -list' -to pick which profile you want to switch to. Alternatively, you -can use the Tailnet or the account names to switch as well. - -This command is currently in alpha and may change in the future.`, - - FlagSet: func() *flag.FlagSet { - fs := flag.NewFlagSet("switch", flag.ExitOnError) - fs.BoolVar(&switchArgs.list, "list", false, "list available accounts") - return fs - }(), - Exec: switchProfile, -} - -func init() { - ffcomplete.Args(switchCmd, func(s []string) (words []string, dir ffcomplete.ShellCompDirective, err error) { - _, all, err := localClient.ProfileStatus(context.Background()) - if err != nil { - return nil, 0, err - } - - seen := make(map[string]bool, 3*len(all)) - wordfns := []func(prof ipn.LoginProfile) string{ - func(prof ipn.LoginProfile) string { return string(prof.ID) }, - func(prof ipn.LoginProfile) string { return prof.NetworkProfile.DomainName }, - func(prof ipn.LoginProfile) string { return prof.Name }, - } - - for _, wordfn := range wordfns { - for _, prof := range all { - word := wordfn(prof) - if seen[word] { - continue - } - seen[word] = true - words = append(words, fmt.Sprintf("%s\tid: %s, tailnet: %s, account: %s", word, prof.ID, prof.NetworkProfile.DomainName, prof.Name)) - } - } - return words, ffcomplete.ShellCompDirectiveNoFileComp, nil - }) -} - -var switchArgs struct { - list bool -} - -func listProfiles(ctx context.Context) error { - curP, all, err := localClient.ProfileStatus(ctx) - if err != nil { - return err - } - tw := tabwriter.NewWriter(Stdout, 2, 2, 2, ' ', 0) - defer tw.Flush() - printRow := func(vals ...string) { - fmt.Fprintln(tw, strings.Join(vals, "\t")) - } - printRow("ID", "Tailnet", "Account") - for _, prof := range all { - name := prof.Name - if prof.ID == curP.ID { - name += "*" - } - printRow( - string(prof.ID), - prof.NetworkProfile.DomainName, - name, - ) - } - return nil -} - -func switchProfile(ctx context.Context, args []string) error { - if switchArgs.list { - return listProfiles(ctx) - } - if len(args) != 1 { - outln("usage: tailscale switch NAME") - os.Exit(1) - } - cp, all, err := localClient.ProfileStatus(ctx) - if err != nil { - errf("Failed to switch to account: %v\n", err) - os.Exit(1) - } - var profID ipn.ProfileID - // Allow matching by ID, Tailnet, or Account - // in that order. - for _, p := range all { - if p.ID == ipn.ProfileID(args[0]) { - profID = p.ID - break - } - } - if profID == "" { - for _, p := range all { - if p.NetworkProfile.DomainName == args[0] { - profID = p.ID - break - } - } - } - if profID == "" { - for _, p := range all { - if p.Name == args[0] { - profID = p.ID - break - } - } - } - if profID == "" { - errf("No profile named %q\n", args[0]) - os.Exit(1) - } - if profID == cp.ID { - printf("Already on account %q\n", args[0]) - os.Exit(0) - } - if err := localClient.SwitchProfile(ctx, profID); err != nil { - errf("Failed to switch to account: %v\n", err) - os.Exit(1) - } - printf("Switching to account %q\n", args[0]) - for { - select { - case <-ctx.Done(): - errf("Timed out waiting for switch to complete.") - os.Exit(1) - default: - } - st, err := localClient.StatusWithoutPeers(ctx) - if err != nil { - errf("Error getting status: %v", err) - os.Exit(1) - } - switch st.BackendState { - case "NoState", "Starting": - // TODO(maisem): maybe add a way to subscribe to state changes to - // LocalClient. - time.Sleep(100 * time.Millisecond) - continue - case "NeedsLogin": - outln("Logged out.") - outln("To log in, run:") - outln(" tailscale up") - return nil - case "Running": - outln("Success.") - return nil - } - // For all other states, use the default error message. - if msg, ok := isRunningOrStarting(st); !ok { - outln(msg) - os.Exit(1) - } - } -} diff --git a/cmd/tailscale/cli/syspolicy.go b/cmd/tailscale/cli/syspolicy.go deleted file mode 100644 index 06a19defb459a..0000000000000 --- a/cmd/tailscale/cli/syspolicy.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "os" - "slices" - "text/tabwriter" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/util/syspolicy/setting" -) - -var syspolicyArgs struct { - json bool // JSON output mode -} - -var syspolicyCmd = &ffcli.Command{ - Name: "syspolicy", - ShortHelp: "Diagnose the MDM and system policy configuration", - LongHelp: "The 'tailscale syspolicy' command provides tools for diagnosing the MDM and system policy configuration.", - ShortUsage: "tailscale syspolicy ", - UsageFunc: usageFuncNoDefaultValues, - Subcommands: []*ffcli.Command{ - { - Name: "list", - ShortUsage: "tailscale syspolicy list", - Exec: runSysPolicyList, - ShortHelp: "Prints effective policy settings", - LongHelp: "The 'tailscale syspolicy list' subcommand displays the effective policy settings and their sources (e.g., MDM or environment variables).", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("syspolicy list") - fs.BoolVar(&syspolicyArgs.json, "json", false, "output in JSON format") - return fs - })(), - }, - { - Name: "reload", - ShortUsage: "tailscale syspolicy reload", - Exec: runSysPolicyReload, - ShortHelp: "Forces a reload of policy settings, even if no changes are detected, and prints the result", - LongHelp: "The 'tailscale syspolicy reload' subcommand forces a reload of policy settings, even if no changes are detected, and prints the result.", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("syspolicy reload") - fs.BoolVar(&syspolicyArgs.json, "json", false, "output in JSON format") - return fs - })(), - }, - }, -} - -func runSysPolicyList(ctx context.Context, args []string) error { - policy, err := localClient.GetEffectivePolicy(ctx, setting.DefaultScope()) - if err != nil { - return err - } - printPolicySettings(policy) - return nil - -} - -func runSysPolicyReload(ctx context.Context, args []string) error { - policy, err := localClient.ReloadEffectivePolicy(ctx, setting.DefaultScope()) - if err != nil { - return err - } - printPolicySettings(policy) - return nil -} - -func printPolicySettings(policy *setting.Snapshot) { - if syspolicyArgs.json { - json, err := json.MarshalIndent(policy, "", "\t") - if err != nil { - errf("syspolicy marshalling error: %v", err) - } else { - outln(string(json)) - } - return - } - if policy.Len() == 0 { - outln("No policy settings") - return - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "Name\tOrigin\tValue\tError") - fmt.Fprintln(w, "----\t------\t-----\t-----") - for _, k := range slices.Sorted(policy.Keys()) { - setting, _ := policy.GetSetting(k) - var origin string - if o := setting.Origin(); o != nil { - origin = o.String() - } - if err := setting.Error(); err != nil { - fmt.Fprintf(w, "%s\t%s\t\t{%s}\n", k, origin, err) - } else { - fmt.Fprintf(w, "%s\t%s\t%s\t\n", k, origin, setting.Value()) - } - } - w.Flush() - - fmt.Println() - return -} diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go deleted file mode 100644 index 6c5c6f337f909..0000000000000 --- a/cmd/tailscale/cli/up.go +++ /dev/null @@ -1,1199 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "flag" - "fmt" - "log" - "net/netip" - "net/url" - "os" - "os/signal" - "reflect" - "runtime" - "sort" - "strconv" - "strings" - "syscall" - "time" - - shellquote "github.com/kballard/go-shellquote" - "github.com/peterbourgon/ff/v3/ffcli" - qrcode "github.com/skip2/go-qrcode" - "golang.org/x/oauth2/clientcredentials" - "tailscale.com/client/tailscale" - "tailscale.com/health/healthmsg" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/netutil" - "tailscale.com/net/tsaddr" - "tailscale.com/safesocket" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/types/preftype" - "tailscale.com/types/views" - "tailscale.com/util/dnsname" - "tailscale.com/version" - "tailscale.com/version/distro" -) - -var upCmd = &ffcli.Command{ - Name: "up", - ShortUsage: "tailscale up [flags]", - ShortHelp: "Connect to Tailscale, logging in if needed", - - LongHelp: strings.TrimSpace(` -"tailscale up" connects this machine to your Tailscale network, -triggering authentication if necessary. - -With no flags, "tailscale up" brings the network online without -changing any settings. (That is, it's the opposite of "tailscale -down"). - -If flags are specified, the flags must be the complete set of desired -settings. An error is returned if any setting would be changed as a -result of an unspecified flag's default value, unless the --reset flag -is also used. (The flags --auth-key, --force-reauth, and --qr are not -considered settings that need to be re-specified when modifying -settings.) -`), - FlagSet: upFlagSet, - Exec: func(ctx context.Context, args []string) error { - return runUp(ctx, "up", args, upArgsGlobal) - }, -} - -func effectiveGOOS() string { - if v := os.Getenv("TS_DEBUG_UP_FLAG_GOOS"); v != "" { - return v - } - return runtime.GOOS -} - -// acceptRouteDefault returns the CLI's default value of --accept-routes as -// a function of the platform it's running on. -func acceptRouteDefault(goos string) bool { - switch goos { - case "windows": - return true - case "darwin": - return version.IsSandboxedMacOS() - default: - return false - } -} - -var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up") - -// newUpFlagSet returns a new flag set for the "up" and "login" commands. -func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet { - if cmd != "up" && cmd != "login" { - panic("cmd must be up or login") - } - upf := newFlagSet(cmd) - - // When adding new flags, prefer to put them under "tailscale set" instead - // of here. Setting preferences via "tailscale up" is deprecated. - upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs") - upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`) - - upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server") - upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes") - upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel") - upf.Var(notFalseVar{}, "host-routes", hidden+"install host routes to other Tailscale nodes (must be true as of Tailscale 1.67+)") - upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node") - upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node") - upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") - upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy") - upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")") - upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") - upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes") - upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector") - upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") - - if safesocket.GOOSUsesPeerCreds(goos) { - upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") - } - switch goos { - case "linux": - upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes") - upf.BoolVar(&upArgs.statefulFiltering, "stateful-filtering", false, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, etc.)") - upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)") - case "windows": - upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)") - } - upf.DurationVar(&upArgs.timeout, "timeout", 0, "maximum amount of time to wait for tailscaled to enter a Running state; default (0s) blocks forever") - - if cmd == "login" { - upf.StringVar(&upArgs.profileName, "nickname", "", "short name for the account") - } - - if cmd == "up" { - // Some flags are only for "up", not "login". - upf.BoolVar(&upArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") - upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values") - upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication") - registerAcceptRiskFlag(upf, &upArgs.acceptedRisks) - } - - return upf -} - -// notFalseVar is is a flag.Value that can only be "true", if set. -type notFalseVar struct{} - -func (notFalseVar) IsBoolFlag() bool { return true } -func (notFalseVar) Set(v string) error { - if v != "true" { - return fmt.Errorf("unsupported value; only 'true' is allowed") - } - return nil -} -func (notFalseVar) String() string { return "true" } - -func defaultNetfilterMode() string { - if distro.Get() == distro.Synology { - return "off" - } - return "on" -} - -// upArgsT is the type of upArgs, the argument struct for `tailscale up`. -// As of 2024-10-08, upArgsT is frozen and no new arguments should be -// added to it. Add new arguments to setArgsT instead. -type upArgsT struct { - qr bool - reset bool - server string - acceptRoutes bool - acceptDNS bool - exitNodeIP string - exitNodeAllowLANAccess bool - shieldsUp bool - runSSH bool - runWebClient bool - forceReauth bool - forceDaemon bool - advertiseRoutes string - advertiseDefaultRoute bool - advertiseTags string - advertiseConnector bool - snat bool - statefulFiltering bool - netfilterMode string - authKeyOrFile string // "secret" or "file:/path/to/secret" - hostname string - opUser string - json bool - timeout time.Duration - acceptedRisks string - profileName string -} - -func (a upArgsT) getAuthKey() (string, error) { - v := a.authKeyOrFile - if file, ok := strings.CutPrefix(v, "file:"); ok { - b, err := os.ReadFile(file) - if err != nil { - return "", err - } - return strings.TrimSpace(string(b)), nil - } - return v, nil -} - -var upArgsGlobal upArgsT - -// Fields output when `tailscale up --json` is used. Two JSON blocks will be output. -// -// When "tailscale up" is run it first outputs a block with AuthURL and QR populated, -// providing the link for where to authenticate this client. BackendState would be -// valid but boring, as it will almost certainly be "NeedsLogin". Error would be -// populated if something goes badly wrong. -// -// When the client is authenticated by having someone visit the AuthURL, a second -// JSON block will be output. The AuthURL and QR fields will not be present, the -// BackendState and Error fields will give the result of the authentication. -// Ex: -// -// { -// "AuthURL": "https://login.tailscale.com/a/0123456789abcdef", -// "QR": "data:image/png;base64,0123...cdef" -// "BackendState": "NeedsLogin" -// } -// -// { -// "BackendState": "Running" -// } -type upOutputJSON struct { - AuthURL string `json:",omitempty"` // Authentication URL of the form https://login.tailscale.com/a/0123456789 - QR string `json:",omitempty"` // a DataURL (base64) PNG of a QR code AuthURL - BackendState string `json:",omitempty"` // name of state like Running or NeedsMachineAuth - Error string `json:",omitempty"` // description of an error -} - -func warnf(format string, args ...any) { - printf("Warning: "+format+"\n", args...) -} - -// prefsFromUpArgs returns the ipn.Prefs for the provided args. -// -// Note that the parameters upArgs and warnf are named intentionally -// to shadow the globals to prevent accidental misuse of them. This -// function exists for testing and should have no side effects or -// outside interactions (e.g. no making Tailscale LocalAPI calls). -func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) { - routes, err := netutil.CalcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute) - if err != nil { - return nil, err - } - - if upArgs.exitNodeIP == "" && upArgs.exitNodeAllowLANAccess { - return nil, fmt.Errorf("--exit-node-allow-lan-access can only be used with --exit-node") - } - - var tags []string - if upArgs.advertiseTags != "" { - tags = strings.Split(upArgs.advertiseTags, ",") - for _, tag := range tags { - err := tailcfg.CheckTag(tag) - if err != nil { - return nil, fmt.Errorf("tag: %q: %s", tag, err) - } - } - } - - if err := dnsname.ValidHostname(upArgs.hostname); upArgs.hostname != "" && err != nil { - return nil, err - } - - prefs := ipn.NewPrefs() - prefs.ControlURL = upArgs.server - prefs.WantRunning = true - prefs.RouteAll = upArgs.acceptRoutes - if distro.Get() == distro.Synology { - // ipn.NewPrefs returns a non-zero Netfilter default. But Synology only - // supports "off" mode. - prefs.NetfilterMode = preftype.NetfilterOff - } - if upArgs.exitNodeIP != "" { - if err := prefs.SetExitNodeIP(upArgs.exitNodeIP, st); err != nil { - var e ipn.ExitNodeLocalIPError - if errors.As(err, &e) { - return nil, fmt.Errorf("%w; did you mean --advertise-exit-node?", err) - } - return nil, err - } - } - - prefs.ExitNodeAllowLANAccess = upArgs.exitNodeAllowLANAccess - prefs.CorpDNS = upArgs.acceptDNS - prefs.ShieldsUp = upArgs.shieldsUp - prefs.RunSSH = upArgs.runSSH - prefs.RunWebClient = upArgs.runWebClient - prefs.AdvertiseRoutes = routes - prefs.AdvertiseTags = tags - prefs.Hostname = upArgs.hostname - prefs.ForceDaemon = upArgs.forceDaemon - prefs.OperatorUser = upArgs.opUser - prefs.ProfileName = upArgs.profileName - prefs.AppConnector.Advertise = upArgs.advertiseConnector - - if goos == "linux" { - prefs.NoSNAT = !upArgs.snat - - // Backfills for NoStatefulFiltering occur when loading a profile; just set it explicitly here. - prefs.NoStatefulFiltering.Set(!upArgs.statefulFiltering) - v, warning, err := netfilterModeFromFlag(upArgs.netfilterMode) - if err != nil { - return nil, err - } - prefs.NetfilterMode = v - if warning != "" { - warnf(warning) - } - } - return prefs, nil -} - -// netfilterModeFromFlag returns the preftype.NetfilterMode for the provided -// flag value. It returns a warning if there is something the user should know -// about the value. -func netfilterModeFromFlag(v string) (_ preftype.NetfilterMode, warning string, _ error) { - switch v { - case "on", "nodivert", "off": - default: - return preftype.NetfilterOn, "", fmt.Errorf("invalid value --netfilter-mode=%q", v) - } - m, err := preftype.ParseNetfilterMode(v) - if err != nil { - return preftype.NetfilterOn, "", err - } - switch m { - case preftype.NetfilterNoDivert: - warning = "netfilter=nodivert; add iptables calls to ts-* chains manually." - case preftype.NetfilterOff: - if defaultNetfilterMode() != "off" { - warning = "netfilter=off; configure iptables yourself." - } - } - return m, warning, nil -} - -// updatePrefs returns how to edit preferences based on the -// flag-provided 'prefs' and the currently active 'curPrefs'. -// -// It returns a non-nil justEditMP if we're already running and none of -// the flags require a restart, so we can just do an EditPrefs call and -// change the prefs at runtime (e.g. changing hostname, changing -// advertised routes, etc). -// -// It returns simpleUp if we're running a simple "tailscale up" to -// transition to running from a previously-logged-in but down state, -// without changing any settings. -func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, justEditMP *ipn.MaskedPrefs, err error) { - if !env.upArgs.reset { - applyImplicitPrefs(prefs, curPrefs, env) - - if err := checkForAccidentalSettingReverts(prefs, curPrefs, env); err != nil { - return false, nil, err - } - } - - controlURLChanged := curPrefs.ControlURL != prefs.ControlURL && - !(ipn.IsLoginServerSynonym(curPrefs.ControlURL) && ipn.IsLoginServerSynonym(prefs.ControlURL)) - if controlURLChanged && env.backendState == ipn.Running.String() && !env.upArgs.forceReauth { - return false, nil, fmt.Errorf("can't change --login-server without --force-reauth") - } - - // Do this after validations to avoid the 5s delay if we're going to error - // out anyway. - wantSSH, haveSSH := env.upArgs.runSSH, curPrefs.RunSSH - if err := presentSSHToggleRisk(wantSSH, haveSSH, env.upArgs.acceptedRisks); err != nil { - return false, nil, err - } - - if runtime.GOOS == "darwin" && env.upArgs.advertiseConnector { - if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, env.upArgs.acceptedRisks); err != nil { - 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 && - curPrefs.Persist != nil && - curPrefs.Persist.UserProfile.LoginName != "" && - env.backendState != ipn.NeedsLogin.String() - - justEdit := env.backendState == ipn.Running.String() && - !env.upArgs.forceReauth && - env.upArgs.authKeyOrFile == "" && - !controlURLChanged && - !tagsChanged - - if justEdit { - justEditMP = new(ipn.MaskedPrefs) - justEditMP.WantRunningSet = true - justEditMP.Prefs = *prefs - visitFlags := env.flagSet.Visit - if env.upArgs.reset { - visitFlags = env.flagSet.VisitAll - } - visitFlags(func(f *flag.Flag) { - updateMaskedPrefsFromUpOrSetFlag(justEditMP, f.Name) - }) - } - - return simpleUp, justEditMP, nil -} - -func presentSSHToggleRisk(wantSSH, haveSSH bool, acceptedRisks string) error { - if !isSSHOverTailscale() || wantSSH == haveSSH { - return nil - } - if wantSSH { - return presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`, acceptedRisks) - } - return presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`, acceptedRisks) -} - -func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retErr error) { - var egg bool - if len(args) > 0 { - egg = fmt.Sprint(args) == "[up down down left right left right b a]" - if !egg { - fatalf("too many non-flag arguments: %q", args) - } - } - - st, err := localClient.Status(ctx) - if err != nil { - return fixTailscaledConnectError(err) - } - origAuthURL := st.AuthURL - - // printAuthURL reports whether we should print out the - // provided auth URL from an IPN notify. - printAuthURL := func(url string) bool { - if url == "" { - // Probably unnecessary but we used to have a bug where tailscaled - // could send an empty URL over the IPN bus. ~Harmless to keep. - return false - } - if upArgs.authKeyOrFile != "" { - // Issue 1755: when using an authkey, don't - // show an authURL that might still be pending - // from a previous non-completed interactive - // login. - return false - } - if upArgs.forceReauth && url == origAuthURL { - return false - } - return true - } - - if distro.Get() == distro.Synology { - notSupported := "not supported on Synology; see https://github.com/tailscale/tailscale/issues/1995" - if upArgs.acceptRoutes { - return errors.New("--accept-routes is " + notSupported) - } - if upArgs.exitNodeIP != "" { - return errors.New("--exit-node is " + notSupported) - } - if upArgs.netfilterMode != "off" { - return errors.New("--netfilter-mode values besides \"off\" " + notSupported) - } - } - - prefs, err := prefsFromUpArgs(upArgs, warnf, st, effectiveGOOS()) - if err != nil { - fatalf("%s", err) - } - - warnOnAdvertiseRouts(ctx, prefs) - - curPrefs, err := localClient.GetPrefs(ctx) - if err != nil { - return err - } - if cmd == "up" { - // "tailscale up" should not be able to change the - // profile name. - prefs.ProfileName = curPrefs.ProfileName - } - - env := upCheckEnv{ - goos: effectiveGOOS(), - distro: distro.Get(), - user: os.Getenv("USER"), - flagSet: upFlagSet, - upArgs: upArgs, - backendState: st.BackendState, - curExitNodeIP: exitNodeIP(curPrefs, st), - } - - defer func() { - if retErr == nil { - checkUpWarnings(ctx) - } - }() - - simpleUp, justEditMP, err := updatePrefs(prefs, curPrefs, env) - if err != nil { - fatalf("%s", err) - } - if justEditMP != nil { - justEditMP.EggSet = egg - _, err := localClient.EditPrefs(ctx, justEditMP) - return err - } - - watchCtx, cancelWatch := context.WithCancel(ctx) - defer cancelWatch() - - go func() { - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) - select { - case <-interrupt: - cancelWatch() - case <-watchCtx.Done(): - } - }() - - running := make(chan bool, 1) // gets value once in state ipn.Running - watchErr := make(chan error, 1) - - // Special case: bare "tailscale up" means to just start - // running, if there's ever been a login. - if simpleUp { - _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - WantRunning: true, - }, - WantRunningSet: true, - }) - if err != nil { - return err - } - } else { - if err := localClient.CheckPrefs(ctx, prefs); err != nil { - return err - } - - authKey, err := upArgs.getAuthKey() - if err != nil { - return err - } - authKey, err = resolveAuthKey(ctx, authKey, upArgs.advertiseTags) - if err != nil { - return err - } - err = localClient.Start(ctx, ipn.Options{ - AuthKey: authKey, - UpdatePrefs: prefs, - }) - if err != nil { - return err - } - if upArgs.forceReauth || !st.HaveNodeKey { - err := localClient.StartLoginInteractive(ctx) - if err != nil { - return err - } - } - } - - watcher, err := localClient.WatchIPNBus(watchCtx, ipn.NotifyInitialState) - if err != nil { - return err - } - defer watcher.Close() - - go func() { - var printed bool // whether we've yet printed anything to stdout or stderr - var lastURLPrinted string - - for { - n, err := watcher.Next() - if err != nil { - watchErr <- err - return - } - if n.ErrMessage != nil { - msg := *n.ErrMessage - fatalf("backend error: %v\n", msg) - } - if s := n.State; s != nil { - switch *s { - case ipn.NeedsMachineAuth: - printed = true - if env.upArgs.json { - printUpDoneJSON(ipn.NeedsMachineAuth, "") - } else { - 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 - if env.upArgs.json { - printUpDoneJSON(ipn.Running, "") - } else if printed { - // Only need to print an update if we printed the "please click" message earlier. - fmt.Fprintf(Stderr, "Success.\n") - } - select { - case running <- true: - default: - } - cancelWatch() - } - } - if url := n.BrowseToURL; url != nil { - authURL := *url - if !printAuthURL(authURL) || authURL == lastURLPrinted { - continue - } - printed = true - lastURLPrinted = authURL - if upArgs.json { - js := &upOutputJSON{AuthURL: authURL, BackendState: st.BackendState} - - q, err := qrcode.New(authURL, qrcode.Medium) - if err == nil { - png, err := q.PNG(128) - if err == nil { - js.QR = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) - } - } - - data, err := json.MarshalIndent(js, "", "\t") - if err != nil { - printf("upOutputJSON marshalling error: %v", err) - } else { - outln(string(data)) - } - } else { - fmt.Fprintf(Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", authURL) - if upArgs.qr { - q, err := qrcode.New(authURL, qrcode.Medium) - if err != nil { - log.Printf("QR code error: %v", err) - } else { - fmt.Fprintf(Stderr, "%s\n", q.ToString(false)) - } - } - } - } - } - }() - - // This whole 'up' mechanism is too complicated and results in - // hairy stuff like this select. We're ultimately waiting for - // 'running' to be done, but even in the case where - // it succeeds, other parts may shut down concurrently so we - // need to prioritize reads from 'running' if it's - // readable; its send does happen before the pump mechanism - // shuts down. (Issue 2333) - var timeoutCh <-chan time.Time - if upArgs.timeout > 0 { - timeoutTimer := time.NewTimer(upArgs.timeout) - defer timeoutTimer.Stop() - timeoutCh = timeoutTimer.C - } - select { - case <-running: - return nil - case <-watchCtx.Done(): - select { - case <-running: - return nil - default: - } - return watchCtx.Err() - case err := <-watchErr: - select { - case <-running: - return nil - default: - } - return err - case <-timeoutCh: - return errors.New(`timeout waiting for Tailscale service to enter a Running state; check health with "tailscale status"`) - } -} - -// upWorthWarning reports whether the health check message s is worth warning -// about during "tailscale up". Many of the health checks are noisy or confusing -// or very ephemeral and happen especially briefly at startup. -// -// TODO(bradfitz): change the server to send typed warnings with metadata about -// the health check, rather than just a string. -func upWorthyWarning(s string) bool { - return strings.Contains(s, healthmsg.TailscaleSSHOnBut) || - strings.Contains(s, healthmsg.WarnAcceptRoutesOff) || - strings.Contains(s, healthmsg.LockedOut) || - strings.Contains(s, healthmsg.WarnExitNodeUsage) || - strings.Contains(strings.ToLower(s), "update available: ") -} - -func checkUpWarnings(ctx context.Context) { - st, err := localClient.StatusWithoutPeers(ctx) - if err != nil { - // Ignore. Don't spam more. - return - } - var warn []string - for _, w := range st.Health { - if upWorthyWarning(w) { - warn = append(warn, w) - } - } - if len(warn) == 0 { - return - } - if len(warn) == 1 { - printf("%s\n", warn[0]) - return - } - printf("# Health check warnings:\n") - for _, m := range warn { - printf("# - %s\n", m) - } -} - -func printUpDoneJSON(state ipn.State, errorString string) { - js := &upOutputJSON{BackendState: state.String(), Error: errorString} - data, err := json.MarshalIndent(js, "", " ") - if err != nil { - log.Printf("printUpDoneJSON marshalling error: %v", err) - } else { - outln(string(data)) - } -} - -var ( - prefsOfFlag = map[string][]string{} // "exit-node" => ExitNodeIP, ExitNodeID -) - -func init() { - // Both these have the same ipn.Pref: - addPrefFlagMapping("advertise-exit-node", "AdvertiseRoutes") - addPrefFlagMapping("advertise-routes", "AdvertiseRoutes") - - // And this flag has two ipn.Prefs: - addPrefFlagMapping("exit-node", "ExitNodeIP", "ExitNodeID") - - // The rest are 1:1: - addPrefFlagMapping("accept-dns", "CorpDNS") - addPrefFlagMapping("accept-routes", "RouteAll") - addPrefFlagMapping("advertise-tags", "AdvertiseTags") - addPrefFlagMapping("hostname", "Hostname") - addPrefFlagMapping("login-server", "ControlURL") - addPrefFlagMapping("netfilter-mode", "NetfilterMode") - addPrefFlagMapping("shields-up", "ShieldsUp") - addPrefFlagMapping("snat-subnet-routes", "NoSNAT") - addPrefFlagMapping("stateful-filtering", "NoStatefulFiltering") - addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess") - addPrefFlagMapping("unattended", "ForceDaemon") - addPrefFlagMapping("operator", "OperatorUser") - addPrefFlagMapping("ssh", "RunSSH") - addPrefFlagMapping("webclient", "RunWebClient") - addPrefFlagMapping("nickname", "ProfileName") - addPrefFlagMapping("update-check", "AutoUpdate.Check") - addPrefFlagMapping("auto-update", "AutoUpdate.Apply") - addPrefFlagMapping("advertise-connector", "AppConnector") - addPrefFlagMapping("posture-checking", "PostureChecking") -} - -func addPrefFlagMapping(flagName string, prefNames ...string) { - prefsOfFlag[flagName] = prefNames - prefType := reflect.TypeFor[ipn.Prefs]() - for _, pref := range prefNames { - t := prefType - for _, name := range strings.Split(pref, ".") { - // Crash at runtime if there's a typo in the prefName. - f, ok := t.FieldByName(name) - if !ok { - panic(fmt.Sprintf("invalid ipn.Prefs field %q", pref)) - } - t = f.Type - } - } -} - -// preflessFlag reports whether flagName is a flag that doesn't -// correspond to an ipn.Pref. -func preflessFlag(flagName string) bool { - switch flagName { - case "auth-key", "force-reauth", "reset", "qr", "json", "timeout", "accept-risk", "host-routes": - return true - } - return false -} - -func updateMaskedPrefsFromUpOrSetFlag(mp *ipn.MaskedPrefs, flagName string) { - if preflessFlag(flagName) { - return - } - if prefs, ok := prefsOfFlag[flagName]; ok { - for _, pref := range prefs { - f := reflect.ValueOf(mp).Elem() - for _, name := range strings.Split(pref, ".") { - f = f.FieldByName(name + "Set") - } - f.SetBool(true) - } - return - } - panic(fmt.Sprintf("internal error: unhandled flag %q", flagName)) -} - -const accidentalUpPrefix = "Error: changing settings via 'tailscale up' requires mentioning all\n" + - "non-default flags. To proceed, either re-run your command with --reset or\n" + - "use the command below to explicitly mention the current value of\n" + - "all non-default settings:\n\n" + - "\ttailscale up" - -// upCheckEnv are extra parameters describing the environment as -// needed by checkForAccidentalSettingReverts and friends. -type upCheckEnv struct { - goos string - user string - flagSet *flag.FlagSet - upArgs upArgsT - backendState string - curExitNodeIP netip.Addr - distro distro.Distro -} - -// checkForAccidentalSettingReverts (the "up checker") checks for -// people running "tailscale up" with a subset of the flags they -// originally ran it with. -// -// For example, in Tailscale 1.6 and prior, a user might've advertised -// a tag, but later tried to change just one other setting and forgot -// to mention the tag later and silently wiped it out. We now -// require --reset to change preferences to flag default values when -// the flag is not mentioned on the command line. -// -// curPrefs is what's currently active on the server. -// -// mp is the mask of settings actually set, where mp.Prefs is the new -// preferences to set, including any values set from implicit flags. -func checkForAccidentalSettingReverts(newPrefs, curPrefs *ipn.Prefs, env upCheckEnv) error { - if curPrefs.ControlURL == "" { - // Don't validate things on initial "up" before a control URL has been set. - return nil - } - - flagIsSet := map[string]bool{} - env.flagSet.Visit(func(f *flag.Flag) { - flagIsSet[f.Name] = true - }) - - if len(flagIsSet) == 0 { - // A bare "tailscale up" is a special case to just - // mean bringing the network up without any changes. - return nil - } - - // flagsCur is what flags we'd need to use to keep the exact - // settings as-is. - flagsCur := prefsToFlags(env, curPrefs) - flagsNew := prefsToFlags(env, newPrefs) - - var missing []string - for flagName := range flagsCur { - valCur, valNew := flagsCur[flagName], flagsNew[flagName] - if flagIsSet[flagName] { - continue - } - if reflect.DeepEqual(valCur, valNew) { - continue - } - if flagName == "login-server" && ipn.IsLoginServerSynonym(valCur) && ipn.IsLoginServerSynonym(valNew) { - continue - } - if flagName == "accept-routes" && valNew == false && env.goos == "linux" && env.distro == distro.Synology { - // Issue 3176. Old prefs had 'RouteAll: true' on disk, so ignore that. - continue - } - if flagName == "netfilter-mode" && valNew == preftype.NetfilterOn && env.goos == "linux" && env.distro == distro.Synology { - // Issue 6811. Ignore on Synology. - continue - } - if flagName == "stateful-filtering" && valCur == true && valNew == false && env.goos == "linux" { - // See https://github.com/tailscale/tailscale/issues/12307 - // Stateful filtering was on by default in tailscale 1.66.0-1.66.3, then off in 1.66.4. - // This broke Tailscale installations in containerized - // environments that use the default containerboot - // configuration that configures tailscale using - // 'tailscale up' command, which requires that all - // previously set flags are explicitly provided on - // subsequent restarts. - continue - } - missing = append(missing, fmtFlagValueArg(flagName, valCur)) - } - if len(missing) == 0 { - return nil - } - - // Some previously provided flags are missing. This run of 'tailscale - // up' will error out. - - sort.Strings(missing) - - // Compute the stringification of the explicitly provided args in flagSet - // to prepend to the command to run. - var explicit []string - env.flagSet.Visit(func(f *flag.Flag) { - type isBool interface { - IsBoolFlag() bool - } - if ib, ok := f.Value.(isBool); ok && ib.IsBoolFlag() { - if f.Value.String() == "false" { - explicit = append(explicit, "--"+f.Name+"=false") - } else { - explicit = append(explicit, "--"+f.Name) - } - } else { - explicit = append(explicit, fmtFlagValueArg(f.Name, f.Value.String())) - } - }) - - var sb strings.Builder - sb.WriteString(accidentalUpPrefix) - - for _, a := range append(explicit, missing...) { - fmt.Fprintf(&sb, " %s", a) - } - sb.WriteString("\n\n") - return errors.New(sb.String()) -} - -// applyImplicitPrefs mutates prefs to add implicit preferences for the user operator. -// If the operator flag is passed no action is taken, otherwise this only needs to be set if it doesn't -// match the current user. -// -// curUser is os.Getenv("USER"). It's pulled out for testability. -func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, env upCheckEnv) { - explicitOperator := false - env.flagSet.Visit(func(f *flag.Flag) { - if f.Name == "operator" { - explicitOperator = true - } - }) - - if prefs.OperatorUser == "" && oldPrefs.OperatorUser == env.user && !explicitOperator { - prefs.OperatorUser = oldPrefs.OperatorUser - } -} - -func flagAppliesToOS(flag, goos string) bool { - switch flag { - case "netfilter-mode", "snat-subnet-routes", "stateful-filtering": - return goos == "linux" - case "unattended": - return goos == "windows" - } - return true -} - -func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) { - ret := make(map[string]any) - - exitNodeIPStr := func() string { - if prefs.ExitNodeIP.IsValid() { - return prefs.ExitNodeIP.String() - } - if prefs.ExitNodeID.IsZero() || !env.curExitNodeIP.IsValid() { - return "" - } - return env.curExitNodeIP.String() - } - - fs := newUpFlagSet(env.goos, new(upArgsT) /* dummy */, "up") - fs.VisitAll(func(f *flag.Flag) { - if preflessFlag(f.Name) { - return - } - set := func(v any) { - if flagAppliesToOS(f.Name, env.goos) { - ret[f.Name] = v - } else { - ret[f.Name] = nil - } - } - switch f.Name { - default: - panic(fmt.Sprintf("unhandled flag %q", f.Name)) - case "ssh": - set(prefs.RunSSH) - case "webclient": - set(prefs.RunWebClient) - case "login-server": - set(prefs.ControlURL) - case "accept-routes": - set(prefs.RouteAll) - case "accept-dns": - set(prefs.CorpDNS) - case "shields-up": - set(prefs.ShieldsUp) - case "exit-node": - set(exitNodeIPStr()) - case "exit-node-allow-lan-access": - set(prefs.ExitNodeAllowLANAccess) - case "advertise-tags": - set(strings.Join(prefs.AdvertiseTags, ",")) - case "hostname": - set(prefs.Hostname) - case "operator": - set(prefs.OperatorUser) - case "advertise-routes": - var sb strings.Builder - for i, r := range tsaddr.WithoutExitRoutes(views.SliceOf(prefs.AdvertiseRoutes)).All() { - if i > 0 { - sb.WriteByte(',') - } - sb.WriteString(r.String()) - } - set(sb.String()) - case "advertise-exit-node": - set(tsaddr.ContainsExitRoutes(views.SliceOf(prefs.AdvertiseRoutes))) - case "advertise-connector": - set(prefs.AppConnector.Advertise) - case "snat-subnet-routes": - set(!prefs.NoSNAT) - case "stateful-filtering": - // We only set the stateful-filtering flag to false if - // the pref (negated!) is explicitly set to true; unset - // or false is treated as enabled. - val, ok := prefs.NoStatefulFiltering.Get() - if ok && val { - set(false) - } else { - set(true) - } - case "netfilter-mode": - set(prefs.NetfilterMode.String()) - case "unattended": - set(prefs.ForceDaemon) - } - }) - return ret -} - -func fmtFlagValueArg(flagName string, val any) string { - if val == true { - return "--" + flagName - } - if val == "" { - return "--" + flagName + "=" - } - return fmt.Sprintf("--%s=%v", flagName, shellquote.Join(fmt.Sprint(val))) -} - -// exitNodeIP returns the exit node IP from p, using st to map -// it from its ID form to an IP address if needed. -func exitNodeIP(p *ipn.Prefs, st *ipnstate.Status) (ip netip.Addr) { - if p == nil { - return - } - if p.ExitNodeIP.IsValid() { - return p.ExitNodeIP - } - id := p.ExitNodeID - if id.IsZero() { - return - } - for _, p := range st.Peer { - if p.ID == id { - if len(p.TailscaleIPs) > 0 { - return p.TailscaleIPs[0] - } - break - } - } - return -} - -func init() { - // Required to use our client API. We're fine with the instability since the - // client lives in the same repo as this code. - tailscale.I_Acknowledge_This_API_Is_Unstable = true -} - -// resolveAuthKey either returns v unchanged (in the common case) or, if it -// starts with "tskey-client-" (as Tailscale OAuth secrets do) parses it like -// -// tskey-client-xxxx[?ephemeral=false&bar&preauthorized=BOOL&baseURL=...] -// -// and does the OAuth2 dance to get and return an authkey. The "ephemeral" -// property defaults to true if unspecified. The "preauthorized" defaults to -// false. The "baseURL" defaults to https://api.tailscale.com. -// The passed in tags are required, and must be non-empty. These will be -// set on the authkey generated by the OAuth2 dance. -func resolveAuthKey(ctx context.Context, v, tags string) (string, error) { - if !strings.HasPrefix(v, "tskey-client-") { - return v, nil - } - if tags == "" { - return "", errors.New("oauth authkeys require --advertise-tags") - } - - clientSecret, named, _ := strings.Cut(v, "?") - attrs, err := url.ParseQuery(named) - if err != nil { - return "", err - } - for k := range attrs { - switch k { - case "ephemeral", "preauthorized", "baseURL": - default: - return "", fmt.Errorf("unknown attribute %q", k) - } - } - getBool := func(name string, def bool) (bool, error) { - v := attrs.Get(name) - if v == "" { - return def, nil - } - ret, err := strconv.ParseBool(v) - if err != nil { - return false, fmt.Errorf("invalid attribute boolean attribute %s value %q", name, v) - } - return ret, nil - } - ephemeral, err := getBool("ephemeral", true) - if err != nil { - return "", err - } - preauth, err := getBool("preauthorized", false) - if err != nil { - return "", err - } - - baseURL := "https://api.tailscale.com" - if v := attrs.Get("baseURL"); v != "" { - baseURL = v - } - - credentials := clientcredentials.Config{ - ClientID: "some-client-id", // ignored - ClientSecret: clientSecret, - TokenURL: baseURL + "/api/v2/oauth/token", - Scopes: []string{"device"}, - } - - tsClient := tailscale.NewClient("-", nil) - tsClient.UserAgent = "tailscale-cli" - tsClient.HTTPClient = credentials.Client(ctx) - tsClient.BaseURL = baseURL - - caps := tailscale.KeyCapabilities{ - Devices: tailscale.KeyDeviceCapabilities{ - Create: tailscale.KeyDeviceCreateCapabilities{ - Reusable: false, - Ephemeral: ephemeral, - Preauthorized: preauth, - Tags: strings.Split(tags, ","), - }, - }, - } - - authkey, _, err := tsClient.CreateKey(ctx, caps) - if err != nil { - return "", err - } - return authkey, nil -} - -func warnOnAdvertiseRouts(ctx context.Context, prefs *ipn.Prefs) { - if len(prefs.AdvertiseRoutes) > 0 || prefs.AppConnector.Advertise { - // TODO(jwhited): compress CheckIPForwarding and CheckUDPGROForwarding - // into a single HTTP req. - if err := localClient.CheckIPForwarding(ctx); err != nil { - warnf("%v", err) - } - if runtime.GOOS == "linux" { - if err := localClient.CheckUDPGROForwarding(ctx); err != nil { - warnf("%v", err) - } - } - } -} diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go deleted file mode 100644 index 69d1aa97b43f7..0000000000000 --- a/cmd/tailscale/cli/update.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "runtime" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/clientupdate" - "tailscale.com/version" - "tailscale.com/version/distro" -) - -var updateCmd = &ffcli.Command{ - Name: "update", - ShortUsage: "tailscale update", - ShortHelp: "Update Tailscale to the latest/different version", - Exec: runUpdate, - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("update") - fs.BoolVar(&updateArgs.yes, "yes", false, "update without interactive prompts") - fs.BoolVar(&updateArgs.dryRun, "dry-run", false, "print what update would do without doing it, or prompts") - // These flags are not supported on several systems that only provide - // the latest version of Tailscale: - // - // - Arch (and other pacman-based distros) - // - Alpine (and other apk-based distros) - // - FreeBSD (and other pkg-based distros) - // - Unraid/QNAP/Synology - // - macOS - if distro.Get() != distro.Arch && - distro.Get() != distro.Alpine && - distro.Get() != distro.QNAP && - distro.Get() != distro.Synology && - runtime.GOOS != "freebsd" && - runtime.GOOS != "darwin" { - fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`) - fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`) - } - return fs - })(), -} - -var updateArgs struct { - yes bool - dryRun bool - track string // explicit track; empty means same as current - version string // explicit version; empty means auto -} - -func runUpdate(ctx context.Context, args []string) error { - if len(args) > 0 { - return flag.ErrHelp - } - if updateArgs.version != "" && updateArgs.track != "" { - return errors.New("cannot specify both --version and --track") - } - err := clientupdate.Update(clientupdate.Arguments{ - Version: updateArgs.version, - Track: updateArgs.track, - Logf: func(f string, a ...any) { printf(f+"\n", a...) }, - Stdout: Stdout, - Stderr: Stderr, - Confirm: confirmUpdate, - }) - if errors.Is(err, errors.ErrUnsupported) { - return errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates") - } - return err -} - -func confirmUpdate(ver string) bool { - if updateArgs.yes { - fmt.Printf("Updating Tailscale from %v to %v; --yes given, continuing without prompts.\n", version.Short(), ver) - return true - } - - if updateArgs.dryRun { - fmt.Printf("Current: %v, Latest: %v\n", version.Short(), ver) - return false - } - - msg := fmt.Sprintf("This will update Tailscale from %v to %v. Continue?", version.Short(), ver) - return promptYesNo(msg) -} - -// PromptYesNo takes a question and prompts the user to answer the -// question with a yes or no. It appends a [y/n] to the message. -func promptYesNo(msg string) bool { - fmt.Print(msg + " [y/n] ") - var resp string - fmt.Scanln(&resp) - resp = strings.ToLower(resp) - switch resp { - case "y", "yes", "sure": - return true - } - return false -} diff --git a/cmd/tailscale/cli/version.go b/cmd/tailscale/cli/version.go deleted file mode 100644 index b25502d5a4be5..0000000000000 --- a/cmd/tailscale/cli/version.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "flag" - "fmt" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/clientupdate" - "tailscale.com/ipn/ipnstate" - "tailscale.com/version" -) - -var versionCmd = &ffcli.Command{ - Name: "version", - ShortUsage: "tailscale version [flags]", - ShortHelp: "Print Tailscale version", - FlagSet: (func() *flag.FlagSet { - fs := newFlagSet("version") - fs.BoolVar(&versionArgs.daemon, "daemon", false, "also print local node's daemon version") - fs.BoolVar(&versionArgs.json, "json", false, "output in JSON format") - fs.BoolVar(&versionArgs.upstream, "upstream", false, "fetch and print the latest upstream release version from pkgs.tailscale.com") - return fs - })(), - Exec: runVersion, -} - -var versionArgs struct { - daemon bool // also check local node's daemon version - json bool - upstream bool -} - -func runVersion(ctx context.Context, args []string) error { - if len(args) > 0 { - return fmt.Errorf("too many non-flag arguments: %q", args) - } - var err error - var st *ipnstate.Status - - if versionArgs.daemon { - st, err = localClient.StatusWithoutPeers(ctx) - if err != nil { - return err - } - } - - var upstreamVer string - if versionArgs.upstream { - upstreamVer, err = clientupdate.LatestTailscaleVersion(clientupdate.CurrentTrack) - if err != nil { - return err - } - } - - if versionArgs.json { - m := version.GetMeta() - if st != nil { - m.DaemonLong = st.Version - } - out := struct { - version.Meta - Upstream string `json:"upstream,omitempty"` - }{ - Meta: m, - Upstream: upstreamVer, - } - e := json.NewEncoder(Stdout) - e.SetIndent("", "\t") - return e.Encode(out) - } - - if st == nil { - outln(version.String()) - if versionArgs.upstream { - printf(" upstream: %s\n", upstreamVer) - } - return nil - } - printf("Client: %s\n", version.String()) - printf("Daemon: %s\n", st.Version) - if versionArgs.upstream { - printf("Upstream: %s\n", upstreamVer) - } - return nil -} diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go deleted file mode 100644 index e209d388eb123..0000000000000 --- a/cmd/tailscale/cli/web.go +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "cmp" - "context" - "crypto/tls" - _ "embed" - "flag" - "fmt" - "log" - "net" - "net/http" - "net/http/cgi" - "net/netip" - "os" - "os/signal" - "strings" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/web" - "tailscale.com/ipn" -) - -var webCmd = &ffcli.Command{ - Name: "web", - ShortUsage: "tailscale web [flags]", - ShortHelp: "Run a web server for controlling Tailscale", - - LongHelp: strings.TrimSpace(` -"tailscale web" runs a webserver for controlling the Tailscale daemon. - -It's primarily intended for use on Synology, QNAP, and other -NAS devices where a web interface is the natural place to control -Tailscale, as opposed to a CLI or a native app. -`), - - FlagSet: (func() *flag.FlagSet { - webf := newFlagSet("web") - webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic") - webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script") - webf.StringVar(&webArgs.prefix, "prefix", "", "URL prefix added to requests (for cgi or reverse proxies)") - webf.BoolVar(&webArgs.readonly, "readonly", false, "run web UI in read-only mode") - return webf - })(), - Exec: runWeb, -} - -var webArgs struct { - listen string - cgi bool - prefix string - readonly bool -} - -func tlsConfigFromEnvironment() *tls.Config { - crt := os.Getenv("TLS_CRT_PEM") - key := os.Getenv("TLS_KEY_PEM") - if crt == "" || key == "" { - return nil - } - - // We support passing in the complete certificate and key from environment - // variables because pfSense stores its cert+key in the PHP config. We populate - // TLS_CRT_PEM and TLS_KEY_PEM from PHP code before starting tailscale web. - // These are the PEM-encoded Certificate and Private Key. - - cert, err := tls.X509KeyPair([]byte(crt), []byte(key)) - if err != nil { - log.Printf("tlsConfigFromEnvironment: %v", err) - - // Fallback to unencrypted HTTP. - return nil - } - - return &tls.Config{Certificates: []tls.Certificate{cert}} -} - -func runWeb(ctx context.Context, args []string) error { - ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) - defer cancel() - - if len(args) > 0 { - return fmt.Errorf("too many non-flag arguments: %q", args) - } - - var selfIP netip.Addr - st, err := localClient.StatusWithoutPeers(ctx) - if err == nil && st.Self != nil && len(st.Self.TailscaleIPs) > 0 { - selfIP = st.Self.TailscaleIPs[0] - } - - var existingWebClient bool - if prefs, err := localClient.GetPrefs(ctx); err == nil { - existingWebClient = prefs.RunWebClient - } - var startedManagementClient bool // we started the management client - if !existingWebClient && !webArgs.readonly { - // Also start full client in tailscaled. - log.Printf("starting tailscaled web client at http://%s\n", netip.AddrPortFrom(selfIP, web.ListenPort)) - if err := setRunWebClient(ctx, true); err != nil { - return fmt.Errorf("starting web client in tailscaled: %w", err) - } - startedManagementClient = true - } - - opts := web.ServerOpts{ - Mode: web.LoginServerMode, - CGIMode: webArgs.cgi, - PathPrefix: webArgs.prefix, - LocalClient: &localClient, - } - if webArgs.readonly { - opts.Mode = web.ReadOnlyServerMode - } - webServer, err := web.NewServer(opts) - if err != nil { - log.Printf("tailscale.web: %v", err) - return err - } - go func() { - select { - case <-ctx.Done(): - // Shutdown the server. - webServer.Shutdown() - if !webArgs.cgi && startedManagementClient { - log.Println("stopping tailscaled web client") - // When not in cgi mode, shut down the tailscaled - // web client on cli termination if we started it. - if err := setRunWebClient(context.Background(), false); err != nil { - log.Printf("stopping tailscaled web client: %v", err) - } - } - } - os.Exit(0) - }() - - if webArgs.cgi { - if err := cgi.Serve(webServer); err != nil { - log.Printf("tailscale.cgi: %v", err) - } - return nil - } else if tlsConfig := tlsConfigFromEnvironment(); tlsConfig != nil { - server := &http.Server{ - Addr: webArgs.listen, - TLSConfig: tlsConfig, - Handler: webServer, - } - defer server.Shutdown(ctx) - log.Printf("web server running on: https://%s", server.Addr) - return server.ListenAndServeTLS("", "") - } else { - log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen)) - return http.ListenAndServe(webArgs.listen, webServer) - } -} - -func setRunWebClient(ctx context.Context, val bool) error { - _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{RunWebClient: val}, - RunWebClientSet: true, - }) - return err -} - -// urlOfListenAddr parses a given listen address into a formatted URL -func urlOfListenAddr(addr string) string { - host, port, _ := net.SplitHostPort(addr) - return fmt.Sprintf("http://%s", net.JoinHostPort(cmp.Or(host, "127.0.0.1"), port)) -} diff --git a/cmd/tailscale/cli/web_test.go b/cmd/tailscale/cli/web_test.go deleted file mode 100644 index f2470b364c41e..0000000000000 --- a/cmd/tailscale/cli/web_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "testing" -) - -func TestUrlOfListenAddr(t *testing.T) { - tests := []struct { - name string - in, want string - }{ - { - name: "TestLocalhost", - in: "localhost:8088", - want: "http://localhost:8088", - }, - { - name: "TestNoHost", - in: ":8088", - want: "http://127.0.0.1:8088", - }, - { - name: "TestExplicitHost", - in: "127.0.0.2:8088", - want: "http://127.0.0.2:8088", - }, - { - name: "TestIPv6", - in: "[::1]:8088", - want: "http://[::1]:8088", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - u := urlOfListenAddr(tt.in) - if u != tt.want { - t.Errorf("expected url: %q, got: %q", tt.want, u) - } - }) - } -} diff --git a/cmd/tailscale/cli/whois.go b/cmd/tailscale/cli/whois.go deleted file mode 100644 index 44ff68dec8777..0000000000000 --- a/cmd/tailscale/cli/whois.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cli - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "strings" - "text/tabwriter" - - "github.com/peterbourgon/ff/v3/ffcli" -) - -var whoisCmd = &ffcli.Command{ - Name: "whois", - ShortUsage: "tailscale whois [--json] ip[:port]", - ShortHelp: "Show the machine and user associated with a Tailscale IP (v4 or v6)", - LongHelp: strings.TrimSpace(` - 'tailscale whois' shows the machine and user associated with a Tailscale IP (v4 or v6). - `), - Exec: runWhoIs, - FlagSet: func() *flag.FlagSet { - fs := newFlagSet("whois") - fs.BoolVar(&whoIsArgs.json, "json", false, "output in JSON format") - fs.StringVar(&whoIsArgs.proto, "proto", "", `protocol; one of "tcp" or "udp"; empty mans both `) - return fs - }(), -} - -var whoIsArgs struct { - json bool // output in JSON format - proto string // "tcp" or "udp" -} - -func runWhoIs(ctx context.Context, args []string) error { - if len(args) > 1 { - return errors.New("too many arguments, expected at most one peer") - } else if len(args) == 0 { - return errors.New("missing argument, expected one peer") - } - who, err := localClient.WhoIsProto(ctx, whoIsArgs.proto, args[0]) - if err != nil { - return err - } - if whoIsArgs.json { - ec := json.NewEncoder(Stdout) - ec.SetIndent("", " ") - ec.Encode(who) - return nil - } - - w := tabwriter.NewWriter(Stdout, 10, 5, 5, ' ', 0) - fmt.Fprintf(w, "Machine:\n") - fmt.Fprintf(w, " Name:\t%s\n", strings.TrimSuffix(who.Node.Name, ".")) - fmt.Fprintf(w, " ID:\t%s\n", who.Node.StableID) - fmt.Fprintf(w, " Addresses:\t%s\n", who.Node.Addresses) - if len(who.Node.AllowedIPs) > 2 { - fmt.Fprintf(w, " AllowedIPs:\t%s\n", who.Node.AllowedIPs[2:]) - } - if who.Node.IsTagged() { - fmt.Fprintf(w, " Tags:\t%s\n", strings.Join(who.Node.Tags, ", ")) - } else { - fmt.Fprintln(w, "User:") - fmt.Fprintf(w, " Name:\t%s\n", who.UserProfile.LoginName) - fmt.Fprintf(w, " ID:\t%d\n", who.UserProfile.ID) - } - w.Flush() - w = nil // avoid accidental use - - if cm := who.CapMap; len(cm) > 0 { - printf("Capabilities:\n") - for cap, vals := range cm { - // To make the output more readable, we have to reindent the JSON - // values so they line up with the cap name. - if len(vals) > 0 { - v, _ := json.MarshalIndent(vals, " ", " ") - - printf(" - %s:\n", cap) - printf(" %s\n", v) - } else { - printf(" - %s\n", cap) - } - } - } - return nil -} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt deleted file mode 100644 index d18d8887327fa..0000000000000 --- a/cmd/tailscale/depaware.txt +++ /dev/null @@ -1,339 +0,0 @@ -tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/depaware) - - filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus - filippo.io/edwards25519/field from filippo.io/edwards25519 - 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 - L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw - W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+ - W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode - github.com/fxamacker/cbor/v2 from tailscale.com/tka - github.com/go-json-experiment/json from tailscale.com/types/opt+ - github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ - github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ - github.com/golang/groupcache/lru from tailscale.com/net/dnscache - L github.com/google/nftables from tailscale.com/util/linuxfw - L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt - L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+ - L github.com/google/nftables/expr from github.com/google/nftables+ - L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ - L github.com/google/nftables/xt from github.com/google/nftables/expr+ - DW github.com/google/uuid from tailscale.com/clientupdate+ - github.com/gorilla/csrf from tailscale.com/client/web - github.com/gorilla/securecookie from github.com/gorilla/csrf - github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ - L github.com/josharian/native from github.com/mdlayher/netlink+ - L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon - L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink - github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli - 💣 github.com/mattn/go-colorable from tailscale.com/cmd/tailscale/cli - 💣 github.com/mattn/go-isatty from tailscale.com/cmd/tailscale/cli+ - L 💣 github.com/mdlayher/netlink from github.com/google/nftables+ - L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ - L github.com/mdlayher/netlink/nltest from github.com/google/nftables - L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink - github.com/miekg/dns from tailscale.com/net/dns/recursive - 💣 github.com/mitchellh/go-ps from tailscale.com/cmd/tailscale/cli+ - github.com/peterbourgon/ff/v3 from github.com/peterbourgon/ff/v3/ffcli+ - github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli+ - github.com/peterbourgon/ff/v3/internal from github.com/peterbourgon/ff/v3 - github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli - github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+ - github.com/skip2/go-qrcode/reedsolomon from github.com/skip2/go-qrcode - W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket - W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio - W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio - W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs - W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+ - github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper - github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+ - github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp - github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+ - github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp - L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw - L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink - github.com/tailscale/web-client-prebuilt from tailscale.com/client/web - github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck - github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli - L github.com/vishvananda/netns from github.com/tailscale/netlink+ - github.com/x448/float16 from github.com/fxamacker/cbor/v2 - 💣 go4.org/mem from tailscale.com/client/tailscale+ - go4.org/netipx from tailscale.com/net/tsaddr - W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+ - k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli - sigs.k8s.io/yaml from tailscale.com/cmd/tailscale/cli - sigs.k8s.io/yaml/goyaml.v2 from sigs.k8s.io/yaml - software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli - software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12 - tailscale.com from tailscale.com/version - tailscale.com/atomicfile from tailscale.com/cmd/tailscale/cli+ - tailscale.com/client/tailscale from tailscale.com/client/web+ - tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+ - tailscale.com/client/web from tailscale.com/cmd/tailscale/cli - tailscale.com/clientupdate from tailscale.com/client/web+ - LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate - tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale - tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli - tailscale.com/cmd/tailscale/cli/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete - tailscale.com/control/controlbase from tailscale.com/control/controlhttp+ - tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli - tailscale.com/control/controlhttp/controlhttpcommon from tailscale.com/control/controlhttp - tailscale.com/control/controlknobs from tailscale.com/net/portmapper - tailscale.com/derp from tailscale.com/derp/derphttp - tailscale.com/derp/derphttp from tailscale.com/net/netcheck - tailscale.com/disco from tailscale.com/derp - tailscale.com/drive from tailscale.com/client/tailscale+ - tailscale.com/envknob from tailscale.com/client/tailscale+ - tailscale.com/envknob/featureknob from tailscale.com/client/web - tailscale.com/health from tailscale.com/net/tlsdial+ - tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli - tailscale.com/hostinfo from tailscale.com/client/web+ - tailscale.com/internal/noiseconn from tailscale.com/cmd/tailscale/cli - tailscale.com/ipn from tailscale.com/client/tailscale+ - tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+ - tailscale.com/kube/kubetypes from tailscale.com/envknob - tailscale.com/licenses from tailscale.com/client/web+ - tailscale.com/metrics from tailscale.com/derp+ - tailscale.com/net/captivedetection from tailscale.com/net/netcheck - tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback - tailscale.com/net/dnscache from tailscale.com/control/controlhttp+ - tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp+ - tailscale.com/net/flowtrack from tailscale.com/net/packet - tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli - tailscale.com/net/neterror from tailscale.com/net/netcheck+ - tailscale.com/net/netknob from tailscale.com/net/netns - 💣 tailscale.com/net/netmon from tailscale.com/cmd/tailscale/cli+ - 💣 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/capture - tailscale.com/net/ping from tailscale.com/net/netcheck - tailscale.com/net/portmapper from tailscale.com/cmd/tailscale/cli+ - tailscale.com/net/sockstats from tailscale.com/control/controlhttp+ - tailscale.com/net/stun from tailscale.com/net/netcheck - L tailscale.com/net/tcpinfo from tailscale.com/derp - tailscale.com/net/tlsdial from tailscale.com/cmd/tailscale/cli+ - tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial - tailscale.com/net/tsaddr from tailscale.com/client/web+ - 💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+ - tailscale.com/paths from tailscale.com/client/tailscale+ - 💣 tailscale.com/safesocket from tailscale.com/client/tailscale+ - tailscale.com/syncs from tailscale.com/cmd/tailscale/cli+ - tailscale.com/tailcfg from tailscale.com/client/tailscale+ - tailscale.com/tempfork/spf13/cobra from tailscale.com/cmd/tailscale/cli/ffcomplete+ - tailscale.com/tka from tailscale.com/client/tailscale+ - tailscale.com/tsconst from tailscale.com/net/netmon+ - tailscale.com/tstime from tailscale.com/control/controlhttp+ - tailscale.com/tstime/mono from tailscale.com/tstime/rate - tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli+ - tailscale.com/tsweb/varz from tailscale.com/util/usermetric - 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/client/tailscale+ - tailscale.com/types/lazy from tailscale.com/util/testenv+ - tailscale.com/types/logger from tailscale.com/client/web+ - tailscale.com/types/netmap from tailscale.com/ipn+ - tailscale.com/types/nettype from tailscale.com/net/netcheck+ - tailscale.com/types/opt from tailscale.com/client/tailscale+ - tailscale.com/types/persist from tailscale.com/ipn - tailscale.com/types/preftype from tailscale.com/cmd/tailscale/cli+ - tailscale.com/types/ptr from tailscale.com/hostinfo+ - tailscale.com/types/result from tailscale.com/util/lineiter - tailscale.com/types/structs from tailscale.com/ipn+ - tailscale.com/types/tkatype from tailscale.com/types/key+ - tailscale.com/types/views from tailscale.com/tailcfg+ - tailscale.com/util/cibuild from tailscale.com/health - tailscale.com/util/clientmetric from tailscale.com/net/netcheck+ - tailscale.com/util/cloudenv from tailscale.com/net/dnscache+ - tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+ - tailscale.com/util/ctxkey from tailscale.com/types/logger+ - 💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting - L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics - tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+ - tailscale.com/util/groupmember from tailscale.com/client/web - 💣 tailscale.com/util/hashx from tailscale.com/util/deephash - tailscale.com/util/httpm from tailscale.com/client/tailscale+ - tailscale.com/util/lineiter from tailscale.com/hostinfo+ - L tailscale.com/util/linuxfw from tailscale.com/net/netns - tailscale.com/util/mak from tailscale.com/cmd/tailscale/cli+ - tailscale.com/util/multierr from tailscale.com/control/controlhttp+ - tailscale.com/util/must from tailscale.com/clientupdate/distsign+ - tailscale.com/util/nocasemaps from tailscale.com/types/ipproto - tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli - tailscale.com/util/set from tailscale.com/derp+ - tailscale.com/util/singleflight from tailscale.com/net/dnscache+ - tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+ - tailscale.com/util/syspolicy from tailscale.com/ipn - tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ - tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+ - tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source - tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy - tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+ - tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+ - tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli+ - tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli - tailscale.com/util/usermetric from tailscale.com/health - tailscale.com/util/vizerror from tailscale.com/tailcfg+ - W 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+ - W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate - W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source - W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ - tailscale.com/version from tailscale.com/client/web+ - tailscale.com/version/distro from tailscale.com/client/web+ - tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli - tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap - golang.org/x/crypto/argon2 from tailscale.com/tka - golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ - golang.org/x/crypto/blake2s from tailscale.com/clientupdate/distsign+ - golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 - golang.org/x/crypto/chacha20poly1305 from crypto/tls+ - golang.org/x/crypto/cryptobyte from crypto/ecdsa+ - golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+ - golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/hkdf from crypto/tls+ - golang.org/x/crypto/nacl/box from tailscale.com/types/key - golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box - golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12 - golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/sha3 from crypto/internal/mlkem768+ - W golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+ - golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli+ - 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/http2 from tailscale.com/cmd/tailscale/cli+ - golang.org/x/net/http2/hpack from net/http+ - golang.org/x/net/icmp from tailscale.com/net/ping - golang.org/x/net/idna from golang.org/x/net/http/httpguts+ - golang.org/x/net/ipv4 from github.com/miekg/dns+ - golang.org/x/net/ipv6 from github.com/miekg/dns+ - golang.org/x/net/proxy from tailscale.com/net/netns - D golang.org/x/net/route from net+ - golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials - golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/tailscale/cli - golang.org/x/oauth2/internal from golang.org/x/oauth2+ - golang.org/x/sync/errgroup from github.com/mdlayher/socket+ - golang.org/x/sys/cpu from github.com/josharian/native+ - LD golang.org/x/sys/unix from github.com/google/nftables+ - W golang.org/x/sys/windows from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+ - W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil - golang.org/x/text/secure/bidirule from golang.org/x/net/idna - golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ - golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ - golang.org/x/text/unicode/norm from golang.org/x/net/idna - golang.org/x/time/rate from tailscale.com/cmd/tailscale/cli+ - archive/tar from tailscale.com/clientupdate - bufio from compress/flate+ - bytes from archive/tar+ - cmp from slices+ - compress/flate from compress/gzip+ - compress/gzip from net/http+ - compress/zlib from debug/pe+ - container/list from crypto/tls+ - context from crypto/tls+ - crypto from crypto/ecdh+ - crypto/aes from crypto/ecdsa+ - crypto/cipher from crypto/aes+ - crypto/des from crypto/tls+ - crypto/dsa from crypto/x509 - crypto/ecdh from crypto/ecdsa+ - crypto/ecdsa from crypto/tls+ - crypto/ed25519 from crypto/tls+ - crypto/elliptic from crypto/ecdsa+ - crypto/hmac from crypto/tls+ - crypto/md5 from crypto/tls+ - crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls - crypto/rsa from crypto/tls+ - crypto/sha1 from crypto/tls+ - crypto/sha256 from crypto/tls+ - crypto/sha512 from crypto/ecdsa+ - crypto/subtle from crypto/aes+ - crypto/tls from github.com/miekg/dns+ - crypto/x509 from crypto/tls+ - crypto/x509/pkix from crypto/x509+ - DW database/sql/driver from github.com/google/uuid - W debug/dwarf from debug/pe - W debug/pe from github.com/dblohm7/wingoes/pe - embed from crypto/internal/nistec+ - encoding from encoding/gob+ - encoding/asn1 from crypto/x509+ - encoding/base32 from github.com/fxamacker/cbor/v2+ - encoding/base64 from encoding/json+ - encoding/binary from compress/gzip+ - encoding/gob from github.com/gorilla/securecookie - encoding/hex from crypto/x509+ - encoding/json from expvar+ - encoding/pem from crypto/tls+ - encoding/xml from github.com/tailscale/goupnp+ - errors from archive/tar+ - expvar from tailscale.com/derp+ - flag from github.com/peterbourgon/ff/v3+ - fmt from archive/tar+ - hash from compress/zlib+ - hash/adler32 from compress/zlib - hash/crc32 from compress/gzip+ - hash/maphash from go4.org/mem - html from html/template+ - html/template from github.com/gorilla/csrf - image from github.com/skip2/go-qrcode+ - image/color from github.com/skip2/go-qrcode+ - image/png from github.com/skip2/go-qrcode - io from archive/tar+ - io/fs from archive/tar+ - io/ioutil from github.com/mitchellh/go-ps+ - iter from maps+ - log from expvar+ - log/internal from log - maps from tailscale.com/clientupdate+ - math from archive/tar+ - math/big from crypto/dsa+ - math/bits from compress/flate+ - math/rand from github.com/mdlayher/netlink+ - math/rand/v2 from tailscale.com/derp+ - mime from golang.org/x/oauth2/internal+ - mime/multipart from net/http - mime/quotedprintable from mime/multipart - net from crypto/tls+ - net/http from expvar+ - net/http/cgi from tailscale.com/cmd/tailscale/cli - net/http/httptrace from github.com/tcnksm/go-httpstat+ - net/http/httputil from tailscale.com/client/web+ - net/http/internal from net/http+ - net/netip from go4.org/netipx+ - net/textproto from golang.org/x/net/http/httpguts+ - net/url from crypto/x509+ - os from crypto/rand+ - os/exec from github.com/coreos/go-iptables/iptables+ - os/signal from tailscale.com/cmd/tailscale/cli - os/user from archive/tar+ - path from archive/tar+ - path/filepath from archive/tar+ - reflect from archive/tar+ - regexp from github.com/coreos/go-iptables/iptables+ - regexp/syntax from regexp - runtime/debug from tailscale.com+ - slices from tailscale.com/client/web+ - sort from compress/flate+ - strconv from archive/tar+ - strings from archive/tar+ - sync from archive/tar+ - sync/atomic from context+ - syscall from archive/tar+ - text/tabwriter from github.com/peterbourgon/ff/v3/ffcli+ - text/template from html/template - text/template/parse from html/template+ - time from archive/tar+ - unicode from bytes+ - unicode/utf16 from crypto/x509+ - unicode/utf8 from bufio+ - unique from net/netip diff --git a/cmd/tailscale/generate.go b/cmd/tailscale/generate.go deleted file mode 100644 index 5c2e9be915980..0000000000000 --- a/cmd/tailscale/generate.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -//go:generate go run tailscale.com/cmd/mkmanifest amd64 windows-manifest.xml manifest_windows_amd64.syso -//go:generate go run tailscale.com/cmd/mkmanifest 386 windows-manifest.xml manifest_windows_386.syso -//go:generate go run tailscale.com/cmd/mkmanifest arm64 windows-manifest.xml manifest_windows_arm64.syso diff --git a/cmd/tailscale/manifest_windows_386.syso b/cmd/tailscale/manifest_windows_386.syso deleted file mode 100644 index ac4915862f63f..0000000000000 Binary files a/cmd/tailscale/manifest_windows_386.syso and /dev/null differ diff --git a/cmd/tailscale/manifest_windows_amd64.syso b/cmd/tailscale/manifest_windows_amd64.syso deleted file mode 100644 index 3a22f2cdffca1..0000000000000 Binary files a/cmd/tailscale/manifest_windows_amd64.syso and /dev/null differ diff --git a/cmd/tailscale/manifest_windows_arm64.syso b/cmd/tailscale/manifest_windows_arm64.syso deleted file mode 100644 index 7998b6736f2ed..0000000000000 Binary files a/cmd/tailscale/manifest_windows_arm64.syso and /dev/null differ diff --git a/cmd/tailscale/tailscale.go b/cmd/tailscale/tailscale.go deleted file mode 100644 index f6adb6c197071..0000000000000 --- a/cmd/tailscale/tailscale.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The tailscale command is the Tailscale command-line client. It interacts -// with the tailscaled node agent. -package main // import "tailscale.com/cmd/tailscale" - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "tailscale.com/cmd/tailscale/cli" -) - -func main() { - args := os.Args[1:] - if name, _ := os.Executable(); strings.HasSuffix(filepath.Base(name), ".cgi") { - args = []string{"web", "-cgi"} - } - if err := cli.Run(args); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/cmd/tailscale/tailscale_test.go b/cmd/tailscale/tailscale_test.go deleted file mode 100644 index dc477fb6e4357..0000000000000 --- a/cmd/tailscale/tailscale_test.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "testing" - - "tailscale.com/tstest/deptest" -) - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - BadDeps: map[string]string{ - "testing": "do not use testing package in production code", - "gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756", - "tailscale.com/wgengine/filter": "brings in bart, etc", - "github.com/bits-and-blooms/bitset": "unneeded in CLI", - "github.com/gaissmai/bart": "unneeded in CLI", - "tailscale.com/net/ipset": "unneeded in CLI", - }, - }.Check(t) -} diff --git a/cmd/tailscale/windows-manifest.xml b/cmd/tailscale/windows-manifest.xml deleted file mode 100644 index 6c5f46058387f..0000000000000 --- a/cmd/tailscale/windows-manifest.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/cmd/tailscaled/childproc/childproc.go b/cmd/tailscaled/childproc/childproc.go deleted file mode 100644 index cc83a06c6ee7c..0000000000000 --- a/cmd/tailscaled/childproc/childproc.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package childproc allows other packages to register "tailscaled be-child" -// child process hook code. This avoids duplicating build tags in the -// tailscaled package. Instead, the code that needs to fork/exec the self -// executable (when it's tailscaled) can instead register the code -// they want to run. -package childproc - -var Code = map[string]func([]string) error{} - -// Add registers code f to run as 'tailscaled be-child [args]'. -func Add(typ string, f func(args []string) error) { - if _, dup := Code[typ]; dup { - panic("dup hook " + typ) - } - Code[typ] = f -} diff --git a/cmd/tailscaled/debug.go b/cmd/tailscaled/debug.go deleted file mode 100644 index b41604d29516e..0000000000000 --- a/cmd/tailscaled/debug.go +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 - -package main - -import ( - "context" - "crypto/tls" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "net/http" - "net/http/httptrace" - "net/url" - "os" - "time" - - "tailscale.com/derp/derphttp" - "tailscale.com/health" - "tailscale.com/ipn" - "tailscale.com/net/netmon" - "tailscale.com/net/tshttpproxy" - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -var debugArgs struct { - ifconfig bool // print network state once and exit - monitor bool - getURL string - derpCheck string - portmap bool -} - -var debugModeFunc = debugMode // so it can be addressable - -func debugMode(args []string) error { - fs := flag.NewFlagSet("debug", flag.ExitOnError) - fs.BoolVar(&debugArgs.ifconfig, "ifconfig", false, "If true, print network interface state") - fs.BoolVar(&debugArgs.monitor, "monitor", false, "If true, run network monitor forever. Precludes all other options.") - fs.BoolVar(&debugArgs.portmap, "portmap", false, "If true, run portmap debugging. Precludes all other options.") - fs.StringVar(&debugArgs.getURL, "get-url", "", "If non-empty, fetch provided URL.") - fs.StringVar(&debugArgs.derpCheck, "derp", "", "if non-empty, test a DERP ping via named region code") - if err := fs.Parse(args); err != nil { - return err - } - if len(fs.Args()) > 0 { - return errors.New("unknown non-flag debug subcommand arguments") - } - ctx := context.Background() - if debugArgs.derpCheck != "" { - return checkDerp(ctx, debugArgs.derpCheck) - } - if debugArgs.ifconfig { - return runMonitor(ctx, false) - } - if debugArgs.monitor { - return runMonitor(ctx, true) - } - if debugArgs.portmap { - return debugPortmap(ctx) - } - if debugArgs.getURL != "" { - return getURL(ctx, debugArgs.getURL) - } - return errors.New("only --monitor is available at the moment") -} - -func runMonitor(ctx context.Context, loop bool) error { - dump := func(st *netmon.State) { - j, _ := json.MarshalIndent(st, "", " ") - os.Stderr.Write(j) - } - mon, err := netmon.New(log.Printf) - if err != nil { - return err - } - defer mon.Close() - - mon.RegisterChangeCallback(func(delta *netmon.ChangeDelta) { - if !delta.Major { - log.Printf("Network monitor fired; not a major change") - return - } - log.Printf("Network monitor fired. New state:") - dump(delta.New) - }) - if loop { - log.Printf("Starting link change monitor; initial state:") - } - dump(mon.InterfaceState()) - if !loop { - return nil - } - mon.Start() - log.Printf("Started link change monitor; waiting...") - select {} -} - -func getURL(ctx context.Context, urlStr string) error { - if urlStr == "login" { - urlStr = "https://login.tailscale.com" - } - log.SetOutput(os.Stdout) - ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ - GetConn: func(hostPort string) { log.Printf("GetConn(%q)", hostPort) }, - GotConn: func(info httptrace.GotConnInfo) { log.Printf("GotConn: %+v", info) }, - DNSStart: func(info httptrace.DNSStartInfo) { log.Printf("DNSStart: %+v", info) }, - DNSDone: func(info httptrace.DNSDoneInfo) { log.Printf("DNSDoneInfo: %+v", info) }, - TLSHandshakeStart: func() { log.Printf("TLSHandshakeStart") }, - TLSHandshakeDone: func(cs tls.ConnectionState, err error) { log.Printf("TLSHandshakeDone: %+v, %v", cs, err) }, - WroteRequest: func(info httptrace.WroteRequestInfo) { log.Printf("WroteRequest: %+v", info) }, - }) - req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) - if err != nil { - return fmt.Errorf("http.NewRequestWithContext: %v", err) - } - proxyURL, err := tshttpproxy.ProxyFromEnvironment(req) - if err != nil { - return fmt.Errorf("tshttpproxy.ProxyFromEnvironment: %v", err) - } - log.Printf("proxy: %v", proxyURL) - tr := &http.Transport{ - Proxy: func(*http.Request) (*url.URL, error) { return proxyURL, nil }, - ProxyConnectHeader: http.Header{}, - DisableKeepAlives: true, - } - if proxyURL != nil { - auth, err := tshttpproxy.GetAuthHeader(proxyURL) - if err == nil && auth != "" { - tr.ProxyConnectHeader.Set("Proxy-Authorization", auth) - } - log.Printf("tshttpproxy.GetAuthHeader(%v) got: auth of %d bytes, err=%v", proxyURL, len(auth), err) - const truncLen = 20 - if len(auth) > truncLen { - auth = fmt.Sprintf("%s...(%d total bytes)", auth[:truncLen], len(auth)) - } - if auth != "" { - // We used log.Printf above (for timestamps). - // Use fmt.Printf here instead just to appease - // a security scanner, despite log.Printf only - // going to stdout. - fmt.Printf("... Proxy-Authorization = %q\n", auth) - } - } - res, err := tr.RoundTrip(req) - if err != nil { - return fmt.Errorf("Transport.RoundTrip: %v", err) - } - defer res.Body.Close() - return res.Write(os.Stdout) -} - -func checkDerp(ctx context.Context, derpRegion string) (err error) { - ht := new(health.Tracker) - req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil) - if err != nil { - return fmt.Errorf("create derp map request: %w", err) - } - res, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("fetch derp map failed: %w", err) - } - defer res.Body.Close() - b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) - if err != nil { - return fmt.Errorf("fetch derp map failed: %w", err) - } - if res.StatusCode != 200 { - return fmt.Errorf("fetch derp map: %v: %s", res.Status, b) - } - var dmap tailcfg.DERPMap - if err = json.Unmarshal(b, &dmap); err != nil { - return fmt.Errorf("fetch DERP map: %w", err) - } - getRegion := func() *tailcfg.DERPRegion { - for _, r := range dmap.Regions { - if r.RegionCode == derpRegion { - return r - } - } - for _, r := range dmap.Regions { - log.Printf("Known region: %q", r.RegionCode) - } - log.Fatalf("unknown region %q", derpRegion) - panic("unreachable") - } - - priv1 := key.NewNode() - priv2 := key.NewNode() - - c1 := derphttp.NewRegionClient(priv1, log.Printf, nil, getRegion) - c2 := derphttp.NewRegionClient(priv2, log.Printf, nil, getRegion) - c1.HealthTracker = ht - c2.HealthTracker = ht - defer func() { - if err != nil { - c1.Close() - c2.Close() - } - }() - - c2.NotePreferred(true) // just to open it - - m, err := c2.Recv() - log.Printf("c2 got %T, %v", m, err) - - t0 := time.Now() - if err := c1.Send(priv2.Public(), []byte("hello")); err != nil { - return err - } - fmt.Println(time.Since(t0)) - - m, err = c2.Recv() - log.Printf("c2 got %T, %v", m, err) - if err != nil { - return err - } - log.Printf("ok") - return err -} - -func debugPortmap(ctx context.Context) error { - return fmt.Errorf("this flag has been deprecated in favour of 'tailscale debug portmap'") -} diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt deleted file mode 100644 index 81cd53271cf9e..0000000000000 --- a/cmd/tailscaled/depaware.txt +++ /dev/null @@ -1,589 +0,0 @@ -tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/depaware) - - filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus - filippo.io/edwards25519/field from filippo.io/edwards25519 - 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 - LD github.com/anmitsu/go-shlex from tailscale.com/tempfork/gliderlabs/ssh - L github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+ - L github.com/aws/aws-sdk-go-v2/aws/arn from tailscale.com/ipn/store/awsstore - L github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+ - L github.com/aws/aws-sdk-go-v2/aws/middleware/private/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+ - L github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry - L github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+ - L github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4 - L github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/service/internal/presigned-url+ - L github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/config from tailscale.com/ipn/store/awsstore - L github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds - L github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds - L github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+ - L github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/ssm/internal/endpoints+ - L github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds - L github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 - L github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws - L github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry - L github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/service/ssm from tailscale.com/ipn/store/awsstore - L github.com/aws/aws-sdk-go-v2/service/ssm/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm - L github.com/aws/aws-sdk-go-v2/service/ssm/types from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso - L github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso - L github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc - L github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc - L github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+ - L github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+ - L github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+ - L github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+ - L github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer - L github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+ - L github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+ - L github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts - L github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer - L github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+ - L github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config - L github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+ - L github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware+ - L github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/ssm+ - L github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws/middleware+ - L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http - L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm - github.com/bits-and-blooms/bitset from github.com/gaissmai/bart - L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw - LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh - W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+ - W 💣 github.com/dblohm7/wingoes/com from tailscale.com/cmd/tailscaled+ - W 💣 github.com/dblohm7/wingoes/com/automation from tailscale.com/util/osdiag/internal/wsc - W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com - W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+ - LW 💣 github.com/digitalocean/go-smbios/smbios from tailscale.com/posture - 💣 github.com/djherbis/times from tailscale.com/drive/driveimpl - github.com/fxamacker/cbor/v2 from tailscale.com/tka - github.com/gaissmai/bart from tailscale.com/net/tstun+ - github.com/go-json-experiment/json from tailscale.com/types/opt+ - github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+ - github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+ - github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+ - github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+ - github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+ - W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+ - W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet - L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+ - github.com/golang/groupcache/lru from tailscale.com/net/dnscache - github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header+ - L github.com/google/nftables from tailscale.com/util/linuxfw - L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt - L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+ - L github.com/google/nftables/expr from github.com/google/nftables+ - L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ - L github.com/google/nftables/xt from github.com/google/nftables/expr+ - DW github.com/google/uuid from tailscale.com/clientupdate+ - github.com/gorilla/csrf from tailscale.com/client/web - github.com/gorilla/securecookie from github.com/gorilla/csrf - github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+ - L 💣 github.com/illarion/gonotify/v2 from tailscale.com/net/dns - L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun - L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4 - L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4 - L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4 - github.com/jellydator/ttlcache/v3 from tailscale.com/drive/driveimpl/compositedav - L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm - L github.com/josharian/native from github.com/mdlayher/netlink+ - L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon - L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink - github.com/klauspost/compress from github.com/klauspost/compress/zstd - github.com/klauspost/compress/fse from github.com/klauspost/compress/huff0 - github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd - github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+ - github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd - github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe - github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd - github.com/kortschak/wol from tailscale.com/ipn/ipnlocal - LD github.com/kr/fs from github.com/pkg/sftp - L github.com/mdlayher/genetlink from tailscale.com/net/tstun - L 💣 github.com/mdlayher/netlink from github.com/google/nftables+ - L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ - L github.com/mdlayher/netlink/nltest from github.com/google/nftables - L github.com/mdlayher/sdnotify from tailscale.com/util/systemd - L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+ - github.com/miekg/dns from tailscale.com/net/dns/recursive - 💣 github.com/mitchellh/go-ps from tailscale.com/safesocket - L github.com/pierrec/lz4/v4 from github.com/u-root/uio/uio - L github.com/pierrec/lz4/v4/internal/lz4block from github.com/pierrec/lz4/v4+ - L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+ - L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4 - L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream - LD github.com/pkg/sftp from tailscale.com/ssh/tailssh - LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp - D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack - L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+ - W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient - W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket - W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio - W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio - W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs - W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ - github.com/tailscale/golang-x-crypto/acme from tailscale.com/ipn/ipnlocal - LD github.com/tailscale/golang-x-crypto/internal/poly1305 from github.com/tailscale/golang-x-crypto/ssh - LD github.com/tailscale/golang-x-crypto/ssh from tailscale.com/ipn/ipnlocal+ - LD github.com/tailscale/golang-x-crypto/ssh/internal/bcrypt_pbkdf from github.com/tailscale/golang-x-crypto/ssh - github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+ - github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper - github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+ - github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp - github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+ - github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp - github.com/tailscale/hujson from tailscale.com/ipn/conffile - L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+ - L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink - github.com/tailscale/peercred from tailscale.com/ipn/ipnauth - github.com/tailscale/web-client-prebuilt from tailscale.com/client/web - W 💣 github.com/tailscale/wf from tailscale.com/wf - 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ - W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn - 💣 github.com/tailscale/wireguard-go/device from tailscale.com/net/tstun+ - 💣 github.com/tailscale/wireguard-go/ipc from github.com/tailscale/wireguard-go/device - W 💣 github.com/tailscale/wireguard-go/ipc/namedpipe from github.com/tailscale/wireguard-go/ipc - github.com/tailscale/wireguard-go/ratelimiter from github.com/tailscale/wireguard-go/device - github.com/tailscale/wireguard-go/replay from github.com/tailscale/wireguard-go/device - github.com/tailscale/wireguard-go/rwcancel from github.com/tailscale/wireguard-go/device+ - github.com/tailscale/wireguard-go/tai64n from github.com/tailscale/wireguard-go/device - 💣 github.com/tailscale/wireguard-go/tun from github.com/tailscale/wireguard-go/device+ - github.com/tailscale/xnet/webdav from tailscale.com/drive/driveimpl+ - github.com/tailscale/xnet/webdav/internal/xml from github.com/tailscale/xnet/webdav - github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck - LD github.com/u-root/u-root/pkg/termios from tailscale.com/ssh/tailssh - L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4 - L github.com/u-root/uio/uio from github.com/insomniacslk/dhcp/dhcpv4+ - L github.com/vishvananda/netns from github.com/tailscale/netlink+ - github.com/x448/float16 from github.com/fxamacker/cbor/v2 - 💣 go4.org/mem from tailscale.com/client/tailscale+ - go4.org/netipx from github.com/tailscale/wf+ - W 💣 golang.zx2c4.com/wintun from github.com/tailscale/wireguard-go/tun+ - W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/cmd/tailscaled+ - gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/buffer+ - gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer - 💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+ - gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs - 💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+ - gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log - gvisor.dev/gvisor/pkg/log from gvisor.dev/gvisor/pkg/context+ - gvisor.dev/gvisor/pkg/rand from gvisor.dev/gvisor/pkg/tcpip/network/hash+ - gvisor.dev/gvisor/pkg/refs from gvisor.dev/gvisor/pkg/buffer+ - 💣 gvisor.dev/gvisor/pkg/sleep from gvisor.dev/gvisor/pkg/tcpip/transport/tcp - 💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+ - gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state - 💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/atomicbitops+ - 💣 gvisor.dev/gvisor/pkg/sync/locking from gvisor.dev/gvisor/pkg/tcpip/stack - gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/tcpip/adapters/gonet from tailscale.com/wgengine/netstack - 💣 gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+ - gvisor.dev/gvisor/pkg/tcpip/hash/jenkins from gvisor.dev/gvisor/pkg/tcpip/stack+ - gvisor.dev/gvisor/pkg/tcpip/header from gvisor.dev/gvisor/pkg/tcpip/header/parse+ - gvisor.dev/gvisor/pkg/tcpip/header/parse from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/internal/tcp from gvisor.dev/gvisor/pkg/tcpip/stack+ - gvisor.dev/gvisor/pkg/tcpip/network/hash from gvisor.dev/gvisor/pkg/tcpip/network/ipv4 - gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+ - gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+ - gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack+ - gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+ - gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+ - 💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/tcpip/stack/gro from tailscale.com/wgengine/netstack/gro - gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+ - gvisor.dev/gvisor/pkg/tcpip/transport/icmp from tailscale.com/wgengine/netstack - gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+ - gvisor.dev/gvisor/pkg/tcpip/transport/internal/noop from gvisor.dev/gvisor/pkg/tcpip/transport/raw - gvisor.dev/gvisor/pkg/tcpip/transport/packet from gvisor.dev/gvisor/pkg/tcpip/transport/raw - gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+ - 💣 gvisor.dev/gvisor/pkg/tcpip/transport/tcp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/tcpip/transport/tcpconntrack from gvisor.dev/gvisor/pkg/tcpip/stack - gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ - gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+ - tailscale.com from tailscale.com/version - tailscale.com/appc from tailscale.com/ipn/ipnlocal - tailscale.com/atomicfile from tailscale.com/ipn+ - LD tailscale.com/chirp from tailscale.com/cmd/tailscaled - tailscale.com/client/tailscale from tailscale.com/client/web+ - tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+ - tailscale.com/client/web from tailscale.com/ipn/ipnlocal - tailscale.com/clientupdate from tailscale.com/client/web+ - LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate - tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+ - tailscale.com/control/controlbase from tailscale.com/control/controlhttp+ - tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+ - tailscale.com/control/controlhttp from tailscale.com/control/controlclient - tailscale.com/control/controlhttp/controlhttpcommon from tailscale.com/control/controlhttp - tailscale.com/control/controlknobs from tailscale.com/control/controlclient+ - tailscale.com/derp from tailscale.com/derp/derphttp+ - tailscale.com/derp/derphttp from tailscale.com/cmd/tailscaled+ - tailscale.com/disco from tailscale.com/derp+ - tailscale.com/doctor from tailscale.com/ipn/ipnlocal - tailscale.com/doctor/ethtool from tailscale.com/ipn/ipnlocal - 💣 tailscale.com/doctor/permissions from tailscale.com/ipn/ipnlocal - tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal - tailscale.com/drive from tailscale.com/client/tailscale+ - tailscale.com/drive/driveimpl from tailscale.com/cmd/tailscaled - tailscale.com/drive/driveimpl/compositedav from tailscale.com/drive/driveimpl - tailscale.com/drive/driveimpl/dirfs from tailscale.com/drive/driveimpl+ - tailscale.com/drive/driveimpl/shared from tailscale.com/drive/driveimpl+ - tailscale.com/envknob from tailscale.com/client/tailscale+ - tailscale.com/envknob/featureknob from tailscale.com/client/web+ - tailscale.com/health from tailscale.com/control/controlclient+ - tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal - tailscale.com/hostinfo from tailscale.com/client/web+ - tailscale.com/internal/noiseconn from tailscale.com/control/controlclient - tailscale.com/ipn from tailscale.com/client/tailscale+ - tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+ - 💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+ - tailscale.com/ipn/ipnlocal from tailscale.com/cmd/tailscaled+ - tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled - tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+ - tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver - tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal - tailscale.com/ipn/store from tailscale.com/cmd/tailscaled+ - L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store - L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store - tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+ - L tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+ - L tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore - tailscale.com/kube/kubetypes from tailscale.com/envknob - tailscale.com/licenses from tailscale.com/client/web - tailscale.com/log/filelogger from tailscale.com/logpolicy - tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal - tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+ - tailscale.com/logtail from tailscale.com/cmd/tailscaled+ - tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+ - tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+ - tailscale.com/metrics from tailscale.com/derp+ - tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+ - tailscale.com/net/connstats from tailscale.com/net/tstun+ - tailscale.com/net/dns from tailscale.com/cmd/tailscaled+ - tailscale.com/net/dns/publicdns from tailscale.com/net/dns+ - tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback - tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+ - tailscale.com/net/dns/resolver from tailscale.com/net/dns - tailscale.com/net/dnscache from tailscale.com/control/controlclient+ - tailscale.com/net/dnsfallback from tailscale.com/cmd/tailscaled+ - tailscale.com/net/flowtrack from tailscale.com/net/packet+ - tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+ - tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock+ - tailscale.com/net/neterror from tailscale.com/net/dns/resolver+ - tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal - tailscale.com/net/netknob from tailscale.com/logpolicy+ - 💣 tailscale.com/net/netmon from tailscale.com/cmd/tailscaled+ - 💣 tailscale.com/net/netns from tailscale.com/cmd/tailscaled+ - W 💣 tailscale.com/net/netstat from tailscale.com/portlist - tailscale.com/net/netutil from tailscale.com/client/tailscale+ - tailscale.com/net/packet from tailscale.com/net/connstats+ - tailscale.com/net/packet/checksum from tailscale.com/net/tstun - tailscale.com/net/ping from tailscale.com/net/netcheck+ - tailscale.com/net/portmapper from tailscale.com/ipn/localapi+ - tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled - tailscale.com/net/routetable from tailscale.com/doctor/routetable - tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled - tailscale.com/net/sockstats from tailscale.com/control/controlclient+ - tailscale.com/net/stun from tailscale.com/ipn/localapi+ - L tailscale.com/net/tcpinfo from tailscale.com/derp - tailscale.com/net/tlsdial from tailscale.com/control/controlclient+ - tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial - tailscale.com/net/tsaddr from tailscale.com/client/web+ - tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+ - 💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+ - tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+ - tailscale.com/omit from tailscale.com/ipn/conffile - tailscale.com/paths from tailscale.com/client/tailscale+ - 💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal - tailscale.com/posture from tailscale.com/ipn/ipnlocal - tailscale.com/proxymap from tailscale.com/tsd+ - 💣 tailscale.com/safesocket from tailscale.com/client/tailscale+ - LD tailscale.com/sessionrecording from tailscale.com/ssh/tailssh - LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled - tailscale.com/syncs from tailscale.com/cmd/tailscaled+ - tailscale.com/tailcfg from tailscale.com/client/tailscale+ - tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+ - LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh - tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock - tailscale.com/tka from tailscale.com/client/tailscale+ - tailscale.com/tsconst from tailscale.com/net/netmon+ - tailscale.com/tsd from tailscale.com/cmd/tailscaled+ - tailscale.com/tstime from tailscale.com/control/controlclient+ - tailscale.com/tstime/mono from tailscale.com/net/tstun+ - tailscale.com/tstime/rate from tailscale.com/derp+ - tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+ - tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal - tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/empty from tailscale.com/ipn+ - tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled - tailscale.com/types/ipproto from tailscale.com/net/flowtrack+ - tailscale.com/types/key from tailscale.com/client/tailscale+ - tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+ - tailscale.com/types/logger from tailscale.com/appc+ - tailscale.com/types/logid from tailscale.com/cmd/tailscaled+ - tailscale.com/types/netlogtype from tailscale.com/net/connstats+ - tailscale.com/types/netmap from tailscale.com/control/controlclient+ - tailscale.com/types/nettype from tailscale.com/ipn/localapi+ - tailscale.com/types/opt from tailscale.com/client/tailscale+ - tailscale.com/types/persist from tailscale.com/control/controlclient+ - tailscale.com/types/preftype from tailscale.com/ipn+ - tailscale.com/types/ptr from tailscale.com/control/controlclient+ - tailscale.com/types/result from tailscale.com/util/lineiter - tailscale.com/types/structs from tailscale.com/control/controlclient+ - tailscale.com/types/tkatype from tailscale.com/tka+ - tailscale.com/types/views from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/cibuild from tailscale.com/health - tailscale.com/util/clientmetric from tailscale.com/control/controlclient+ - tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+ - tailscale.com/util/cmpver from tailscale.com/net/dns+ - tailscale.com/util/ctxkey from tailscale.com/ipn/ipnlocal+ - 💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+ - L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+ - tailscale.com/util/dnsname from tailscale.com/appc+ - tailscale.com/util/execqueue from tailscale.com/control/controlclient+ - tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal - tailscale.com/util/groupmember from tailscale.com/client/web+ - 💣 tailscale.com/util/hashx from tailscale.com/util/deephash - tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/httpm from tailscale.com/client/tailscale+ - tailscale.com/util/lineiter from tailscale.com/hostinfo+ - L tailscale.com/util/linuxfw from tailscale.com/net/netns+ - tailscale.com/util/mak from tailscale.com/control/controlclient+ - tailscale.com/util/multierr from tailscale.com/cmd/tailscaled+ - tailscale.com/util/must from tailscale.com/clientupdate/distsign+ - tailscale.com/util/nocasemaps from tailscale.com/types/ipproto - 💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+ - W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag - tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+ - tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/progresstracking from tailscale.com/ipn/localapi - tailscale.com/util/race from tailscale.com/net/dns/resolver - tailscale.com/util/racebuild from tailscale.com/logpolicy - tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/ringbuffer from tailscale.com/wgengine/magicsock - tailscale.com/util/set from tailscale.com/derp+ - tailscale.com/util/singleflight from tailscale.com/control/controlclient+ - tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+ - tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+ - tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+ - tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+ - tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source - tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy+ - tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+ - tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+ - tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock - tailscale.com/util/systemd from tailscale.com/control/controlclient+ - tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/truncate from tailscale.com/logtail - tailscale.com/util/uniq from tailscale.com/ipn/ipnlocal+ - tailscale.com/util/usermetric from tailscale.com/health+ - tailscale.com/util/vizerror from tailscale.com/tailcfg+ - 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+ - W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+ - W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns+ - W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal - W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+ - tailscale.com/util/zstdframe from tailscale.com/control/controlclient+ - tailscale.com/version from tailscale.com/client/web+ - tailscale.com/version/distro from tailscale.com/client/web+ - W tailscale.com/wf from tailscale.com/cmd/tailscaled - tailscale.com/wgengine from tailscale.com/cmd/tailscaled+ - tailscale.com/wgengine/capture from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/filter from tailscale.com/control/controlclient+ - tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap+ - 💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/netlog from tailscale.com/wgengine - tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled - tailscale.com/wgengine/netstack/gro from tailscale.com/net/tstun+ - tailscale.com/wgengine/router from tailscale.com/cmd/tailscaled+ - tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+ - tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal - 💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+ - tailscale.com/wgengine/wglog from tailscale.com/wgengine - W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router - golang.org/x/crypto/argon2 from tailscale.com/tka - golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ - golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ - LD golang.org/x/crypto/blowfish from github.com/tailscale/golang-x-crypto/ssh/internal/bcrypt_pbkdf+ - golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+ - golang.org/x/crypto/chacha20poly1305 from crypto/tls+ - golang.org/x/crypto/cryptobyte from crypto/ecdsa+ - golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+ - golang.org/x/crypto/curve25519 from github.com/tailscale/golang-x-crypto/ssh+ - golang.org/x/crypto/hkdf from crypto/tls+ - golang.org/x/crypto/nacl/box from tailscale.com/types/key - golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box - golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device - golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - golang.org/x/crypto/sha3 from crypto/internal/mlkem768+ - LD golang.org/x/crypto/ssh from github.com/pkg/sftp+ - golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+ - golang.org/x/exp/maps from tailscale.com/appc+ - golang.org/x/net/bpf from github.com/mdlayher/genetlink+ - golang.org/x/net/dns/dnsmessage from net+ - golang.org/x/net/http/httpguts from golang.org/x/net/http2+ - golang.org/x/net/http/httpproxy from net/http+ - golang.org/x/net/http2 from golang.org/x/net/http2/h2c+ - golang.org/x/net/http2/h2c from tailscale.com/ipn/ipnlocal - golang.org/x/net/http2/hpack from golang.org/x/net/http2+ - golang.org/x/net/icmp from tailscale.com/net/ping+ - golang.org/x/net/idna from golang.org/x/net/http/httpguts+ - golang.org/x/net/ipv4 from github.com/miekg/dns+ - golang.org/x/net/ipv6 from github.com/miekg/dns+ - golang.org/x/net/proxy from tailscale.com/net/netns - D golang.org/x/net/route from net+ - golang.org/x/sync/errgroup from github.com/mdlayher/socket+ - golang.org/x/sync/singleflight from github.com/jellydator/ttlcache/v3 - golang.org/x/sys/cpu from github.com/josharian/native+ - LD golang.org/x/sys/unix from github.com/google/nftables+ - W golang.org/x/sys/windows from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+ - W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+ - W golang.org/x/sys/windows/svc/eventlog from tailscale.com/cmd/tailscaled - W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled+ - golang.org/x/term from tailscale.com/logpolicy - golang.org/x/text/secure/bidirule from golang.org/x/net/idna - golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ - golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ - golang.org/x/text/unicode/norm from golang.org/x/net/idna - golang.org/x/time/rate from gvisor.dev/gvisor/pkg/log+ - archive/tar from tailscale.com/clientupdate - bufio from compress/flate+ - bytes from archive/tar+ - cmp from slices+ - compress/flate from compress/gzip+ - compress/gzip from golang.org/x/net/http2+ - W compress/zlib from debug/pe - container/heap from github.com/jellydator/ttlcache/v3+ - container/list from crypto/tls+ - context from crypto/tls+ - crypto from crypto/ecdh+ - crypto/aes from crypto/ecdsa+ - crypto/cipher from crypto/aes+ - crypto/des from crypto/tls+ - crypto/dsa from crypto/x509+ - crypto/ecdh from crypto/ecdsa+ - crypto/ecdsa from crypto/tls+ - crypto/ed25519 from crypto/tls+ - crypto/elliptic from crypto/ecdsa+ - crypto/hmac from crypto/tls+ - crypto/md5 from crypto/tls+ - crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls+ - crypto/rsa from crypto/tls+ - crypto/sha1 from crypto/tls+ - crypto/sha256 from crypto/tls+ - crypto/sha512 from crypto/ecdsa+ - crypto/subtle from crypto/aes+ - crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+ - crypto/x509 from crypto/tls+ - crypto/x509/pkix from crypto/x509+ - DW database/sql/driver from github.com/google/uuid - W debug/dwarf from debug/pe - W debug/pe from github.com/dblohm7/wingoes/pe - embed from crypto/internal/nistec+ - encoding from encoding/gob+ - encoding/asn1 from crypto/x509+ - encoding/base32 from github.com/fxamacker/cbor/v2+ - encoding/base64 from encoding/json+ - encoding/binary from compress/gzip+ - encoding/gob from github.com/gorilla/securecookie - encoding/hex from crypto/x509+ - encoding/json from expvar+ - encoding/pem from crypto/tls+ - encoding/xml from github.com/aws/aws-sdk-go-v2/aws/protocol/xml+ - errors from archive/tar+ - expvar from tailscale.com/derp+ - flag from net/http/httptest+ - fmt from archive/tar+ - hash from compress/zlib+ - hash/adler32 from compress/zlib+ - hash/crc32 from compress/gzip+ - hash/maphash from go4.org/mem - html from html/template+ - html/template from github.com/gorilla/csrf - io from archive/tar+ - io/fs from archive/tar+ - io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+ - iter from maps+ - log from expvar+ - log/internal from log - LD log/syslog from tailscale.com/ssh/tailssh - maps from tailscale.com/clientupdate+ - math from archive/tar+ - math/big from crypto/dsa+ - math/bits from compress/flate+ - math/rand from github.com/mdlayher/netlink+ - math/rand/v2 from tailscale.com/util/rands+ - mime from github.com/tailscale/xnet/webdav+ - mime/multipart from net/http+ - mime/quotedprintable from mime/multipart - net from crypto/tls+ - net/http from expvar+ - net/http/httptest from tailscale.com/control/controlclient - net/http/httptrace from github.com/tcnksm/go-httpstat+ - net/http/httputil from github.com/aws/smithy-go/transport/http+ - net/http/internal from net/http+ - net/http/pprof from tailscale.com/cmd/tailscaled+ - net/netip from github.com/tailscale/wireguard-go/conn+ - net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+ - net/url from crypto/x509+ - os from crypto/rand+ - os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+ - os/signal from tailscale.com/cmd/tailscaled - os/user from archive/tar+ - path from archive/tar+ - path/filepath from archive/tar+ - reflect from archive/tar+ - regexp from github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn+ - regexp/syntax from regexp - runtime/debug from github.com/aws/aws-sdk-go-v2/internal/sync/singleflight+ - runtime/pprof from net/http/pprof+ - runtime/trace from net/http/pprof - slices from tailscale.com/appc+ - sort from compress/flate+ - strconv from archive/tar+ - strings from archive/tar+ - sync from archive/tar+ - sync/atomic from context+ - syscall from archive/tar+ - text/tabwriter from runtime/pprof - text/template from html/template - text/template/parse from html/template+ - time from archive/tar+ - unicode from bytes+ - unicode/utf16 from crypto/x509+ - unicode/utf8 from bufio+ - unique from net/netip diff --git a/cmd/tailscaled/deps_test.go b/cmd/tailscaled/deps_test.go deleted file mode 100644 index 2b4bc280d26cf..0000000000000 --- a/cmd/tailscaled/deps_test.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "testing" - - "tailscale.com/tstest/deptest" -) - -func TestOmitSSH(t *testing.T) { - const msg = "unexpected with ts_omit_ssh" - deptest.DepChecker{ - GOOS: "linux", - GOARCH: "amd64", - Tags: "ts_omit_ssh", - BadDeps: map[string]string{ - "tailscale.com/ssh/tailssh": msg, - "golang.org/x/crypto/ssh": msg, - "tailscale.com/sessionrecording": msg, - "github.com/anmitsu/go-shlex": msg, - "github.com/creack/pty": msg, - "github.com/kr/fs": msg, - "github.com/pkg/sftp": msg, - "github.com/u-root/u-root/pkg/termios": msg, - "tempfork/gliderlabs/ssh": msg, - }, - }.Check(t) -} diff --git a/cmd/tailscaled/generate.go b/cmd/tailscaled/generate.go deleted file mode 100644 index 5c2e9be915980..0000000000000 --- a/cmd/tailscaled/generate.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -//go:generate go run tailscale.com/cmd/mkmanifest amd64 windows-manifest.xml manifest_windows_amd64.syso -//go:generate go run tailscale.com/cmd/mkmanifest 386 windows-manifest.xml manifest_windows_386.syso -//go:generate go run tailscale.com/cmd/mkmanifest arm64 windows-manifest.xml manifest_windows_arm64.syso diff --git a/cmd/tailscaled/install_darwin.go b/cmd/tailscaled/install_darwin.go deleted file mode 100644 index 05e5eaed8af90..0000000000000 --- a/cmd/tailscaled/install_darwin.go +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 - -package main - -import ( - "errors" - "fmt" - "io" - "io/fs" - "os" - "os/exec" - "path/filepath" -) - -func init() { - installSystemDaemon = installSystemDaemonDarwin - uninstallSystemDaemon = uninstallSystemDaemonDarwin -} - -// darwinLaunchdPlist is the launchd.plist that's written to -// /Library/LaunchDaemons/com.tailscale.tailscaled.plist or (in the -// future) a user-specific location. -// -// See man launchd.plist. -const darwinLaunchdPlist = ` - - - - - - Label - com.tailscale.tailscaled - - ProgramArguments - - /usr/local/bin/tailscaled - - - RunAtLoad - - - - -` - -const sysPlist = "/Library/LaunchDaemons/com.tailscale.tailscaled.plist" -const targetBin = "/usr/local/bin/tailscaled" -const service = "com.tailscale.tailscaled" - -func uninstallSystemDaemonDarwin(args []string) (ret error) { - if len(args) > 0 { - return errors.New("uninstall subcommand takes no arguments") - } - - plist, err := exec.Command("launchctl", "list", "com.tailscale.tailscaled").Output() - _ = plist // parse it? https://github.com/DHowett/go-plist if we need something. - running := err == nil - - if running { - out, err := exec.Command("launchctl", "stop", "com.tailscale.tailscaled").CombinedOutput() - if err != nil { - fmt.Printf("launchctl stop com.tailscale.tailscaled: %v, %s\n", err, out) - ret = err - } - out, err = exec.Command("launchctl", "unload", sysPlist).CombinedOutput() - if err != nil { - fmt.Printf("launchctl unload %s: %v, %s\n", sysPlist, err, out) - if ret == nil { - ret = err - } - } - } - - if err := os.Remove(sysPlist); err != nil { - if os.IsNotExist(err) { - err = nil - } - if ret == nil { - ret = err - } - } - - // Do not delete targetBin if it's a symlink, which happens if it was installed via - // Homebrew. - if isSymlink(targetBin) { - return ret - } - - if err := os.Remove(targetBin); err != nil { - if os.IsNotExist(err) { - err = nil - } - if ret == nil { - ret = err - } - } - return ret -} - -func installSystemDaemonDarwin(args []string) (err error) { - if len(args) > 0 { - return errors.New("install subcommand takes no arguments") - } - defer func() { - if err != nil && os.Getuid() != 0 { - err = fmt.Errorf("%w; try running tailscaled with sudo", err) - } - }() - - // Best effort: - uninstallSystemDaemonDarwin(nil) - - exe, err := os.Executable() - if err != nil { - return fmt.Errorf("failed to find our own executable path: %w", err) - } - - same, err := sameFile(exe, targetBin) - if err != nil { - return err - } - - // Do not overwrite targetBin with the binary file if it it's already - // pointing to it. This is primarily to handle Homebrew that writes - // /usr/local/bin/tailscaled is a symlink to the actual binary. - if !same { - if err := copyBinary(exe, targetBin); err != nil { - return err - } - } - if err := os.WriteFile(sysPlist, []byte(darwinLaunchdPlist), 0700); err != nil { - return err - } - - if out, err := exec.Command("launchctl", "load", sysPlist).CombinedOutput(); err != nil { - return fmt.Errorf("error running launchctl load %s: %v, %s", sysPlist, err, out) - } - - if out, err := exec.Command("launchctl", "start", service).CombinedOutput(); err != nil { - return fmt.Errorf("error running launchctl start %s: %v, %s", service, err, out) - } - - return nil -} - -// copyBinary copies binary file `src` into `dst`. -func copyBinary(src, dst string) error { - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return err - } - tmpBin := dst + ".tmp" - f, err := os.Create(tmpBin) - if err != nil { - return err - } - srcf, err := os.Open(src) - if err != nil { - f.Close() - return err - } - _, err = io.Copy(f, srcf) - srcf.Close() - if err != nil { - f.Close() - return err - } - if err := f.Close(); err != nil { - return err - } - if err := os.Chmod(tmpBin, 0755); err != nil { - return err - } - if err := os.Rename(tmpBin, dst); err != nil { - return err - } - - return nil -} - -func isSymlink(path string) bool { - fi, err := os.Lstat(path) - return err == nil && (fi.Mode()&os.ModeSymlink == os.ModeSymlink) -} - -// sameFile returns true if both file paths exist and resolve to the same file. -func sameFile(path1, path2 string) (bool, error) { - dst1, err := filepath.EvalSymlinks(path1) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return false, fmt.Errorf("EvalSymlinks(%s): %w", path1, err) - } - dst2, err := filepath.EvalSymlinks(path2) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return false, fmt.Errorf("EvalSymlinks(%s): %w", path2, err) - } - return dst1 == dst2, nil -} diff --git a/cmd/tailscaled/install_windows.go b/cmd/tailscaled/install_windows.go deleted file mode 100644 index c36418642d2b4..0000000000000 --- a/cmd/tailscaled/install_windows.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 - -package main - -import ( - "context" - "errors" - "fmt" - "os" - "time" - - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/svc" - "golang.org/x/sys/windows/svc/mgr" - "tailscale.com/logtail/backoff" - "tailscale.com/types/logger" - "tailscale.com/util/osshare" -) - -func init() { - installSystemDaemon = installSystemDaemonWindows - uninstallSystemDaemon = uninstallSystemDaemonWindows -} - -func installSystemDaemonWindows(args []string) (err error) { - m, err := mgr.Connect() - if err != nil { - return fmt.Errorf("failed to connect to Windows service manager: %v", err) - } - - service, err := m.OpenService(serviceName) - if err == nil { - service.Close() - return fmt.Errorf("service %q is already installed", serviceName) - } - - // no such service; proceed to install the service. - - exe, err := os.Executable() - if err != nil { - return err - } - - c := mgr.Config{ - ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, - StartType: mgr.StartAutomatic, - ErrorControl: mgr.ErrorNormal, - DisplayName: serviceName, - Description: "Connects this computer to others on the Tailscale network.", - } - - service, err = m.CreateService(serviceName, exe, c) - if err != nil { - return fmt.Errorf("failed to create %q service: %v", serviceName, err) - } - defer service.Close() - - // Exponential backoff is often too aggressive, so use (mostly) - // squares instead. - ra := []mgr.RecoveryAction{ - {mgr.ServiceRestart, 1 * time.Second}, - {mgr.ServiceRestart, 2 * time.Second}, - {mgr.ServiceRestart, 4 * time.Second}, - {mgr.ServiceRestart, 9 * time.Second}, - {mgr.ServiceRestart, 16 * time.Second}, - {mgr.ServiceRestart, 25 * time.Second}, - {mgr.ServiceRestart, 36 * time.Second}, - {mgr.ServiceRestart, 49 * time.Second}, - {mgr.ServiceRestart, 64 * time.Second}, - } - const resetPeriodSecs = 60 - err = service.SetRecoveryActions(ra, resetPeriodSecs) - if err != nil { - return fmt.Errorf("failed to set service recovery actions: %v", err) - } - - return nil -} - -func uninstallSystemDaemonWindows(args []string) (ret error) { - // Remove file sharing from Windows shell (noop in non-windows) - osshare.SetFileSharingEnabled(false, logger.Discard) - - m, err := mgr.Connect() - if err != nil { - return fmt.Errorf("failed to connect to Windows service manager: %v", err) - } - defer m.Disconnect() - - service, err := m.OpenService(serviceName) - if err != nil { - return fmt.Errorf("failed to open %q service: %v", serviceName, err) - } - - st, err := service.Query() - if err != nil { - service.Close() - return fmt.Errorf("failed to query service state: %v", err) - } - if st.State != svc.Stopped { - service.Control(svc.Stop) - } - err = service.Delete() - service.Close() - if err != nil { - return fmt.Errorf("failed to delete service: %v", err) - } - - bo := backoff.NewBackoff("uninstall", logger.Discard, 30*time.Second) - end := time.Now().Add(15 * time.Second) - for time.Until(end) > 0 { - service, err = m.OpenService(serviceName) - if err != nil { - // service is no longer openable; success! - break - } - service.Close() - bo.BackOff(context.Background(), errors.New("service not deleted")) - } - return nil -} diff --git a/cmd/tailscaled/manifest_windows_386.syso b/cmd/tailscaled/manifest_windows_386.syso deleted file mode 100644 index ac4915862f63f..0000000000000 Binary files a/cmd/tailscaled/manifest_windows_386.syso and /dev/null differ diff --git a/cmd/tailscaled/manifest_windows_amd64.syso b/cmd/tailscaled/manifest_windows_amd64.syso deleted file mode 100644 index 3a22f2cdffca1..0000000000000 Binary files a/cmd/tailscaled/manifest_windows_amd64.syso and /dev/null differ diff --git a/cmd/tailscaled/manifest_windows_arm64.syso b/cmd/tailscaled/manifest_windows_arm64.syso deleted file mode 100644 index 7998b6736f2ed..0000000000000 Binary files a/cmd/tailscaled/manifest_windows_arm64.syso and /dev/null differ diff --git a/cmd/tailscaled/proxy.go b/cmd/tailscaled/proxy.go deleted file mode 100644 index a91c62bfa44ac..0000000000000 --- a/cmd/tailscaled/proxy.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 - -// HTTP proxy code - -package main - -import ( - "context" - "io" - "net" - "net/http" - "net/http/httputil" - "strings" -) - -// httpProxyHandler returns an HTTP proxy http.Handler using the -// provided backend dialer. -func httpProxyHandler(dialer func(ctx context.Context, netw, addr string) (net.Conn, error)) http.Handler { - rp := &httputil.ReverseProxy{ - Director: func(r *http.Request) {}, // no change - Transport: &http.Transport{ - DialContext: dialer, - }, - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "CONNECT" { - backURL := r.RequestURI - if strings.HasPrefix(backURL, "/") || backURL == "*" { - http.Error(w, "bogus RequestURI; must be absolute URL or CONNECT", 400) - return - } - rp.ServeHTTP(w, r) - return - } - - // CONNECT support: - - dst := r.RequestURI - c, err := dialer(r.Context(), "tcp", dst) - if err != nil { - w.Header().Set("Tailscale-Connect-Error", err.Error()) - http.Error(w, err.Error(), 500) - return - } - defer c.Close() - - cc, ccbuf, err := w.(http.Hijacker).Hijack() - if err != nil { - http.Error(w, err.Error(), 500) - return - } - defer cc.Close() - - io.WriteString(cc, "HTTP/1.1 200 OK\r\n\r\n") - - var clientSrc io.Reader = ccbuf - if ccbuf.Reader.Buffered() == 0 { - // In the common case (with no - // buffered data), read directly from - // the underlying client connection to - // save some memory, letting the - // bufio.Reader/Writer get GC'ed. - clientSrc = cc - } - - errc := make(chan error, 1) - go func() { - _, err := io.Copy(cc, c) - errc <- err - }() - go func() { - _, err := io.Copy(c, clientSrc) - errc <- err - }() - <-errc - }) -} diff --git a/cmd/tailscaled/required_version.go b/cmd/tailscaled/required_version.go deleted file mode 100644 index 3acb3d52e4d8c..0000000000000 --- a/cmd/tailscaled/required_version.go +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !go1.23 - -package main - -func init() { - you_need_Go_1_23_to_compile_Tailscale() -} diff --git a/cmd/tailscaled/sigpipe.go b/cmd/tailscaled/sigpipe.go deleted file mode 100644 index 2fcdab2a4660e..0000000000000 --- a/cmd/tailscaled/sigpipe.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.21 && !plan9 - -package main - -import "syscall" - -func init() { - sigPipe = syscall.SIGPIPE -} diff --git a/cmd/tailscaled/ssh.go b/cmd/tailscaled/ssh.go deleted file mode 100644 index b10a3b7748719..0000000000000 --- a/cmd/tailscaled/ssh.go +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build (linux || darwin || freebsd || openbsd) && !ts_omit_ssh - -package main - -// Force registration of tailssh with LocalBackend. -import _ "tailscale.com/ssh/tailssh" diff --git a/cmd/tailscaled/taildrop.go b/cmd/tailscaled/taildrop.go deleted file mode 100644 index 39fe54373bdda..0000000000000 --- a/cmd/tailscaled/taildrop.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 - -package main - -import ( - "fmt" - "os" - "path/filepath" - - "tailscale.com/ipn/ipnlocal" - "tailscale.com/types/logger" - "tailscale.com/version/distro" -) - -func configureTaildrop(logf logger.Logf, lb *ipnlocal.LocalBackend) { - dg := distro.Get() - switch dg { - case distro.Synology, distro.TrueNAS, distro.QNAP, distro.Unraid: - // See if they have a "Taildrop" share. - // See https://github.com/tailscale/tailscale/issues/2179#issuecomment-982821319 - path, err := findTaildropDir(dg) - if err != nil { - logf("%s Taildrop support: %v", dg, err) - } else { - logf("%s Taildrop: using %v", dg, path) - lb.SetDirectFileRoot(path) - } - } - -} - -func findTaildropDir(dg distro.Distro) (string, error) { - const name = "Taildrop" - switch dg { - case distro.Synology: - return findSynologyTaildropDir(name) - case distro.TrueNAS: - return findTrueNASTaildropDir(name) - case distro.QNAP: - return findQnapTaildropDir(name) - case distro.Unraid: - return findUnraidTaildropDir(name) - } - return "", fmt.Errorf("%s is an unsupported distro for Taildrop dir", dg) -} - -// findSynologyTaildropDir looks for the first volume containing a -// "Taildrop" directory. We'd run "synoshare --get Taildrop" command -// but on DSM7 at least, we lack permissions to run that. -func findSynologyTaildropDir(name string) (dir string, err error) { - for i := 1; i <= 16; i++ { - dir = fmt.Sprintf("/volume%v/%s", i, name) - if fi, err := os.Stat(dir); err == nil && fi.IsDir() { - return dir, nil - } - } - return "", fmt.Errorf("shared folder %q not found", name) -} - -// findTrueNASTaildropDir returns the first matching directory of -// /mnt/{name} or /mnt/*/{name} -func findTrueNASTaildropDir(name string) (dir string, err error) { - // If we're running in a jail, a mount point could just be added at /mnt/Taildrop - dir = fmt.Sprintf("/mnt/%s", name) - if fi, err := os.Stat(dir); err == nil && fi.IsDir() { - return dir, nil - } - - // but if running on the host, it may be something like /mnt/Primary/Taildrop - fis, err := os.ReadDir("/mnt") - if err != nil { - return "", fmt.Errorf("error reading /mnt: %w", err) - } - for _, fi := range fis { - dir = fmt.Sprintf("/mnt/%s/%s", fi.Name(), name) - if fi, err := os.Stat(dir); err == nil && fi.IsDir() { - return dir, nil - } - } - return "", fmt.Errorf("shared folder %q not found", name) -} - -// findQnapTaildropDir checks if a Shared Folder named "Taildrop" exists. -func findQnapTaildropDir(name string) (string, error) { - dir := fmt.Sprintf("/share/%s", name) - fi, err := os.Stat(dir) - if err != nil { - return "", fmt.Errorf("shared folder %q not found", name) - } - if fi.IsDir() { - return dir, nil - } - - // share/Taildrop is usually a symlink to CACHEDEV1_DATA/Taildrop/ or some such. - fullpath, err := filepath.EvalSymlinks(dir) - if err != nil { - return "", fmt.Errorf("symlink to shared folder %q not found", name) - } - if fi, err = os.Stat(fullpath); err == nil && fi.IsDir() { - return dir, nil // return the symlink, how QNAP set it up - } - return "", fmt.Errorf("shared folder %q not found", name) -} - -// findUnraidTaildropDir looks for a directory linked at -// /var/lib/tailscale/Taildrop. This is a symlink to the -// path specified by the user in the Unraid Web UI -func findUnraidTaildropDir(name string) (string, error) { - dir := fmt.Sprintf("/var/lib/tailscale/%s", name) - _, err := os.Stat(dir) - if err != nil { - return "", fmt.Errorf("symlink %q not found", name) - } - - fullpath, err := filepath.EvalSymlinks(dir) - if err != nil { - return "", fmt.Errorf("symlink %q to shared folder not valid", name) - } - - fi, err := os.Stat(fullpath) - if err == nil && fi.IsDir() { - return dir, nil // return the symlink - } - return "", fmt.Errorf("shared folder %q not found", name) -} diff --git a/cmd/tailscaled/tailscaled.defaults b/cmd/tailscaled/tailscaled.defaults deleted file mode 100644 index e8384a4f82097..0000000000000 --- a/cmd/tailscaled/tailscaled.defaults +++ /dev/null @@ -1,8 +0,0 @@ -# Set the port to listen on for incoming VPN packets. -# Remote nodes will automatically be informed about the new port number, -# but you might want to configure this in order to set external firewall -# settings. -PORT="41641" - -# Extra flags you might want to pass to tailscaled. -FLAGS="" diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go deleted file mode 100644 index 7a5ee03983f44..0000000000000 --- a/cmd/tailscaled/tailscaled.go +++ /dev/null @@ -1,926 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.23 - -// The tailscaled program is the Tailscale client daemon. It's configured -// and controlled via the tailscale CLI program. -// -// It primarily supports Linux, though other systems will likely be -// supported in the future. -package main // import "tailscale.com/cmd/tailscaled" - -import ( - "context" - "errors" - "expvar" - "flag" - "fmt" - "log" - "net" - "net/http" - "net/http/pprof" - "net/netip" - "os" - "os/signal" - "path/filepath" - "runtime" - "strconv" - "strings" - "syscall" - "time" - - "tailscale.com/client/tailscale" - "tailscale.com/cmd/tailscaled/childproc" - "tailscale.com/control/controlclient" - "tailscale.com/drive/driveimpl" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/conffile" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/ipnserver" - "tailscale.com/ipn/store" - "tailscale.com/logpolicy" - "tailscale.com/logtail" - "tailscale.com/net/dns" - "tailscale.com/net/dnsfallback" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/proxymux" - "tailscale.com/net/socks5" - "tailscale.com/net/tsdial" - "tailscale.com/net/tshttpproxy" - "tailscale.com/net/tstun" - "tailscale.com/paths" - "tailscale.com/safesocket" - "tailscale.com/syncs" - "tailscale.com/tsd" - "tailscale.com/tsweb/varz" - "tailscale.com/types/flagtype" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/util/clientmetric" - "tailscale.com/util/multierr" - "tailscale.com/util/osshare" - "tailscale.com/version" - "tailscale.com/version/distro" - "tailscale.com/wgengine" - "tailscale.com/wgengine/netstack" - "tailscale.com/wgengine/router" -) - -// defaultTunName returns the default tun device name for the platform. -func defaultTunName() string { - switch runtime.GOOS { - case "openbsd": - return "tun" - case "windows": - return "Tailscale" - case "darwin": - // "utun" is recognized by wireguard-go/tun/tun_darwin.go - // as a magic value that uses/creates any free number. - return "utun" - case "plan9", "aix": - return "userspace-networking" - case "linux": - switch distro.Get() { - case distro.Synology: - // Try TUN, but fall back to userspace networking if needed. - // See https://github.com/tailscale/tailscale-synology/issues/35 - return "tailscale0,userspace-networking" - } - - } - return "tailscale0" -} - -// defaultPort returns the default UDP port to listen on for disco+wireguard. -// By default it returns 0, to pick one randomly from the kernel. -// If the environment variable PORT is set, that's used instead. -// The PORT environment variable is chosen to match what the Linux systemd -// unit uses, to make documentation more consistent. -func defaultPort() uint16 { - if s := envknob.String("PORT"); s != "" { - if p, err := strconv.ParseUint(s, 10, 16); err == nil { - return uint16(p) - } - } - if envknob.GOOS() == "windows" { - return 41641 - } - return 0 -} - -var args struct { - // tunname is a /dev/net/tun tunnel name ("tailscale0"), the - // string "userspace-networking", "tap:TAPNAME[:BRIDGENAME]" - // or comma-separated list thereof. - tunname string - - cleanUp bool - confFile string // empty, file path, or "vm:user-data" - debug string - port uint16 - statepath string - statedir string - socketpath string - birdSocketPath string - verbose int - socksAddr string // listen address for SOCKS5 server - httpProxyAddr string // listen address for HTTP proxy server - disableLogs bool -} - -var ( - installSystemDaemon func([]string) error // non-nil on some platforms - uninstallSystemDaemon func([]string) error // non-nil on some platforms - createBIRDClient func(string) (wgengine.BIRDClient, error) // non-nil on some platforms -) - -// Note - we use function pointers for subcommands so that subcommands like -// installSystemDaemon and uninstallSystemDaemon can be assigned platform- -// specific variants. - -var subCommands = map[string]*func([]string) error{ - "install-system-daemon": &installSystemDaemon, - "uninstall-system-daemon": &uninstallSystemDaemon, - "debug": &debugModeFunc, - "be-child": &beChildFunc, - "serve-taildrive": &serveDriveFunc, -} - -var beCLI func() // non-nil if CLI is linked in - -func main() { - envknob.PanicIfAnyEnvCheckedInInit() - envknob.ApplyDiskConfig() - applyIntegrationTestEnvKnob() - - defaultVerbosity := envknob.RegisterInt("TS_LOG_VERBOSITY") - printVersion := false - flag.IntVar(&args.verbose, "verbose", defaultVerbosity(), "log verbosity level; 0 is default, 1 or higher are increasingly verbose") - flag.BoolVar(&args.cleanUp, "cleanup", false, "clean up system state and exit") - flag.StringVar(&args.debug, "debug", "", "listen address ([ip]:port) of optional debug server") - flag.StringVar(&args.socksAddr, "socks5-server", "", `optional [ip]:port to run a SOCK5 server (e.g. "localhost:1080")`) - flag.StringVar(&args.httpProxyAddr, "outbound-http-proxy-listen", "", `optional [ip]:port to run an outbound HTTP proxy (e.g. "localhost:8080")`) - flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`) - flag.Var(flagtype.PortValue(&args.port, defaultPort()), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select") - flag.StringVar(&args.statepath, "state", "", "absolute path of state file; use 'kube:' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an ephemeral node. If empty and --statedir is provided, the default is /tailscaled.state. Default: "+paths.DefaultTailscaledStateFile()) - flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.") - flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket") - flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket") - flag.BoolVar(&printVersion, "version", false, "print version information and exit") - flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support") - flag.StringVar(&args.confFile, "config", "", "path to config file, or 'vm:user-data' to use the VM's user-data (EC2)") - - if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil { - beCLI() - return - } - - if len(os.Args) > 1 { - sub := os.Args[1] - if fp, ok := subCommands[sub]; ok { - if fp == nil { - log.SetFlags(0) - log.Fatalf("%s not available on %v", sub, runtime.GOOS) - } - if err := (*fp)(os.Args[2:]); err != nil { - log.SetFlags(0) - log.Fatal(err) - } - return - } - } - - flag.Parse() - if flag.NArg() > 0 { - // Windows subprocess is spawned with /subprocess, so we need to avoid this check there. - if runtime.GOOS != "windows" || (flag.Arg(0) != "/subproc" && flag.Arg(0) != "/firewall") { - log.Fatalf("tailscaled does not take non-flag arguments: %q", flag.Args()) - } - } - - if fd, ok := envknob.LookupInt("TS_PARENT_DEATH_FD"); ok && fd > 2 { - go dieOnPipeReadErrorOfFD(fd) - } - - if printVersion { - fmt.Println(version.String()) - os.Exit(0) - } - - if runtime.GOOS == "darwin" && os.Getuid() != 0 && !strings.Contains(args.tunname, "userspace-networking") && !args.cleanUp { - log.SetFlags(0) - log.Fatalf("tailscaled requires root; use sudo tailscaled (or use --tun=userspace-networking)") - } - - if args.socketpath == "" && runtime.GOOS != "windows" { - log.SetFlags(0) - log.Fatalf("--socket is required") - } - - if args.birdSocketPath != "" && createBIRDClient == nil { - log.SetFlags(0) - log.Fatalf("--bird-socket is not supported on %s", runtime.GOOS) - } - - // Only apply a default statepath when neither have been provided, so that a - // user may specify only --statedir if they wish. - if args.statepath == "" && args.statedir == "" { - args.statepath = paths.DefaultTailscaledStateFile() - } - - if args.disableLogs { - envknob.SetNoLogsNoSupport() - } - - if beWindowsSubprocess() { - return - } - - err := run() - - // Remove file sharing from Windows shell (noop in non-windows) - osshare.SetFileSharingEnabled(false, logger.Discard) - - if err != nil { - log.Fatal(err) - } -} - -func trySynologyMigration(p string) error { - if runtime.GOOS != "linux" || distro.Get() != distro.Synology { - return nil - } - - fi, err := os.Stat(p) - if err == nil && fi.Size() > 0 || !os.IsNotExist(err) { - return err - } - // File is empty or doesn't exist, try reading from the old path. - - const oldPath = "/var/packages/Tailscale/etc/tailscaled.state" - if _, err := os.Stat(oldPath); err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - if err := os.Chown(oldPath, os.Getuid(), os.Getgid()); err != nil { - return err - } - if err := os.Rename(oldPath, p); err != nil { - return err - } - return nil -} - -func statePathOrDefault() string { - if args.statepath != "" { - return args.statepath - } - if args.statedir != "" { - return filepath.Join(args.statedir, "tailscaled.state") - } - return "" -} - -// serverOptions is the configuration of the Tailscale node agent. -type serverOptions struct { - // VarRoot is the Tailscale daemon's private writable - // directory (usually "/var/lib/tailscale" on Linux) that - // contains the "tailscaled.state" file, the "certs" directory - // for TLS certs, and the "files" directory for incoming - // Taildrop files before they're moved to a user directory. - // If empty, Taildrop and TLS certs don't function. - VarRoot string - - // LoginFlags specifies the LoginFlags to pass to the client. - LoginFlags controlclient.LoginFlags -} - -func ipnServerOpts() (o serverOptions) { - goos := envknob.GOOS() - - o.VarRoot = args.statedir - - // If an absolute --state is provided but not --statedir, try to derive - // a state directory. - if o.VarRoot == "" && filepath.IsAbs(args.statepath) { - if dir := filepath.Dir(args.statepath); strings.EqualFold(filepath.Base(dir), "tailscale") { - o.VarRoot = dir - } - } - if strings.HasPrefix(statePathOrDefault(), "mem:") { - // Register as an ephemeral node. - o.LoginFlags = controlclient.LoginEphemeral - } - - switch goos { - case "js": - // The js/wasm client has no state storage so for now - // treat all interactive logins as ephemeral. - // TODO(bradfitz): if we start using browser LocalStorage - // or something, then rethink this. - o.LoginFlags = controlclient.LoginEphemeral - case "windows": - // Not those. - } - return o -} - -var logPol *logpolicy.Policy -var debugMux *http.ServeMux - -func run() (err error) { - var logf logger.Logf = log.Printf - - sys := new(tsd.System) - - // Parse config, if specified, to fail early if it's invalid. - var conf *conffile.Config - if args.confFile != "" { - conf, err = conffile.Load(args.confFile) - if err != nil { - return fmt.Errorf("error reading config file: %w", err) - } - sys.InitialConfig = conf - } - - var netMon *netmon.Monitor - isWinSvc := isWindowsService() - if !isWinSvc { - netMon, err = netmon.New(func(format string, args ...any) { - logf(format, args...) - }) - if err != nil { - return fmt.Errorf("netmon.New: %w", err) - } - sys.Set(netMon) - } - - pol := logpolicy.New(logtail.CollectionNode, netMon, sys.HealthTracker(), nil /* use log.Printf */) - pol.SetVerbosityLevel(args.verbose) - logPol = pol - defer func() { - // Finish uploading logs after closing everything else. - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - pol.Shutdown(ctx) - }() - - if err := envknob.ApplyDiskConfigError(); err != nil { - log.Printf("Error reading environment config: %v", err) - } - - if isWinSvc { - // Run the IPN server from the Windows service manager. - log.Printf("Running service...") - if err := runWindowsService(pol); err != nil { - log.Printf("runservice: %v", err) - } - log.Printf("Service ended.") - return nil - } - - if envknob.Bool("TS_DEBUG_MEMORY") { - logf = logger.RusagePrefixLog(logf) - } - logf = logger.RateLimitedFn(logf, 5*time.Second, 5, 100) - - if envknob.Bool("TS_PLEASE_PANIC") { - panic("TS_PLEASE_PANIC asked us to panic") - } - // Always clean up, even if we're going to run the server. This covers cases - // such as when a system was rebooted without shutting down, or tailscaled - // crashed, and would for example restore system DNS configuration. - dns.CleanUp(logf, netMon, sys.HealthTracker(), args.tunname) - router.CleanUp(logf, netMon, args.tunname) - // If the cleanUp flag was passed, then exit. - if args.cleanUp { - return nil - } - - if args.statepath == "" && args.statedir == "" { - log.Fatalf("--statedir (or at least --state) is required") - } - if err := trySynologyMigration(statePathOrDefault()); err != nil { - log.Printf("error in synology migration: %v", err) - } - - if args.debug != "" { - debugMux = newDebugMux() - } - - sys.Set(driveimpl.NewFileSystemForRemote(logf)) - - if app := envknob.App(); app != "" { - hostinfo.SetApp(app) - } - - return startIPNServer(context.Background(), logf, pol.PublicID, sys) -} - -var sigPipe os.Signal // set by sigpipe.go - -func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID, sys *tsd.System) error { - ln, err := safesocket.Listen(args.socketpath) - if err != nil { - return fmt.Errorf("safesocket.Listen: %v", err) - } - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - // Exit gracefully by cancelling the ipnserver context in most common cases: - // interrupted from the TTY or killed by a service manager. - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) - // SIGPIPE sometimes gets generated when CLIs disconnect from - // tailscaled. The default action is to terminate the process, we - // want to keep running. - if sigPipe != nil { - signal.Ignore(sigPipe) - } - wgEngineCreated := make(chan struct{}) - go func() { - var wgEngineClosed <-chan struct{} - wgEngineCreated := wgEngineCreated // local shadow - for { - select { - case s := <-interrupt: - logf("tailscaled got signal %v; shutting down", s) - cancel() - return - case <-wgEngineClosed: - logf("wgengine has been closed; shutting down") - cancel() - return - case <-wgEngineCreated: - wgEngineClosed = sys.Engine.Get().Done() - wgEngineCreated = nil - case <-ctx.Done(): - return - } - } - }() - - srv := ipnserver.New(logf, logID, sys.NetMon.Get()) - if debugMux != nil { - debugMux.HandleFunc("/debug/ipn", srv.ServeHTMLStatus) - } - var lbErr syncs.AtomicValue[error] - - go func() { - t0 := time.Now() - if s, ok := envknob.LookupInt("TS_DEBUG_BACKEND_DELAY_SEC"); ok { - d := time.Duration(s) * time.Second - logf("sleeping %v before starting backend...", d) - select { - case <-time.After(d): - logf("slept %v; starting backend...", d) - case <-ctx.Done(): - return - } - } - lb, err := getLocalBackend(ctx, logf, logID, sys) - if err == nil { - logf("got LocalBackend in %v", time.Since(t0).Round(time.Millisecond)) - if lb.Prefs().Valid() { - if err := lb.Start(ipn.Options{}); err != nil { - logf("LocalBackend.Start: %v", err) - lb.Shutdown() - lbErr.Store(err) - cancel() - return - } - } - srv.SetLocalBackend(lb) - close(wgEngineCreated) - return - } - lbErr.Store(err) // before the following cancel - cancel() // make srv.Run below complete - }() - - err = srv.Run(ctx, ln) - - if err != nil && lbErr.Load() != nil { - return fmt.Errorf("getLocalBackend error: %v", lbErr.Load()) - } - - // Cancelation is not an error: it is the only way to stop ipnserver. - if err != nil && !errors.Is(err, context.Canceled) { - return fmt.Errorf("ipnserver.Run: %w", err) - } - - return nil -} - -func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID, sys *tsd.System) (_ *ipnlocal.LocalBackend, retErr error) { - if logPol != nil { - logPol.Logtail.SetNetMon(sys.NetMon.Get()) - } - - socksListener, httpProxyListener := mustStartProxyListeners(args.socksAddr, args.httpProxyAddr) - - dialer := &tsdial.Dialer{Logf: logf} // mutated below (before used) - sys.Set(dialer) - - onlyNetstack, err := createEngine(logf, sys) - if err != nil { - return nil, fmt.Errorf("createEngine: %w", err) - } - if debugMux != nil { - if ms, ok := sys.MagicSock.GetOK(); ok { - debugMux.HandleFunc("/debug/magicsock", ms.ServeHTTPDebug) - } - go runDebugServer(debugMux, args.debug) - } - - ns, err := newNetstack(logf, sys) - if err != nil { - return nil, fmt.Errorf("newNetstack: %w", err) - } - sys.Set(ns) - ns.ProcessLocalIPs = onlyNetstack - ns.ProcessSubnets = onlyNetstack || handleSubnetsInNetstack() - - if onlyNetstack { - e := sys.Engine.Get() - dialer.UseNetstackForIP = func(ip netip.Addr) bool { - _, ok := e.PeerForIP(ip) - return ok - } - dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - // Note: don't just return ns.DialContextTCP or we'll return - // *gonet.TCPConn(nil) instead of a nil interface which trips up - // callers. - tcpConn, err := ns.DialContextTCP(ctx, dst) - if err != nil { - return nil, err - } - return tcpConn, nil - } - dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - // Note: don't just return ns.DialContextUDP or we'll return - // *gonet.UDPConn(nil) instead of a nil interface which trips up - // callers. - udpConn, err := ns.DialContextUDP(ctx, dst) - if err != nil { - return nil, err - } - return udpConn, nil - } - } - if socksListener != nil || httpProxyListener != nil { - var addrs []string - if httpProxyListener != nil { - hs := &http.Server{Handler: httpProxyHandler(dialer.UserDial)} - go func() { - log.Fatalf("HTTP proxy exited: %v", hs.Serve(httpProxyListener)) - }() - addrs = append(addrs, httpProxyListener.Addr().String()) - } - if socksListener != nil { - ss := &socks5.Server{ - Logf: logger.WithPrefix(logf, "socks5: "), - Dialer: dialer.UserDial, - } - go func() { - log.Fatalf("SOCKS5 server exited: %v", ss.Serve(socksListener)) - }() - addrs = append(addrs, socksListener.Addr().String()) - } - tshttpproxy.SetSelfProxy(addrs...) - } - - opts := ipnServerOpts() - - store, err := store.New(logf, statePathOrDefault()) - if err != nil { - return nil, fmt.Errorf("store.New: %w", err) - } - sys.Set(store) - - if w, ok := sys.Tun.GetOK(); ok { - w.Start() - } - - lb, err := ipnlocal.NewLocalBackend(logf, logID, sys, opts.LoginFlags) - if err != nil { - return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err) - } - lb.SetVarRoot(opts.VarRoot) - if logPol != nil { - lb.SetLogFlusher(logPol.Logtail.StartFlush) - } - if root := lb.TailscaleVarRoot(); root != "" { - dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf) - } - lb.ConfigureWebClient(&tailscale.LocalClient{ - Socket: args.socketpath, - UseSocketOnly: args.socketpath != paths.DefaultTailscaledSocket(), - }) - configureTaildrop(logf, lb) - if err := ns.Start(lb); err != nil { - log.Fatalf("failed to start netstack: %v", err) - } - return lb, nil -} - -// createEngine tries to the wgengine.Engine based on the order of tunnels -// specified in the command line flags. -// -// onlyNetstack is true if the user has explicitly requested that we use netstack -// for all networking. -func createEngine(logf logger.Logf, sys *tsd.System) (onlyNetstack bool, err error) { - if args.tunname == "" { - return false, errors.New("no --tun value specified") - } - var errs []error - for _, name := range strings.Split(args.tunname, ",") { - logf("wgengine.NewUserspaceEngine(tun %q) ...", name) - onlyNetstack, err = tryEngine(logf, sys, name) - if err == nil { - return onlyNetstack, nil - } - logf("wgengine.NewUserspaceEngine(tun %q) error: %v", name, err) - errs = append(errs, err) - } - return false, multierr.New(errs...) -} - -// handleSubnetsInNetstack reports whether netstack should handle subnet routers -// as opposed to the OS. We do this if the OS doesn't support subnet routers -// (e.g. Windows) or if the user has explicitly requested it (e.g. -// --tun=userspace-networking). -func handleSubnetsInNetstack() bool { - if v, ok := envknob.LookupBool("TS_DEBUG_NETSTACK_SUBNETS"); ok { - return v - } - if distro.Get() == distro.Synology { - return true - } - switch runtime.GOOS { - case "windows", "darwin", "freebsd", "openbsd": - // Enable on Windows and tailscaled-on-macOS (this doesn't - // affect the GUI clients), and on FreeBSD. - return true - } - return false -} - -var tstunNew = tstun.New - -func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack bool, err error) { - conf := wgengine.Config{ - ListenPort: args.port, - NetMon: sys.NetMon.Get(), - HealthTracker: sys.HealthTracker(), - Metrics: sys.UserMetricsRegistry(), - Dialer: sys.Dialer.Get(), - SetSubsystem: sys.Set, - ControlKnobs: sys.ControlKnobs(), - DriveForLocal: driveimpl.NewFileSystemForLocal(logf), - } - - sys.HealthTracker().SetMetricsRegistry(sys.UserMetricsRegistry()) - - onlyNetstack = name == "userspace-networking" - netstackSubnetRouter := onlyNetstack // but mutated later on some platforms - netns.SetEnabled(!onlyNetstack) - - if args.birdSocketPath != "" && createBIRDClient != nil { - log.Printf("Connecting to BIRD at %s ...", args.birdSocketPath) - conf.BIRDClient, err = createBIRDClient(args.birdSocketPath) - if err != nil { - return false, fmt.Errorf("createBIRDClient: %w", err) - } - } - if onlyNetstack { - if runtime.GOOS == "linux" && distro.Get() == distro.Synology { - // On Synology in netstack mode, still init a DNS - // manager (directManager) to avoid the health check - // warnings in 'tailscale status' about DNS base - // configuration being unavailable (from the noop - // manager). More in Issue 4017. - // TODO(bradfitz): add a Synology-specific DNS manager. - conf.DNS, err = dns.NewOSConfigurator(logf, sys.HealthTracker(), sys.ControlKnobs(), "") // empty interface name - if err != nil { - return false, fmt.Errorf("dns.NewOSConfigurator: %w", err) - } - } - } else { - dev, devName, err := tstunNew(logf, name) - if err != nil { - tstun.Diagnose(logf, name, err) - return false, fmt.Errorf("tstun.New(%q): %w", name, err) - } - conf.Tun = dev - if strings.HasPrefix(name, "tap:") { - conf.IsTAP = true - e, err := wgengine.NewUserspaceEngine(logf, conf) - if err != nil { - return false, err - } - sys.Set(e) - return false, err - } - - r, err := router.New(logf, dev, sys.NetMon.Get(), sys.HealthTracker()) - if err != nil { - dev.Close() - return false, fmt.Errorf("creating router: %w", err) - } - - d, err := dns.NewOSConfigurator(logf, sys.HealthTracker(), sys.ControlKnobs(), devName) - if err != nil { - dev.Close() - r.Close() - return false, fmt.Errorf("dns.NewOSConfigurator: %w", err) - } - conf.DNS = d - conf.Router = r - if handleSubnetsInNetstack() { - netstackSubnetRouter = true - } - sys.Set(conf.Router) - } - e, err := wgengine.NewUserspaceEngine(logf, conf) - if err != nil { - return onlyNetstack, err - } - e = wgengine.NewWatchdog(e) - sys.Set(e) - sys.NetstackRouter.Set(netstackSubnetRouter) - - return onlyNetstack, nil -} - -func newDebugMux() *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("/debug/metrics", servePrometheusMetrics) - mux.HandleFunc("/debug/pprof/", pprof.Index) - mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) - mux.HandleFunc("/debug/pprof/profile", pprof.Profile) - mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) - mux.HandleFunc("/debug/pprof/trace", pprof.Trace) - return mux -} - -func servePrometheusMetrics(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - varz.Handler(w, r) - clientmetric.WritePrometheusExpositionFormat(w) -} - -func runDebugServer(mux *http.ServeMux, addr string) { - srv := &http.Server{ - Addr: addr, - Handler: mux, - } - if err := srv.ListenAndServe(); err != nil { - log.Fatal(err) - } -} - -func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) { - ret, err := netstack.Create(logf, - sys.Tun.Get(), - sys.Engine.Get(), - sys.MagicSock.Get(), - sys.Dialer.Get(), - sys.DNSManager.Get(), - sys.ProxyMapper(), - ) - if err != nil { - return nil, err - } - // Only register debug info if we have a debug mux - if debugMux != nil { - expvar.Publish("netstack", ret.ExpVar()) - } - return ret, nil -} - -// mustStartProxyListeners creates listeners for local SOCKS and HTTP -// proxies, if the respective addresses are not empty. socksAddr and -// httpAddr can be the same, in which case socksListener will receive -// connections that look like they're speaking SOCKS and httpListener -// will receive everything else. -// -// socksListener and httpListener can be nil, if their respective -// addrs are empty. -func mustStartProxyListeners(socksAddr, httpAddr string) (socksListener, httpListener net.Listener) { - if socksAddr == httpAddr && socksAddr != "" && !strings.HasSuffix(socksAddr, ":0") { - ln, err := net.Listen("tcp", socksAddr) - if err != nil { - log.Fatalf("proxy listener: %v", err) - } - return proxymux.SplitSOCKSAndHTTP(ln) - } - - var err error - if socksAddr != "" { - socksListener, err = net.Listen("tcp", socksAddr) - if err != nil { - log.Fatalf("SOCKS5 listener: %v", err) - } - if strings.HasSuffix(socksAddr, ":0") { - // Log kernel-selected port number so integration tests - // can find it portably. - log.Printf("SOCKS5 listening on %v", socksListener.Addr()) - } - } - if httpAddr != "" { - httpListener, err = net.Listen("tcp", httpAddr) - if err != nil { - log.Fatalf("HTTP proxy listener: %v", err) - } - if strings.HasSuffix(httpAddr, ":0") { - // Log kernel-selected port number so integration tests - // can find it portably. - log.Printf("HTTP proxy listening on %v", httpListener.Addr()) - } - } - - return socksListener, httpListener -} - -var beChildFunc = beChild - -func beChild(args []string) error { - if len(args) == 0 { - return errors.New("missing mode argument") - } - typ := args[0] - f, ok := childproc.Code[typ] - if !ok { - return fmt.Errorf("unknown be-child mode %q", typ) - } - return f(args[1:]) -} - -var serveDriveFunc = serveDrive - -// serveDrive serves one or more Taildrives on localhost using the WebDAV -// protocol. On UNIX and MacOS tailscaled environment, Taildrive spawns child -// tailscaled processes in serve-taildrive mode in order to access the fliesystem -// as specific (usually unprivileged) users. -// -// serveDrive prints the address on which it's listening to stdout so that the -// parent process knows where to connect to. -func serveDrive(args []string) error { - if len(args) == 0 { - return errors.New("missing shares") - } - if len(args)%2 != 0 { - return errors.New("need pairs") - } - s, err := driveimpl.NewFileServer() - if err != nil { - return fmt.Errorf("unable to start Taildrive file server: %v", err) - } - shares := make(map[string]string) - for i := 0; i < len(args); i += 2 { - shares[args[i]] = args[i+1] - } - s.SetShares(shares) - fmt.Printf("%v\n", s.Addr()) - return s.Serve() -} - -// dieOnPipeReadErrorOfFD reads from the pipe named by fd and exit the process -// when the pipe becomes readable. We use this in tests as a somewhat more -// portable mechanism for the Linux PR_SET_PDEATHSIG, which we wish existed on -// macOS. This helps us clean up straggler tailscaled processes when the parent -// test driver dies unexpectedly. -func dieOnPipeReadErrorOfFD(fd int) { - f := os.NewFile(uintptr(fd), "TS_PARENT_DEATH_FD") - f.Read(make([]byte, 1)) - os.Exit(1) -} - -// applyIntegrationTestEnvKnob applies the tailscaled.env=... environment -// variables specified on the Linux kernel command line, if the VM is being -// run in NATLab integration tests. -// -// They're specified as: tailscaled.env=FOO=bar tailscaled.env=BAR=baz -func applyIntegrationTestEnvKnob() { - if runtime.GOOS != "linux" || !hostinfo.IsNATLabGuestVM() { - return - } - cmdLine, _ := os.ReadFile("/proc/cmdline") - for _, s := range strings.Fields(string(cmdLine)) { - suf, ok := strings.CutPrefix(s, "tailscaled.env=") - if !ok { - continue - } - if k, v, ok := strings.Cut(suf, "="); ok { - envknob.Setenv(k, v) - } - } -} diff --git a/cmd/tailscaled/tailscaled.openrc b/cmd/tailscaled/tailscaled.openrc deleted file mode 100755 index 309d70f23a26f..0000000000000 --- a/cmd/tailscaled/tailscaled.openrc +++ /dev/null @@ -1,25 +0,0 @@ -#!/sbin/openrc-run - -set -a -source /etc/default/tailscaled -set +a - -command="/usr/sbin/tailscaled" -command_args="--state=/var/lib/tailscale/tailscaled.state --port=$PORT --socket=/var/run/tailscale/tailscaled.sock $FLAGS" -command_background=true -pidfile="/run/tailscaled.pid" -start_stop_daemon_args="-1 /var/log/tailscaled.log -2 /var/log/tailscaled.log" - -depend() { - need net -} - -start_pre() { - mkdir -p /var/run/tailscale - mkdir -p /var/lib/tailscale - $command --cleanup -} - -stop_post() { - $command --cleanup -} diff --git a/cmd/tailscaled/tailscaled.service b/cmd/tailscaled/tailscaled.service deleted file mode 100644 index 719a3c0c96398..0000000000000 --- a/cmd/tailscaled/tailscaled.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=Tailscale node agent -Documentation=https://tailscale.com/kb/ -Wants=network-pre.target -After=network-pre.target NetworkManager.service systemd-resolved.service - -[Service] -EnvironmentFile=/etc/default/tailscaled -ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=${PORT} $FLAGS -ExecStopPost=/usr/sbin/tailscaled --cleanup - -Restart=on-failure - -RuntimeDirectory=tailscale -RuntimeDirectoryMode=0755 -StateDirectory=tailscale -StateDirectoryMode=0700 -CacheDirectory=tailscale -CacheDirectoryMode=0750 -Type=notify - -[Install] -WantedBy=multi-user.target diff --git a/cmd/tailscaled/tailscaled_bird.go b/cmd/tailscaled/tailscaled_bird.go deleted file mode 100644 index c76f77bec6e36..0000000000000 --- a/cmd/tailscaled/tailscaled_bird.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 && (linux || darwin || freebsd || openbsd) && !ts_omit_bird - -package main - -import ( - "tailscale.com/chirp" - "tailscale.com/wgengine" -) - -func init() { - createBIRDClient = func(ctlSocket string) (wgengine.BIRDClient, error) { - return chirp.New(ctlSocket) - } -} diff --git a/cmd/tailscaled/tailscaled_notwindows.go b/cmd/tailscaled/tailscaled_notwindows.go deleted file mode 100644 index d5361cf286d3d..0000000000000 --- a/cmd/tailscaled/tailscaled_notwindows.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && go1.19 - -package main // import "tailscale.com/cmd/tailscaled" - -import "tailscale.com/logpolicy" - -func isWindowsService() bool { return false } - -func runWindowsService(pol *logpolicy.Policy) error { panic("unreachable") } - -func beWindowsSubprocess() bool { return false } diff --git a/cmd/tailscaled/tailscaled_test.go b/cmd/tailscaled/tailscaled_test.go deleted file mode 100644 index 5045468d6543a..0000000000000 --- a/cmd/tailscaled/tailscaled_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main // import "tailscale.com/cmd/tailscaled" - -import ( - "testing" - - "tailscale.com/tstest/deptest" -) - -func TestNothing(t *testing.T) { - // This test does nothing on purpose, so we can run - // GODEBUG=memprofilerate=1 go test -v -run=Nothing -memprofile=prof.mem - // without any errors about no matching tests. -} - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - GOOS: "darwin", - GOARCH: "arm64", - BadDeps: map[string]string{ - "testing": "do not use testing package in production code", - "gvisor.dev/gvisor/pkg/hostarch": "will crash on non-4K page sizes; see https://github.com/tailscale/tailscale/issues/8658", - }, - }.Check(t) - - deptest.DepChecker{ - GOOS: "linux", - GOARCH: "arm64", - BadDeps: map[string]string{ - "testing": "do not use testing package in production code", - "gvisor.dev/gvisor/pkg/hostarch": "will crash on non-4K page sizes; see https://github.com/tailscale/tailscale/issues/8658", - }, - }.Check(t) -} diff --git a/cmd/tailscaled/tailscaled_windows.go b/cmd/tailscaled/tailscaled_windows.go deleted file mode 100644 index 35c878f38ece3..0000000000000 --- a/cmd/tailscaled/tailscaled_windows.go +++ /dev/null @@ -1,546 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.19 - -package main // import "tailscale.com/cmd/tailscaled" - -// TODO: check if administrator, like tswin does. -// -// TODO: try to load wintun.dll early at startup, before wireguard/tun -// does (which panics) and if we'd fail (e.g. due to access -// denied, even if administrator), use 'tasklist /m wintun.dll' -// to see if something else is currently using it and tell user. -// -// TODO: check if Tailscale service is already running, and fail early -// like tswin does. -// -// TODO: on failure, check if on a UNC drive and recommend copying it -// to C:\ to run it, like tswin does. - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/netip" - "os" - "os/exec" - "os/signal" - "path/filepath" - "sync" - "syscall" - "time" - - "github.com/dblohm7/wingoes/com" - "github.com/tailscale/wireguard-go/tun" - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/svc" - "golang.org/x/sys/windows/svc/eventlog" - "golang.zx2c4.com/wintun" - "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/drive/driveimpl" - "tailscale.com/envknob" - "tailscale.com/logpolicy" - "tailscale.com/logtail/backoff" - "tailscale.com/net/dns" - "tailscale.com/net/netmon" - "tailscale.com/net/tstun" - "tailscale.com/tsd" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/util/osdiag" - "tailscale.com/util/syspolicy" - "tailscale.com/util/winutil" - "tailscale.com/version" - "tailscale.com/wf" -) - -func init() { - // Initialize COM process-wide. - comProcessType := com.Service - if !isWindowsService() { - comProcessType = com.ConsoleApp - } - if err := com.StartRuntime(comProcessType); err != nil { - log.Printf("wingoes.com.StartRuntime(%d) failed: %v", comProcessType, err) - } -} - -const serviceName = "Tailscale" - -// Application-defined command codes between 128 and 255 -// See https://web.archive.org/web/20221007222822/https://learn.microsoft.com/en-us/windows/win32/api/winsvc/nf-winsvc-controlservice -const ( - cmdUninstallWinTun = svc.Cmd(128 + iota) -) - -func init() { - tstunNew = tstunNewWithWindowsRetries -} - -// tstunNewOrRetry is a wrapper around tstun.New that retries on Windows for certain -// errors. -// -// TODO(bradfitz): move this into tstun and/or just fix the problems so it doesn't -// require a few tries to work. -func tstunNewWithWindowsRetries(logf logger.Logf, tunName string) (_ tun.Device, devName string, _ error) { - bo := backoff.NewBackoff("tstunNew", logf, 10*time.Second) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - for { - dev, devName, err := tstun.New(logf, tunName) - if err == nil { - return dev, devName, err - } - if errors.Is(err, windows.ERROR_DEVICE_NOT_AVAILABLE) || windowsUptime() < 10*time.Minute { - // Wintun is not installing correctly. Dump the state of NetSetupSvc - // (which is a user-mode service that must be active for network devices - // to install) and its dependencies to the log. - winutil.LogSvcState(logf, "NetSetupSvc") - } - bo.BackOff(ctx, err) - if ctx.Err() != nil { - return nil, "", ctx.Err() - } - } -} - -func isWindowsService() bool { - v, err := svc.IsWindowsService() - if err != nil { - log.Fatalf("svc.IsWindowsService failed: %v", err) - } - return v -} - -// syslogf is a logger function that writes to the Windows event log (ie, the -// one that you see in the Windows Event Viewer). tailscaled may optionally -// generate diagnostic messages in the same event timeline as the Windows -// Service Control Manager to assist with diagnosing issues with tailscaled's -// lifetime (such as slow shutdowns). -var syslogf logger.Logf = logger.Discard - -// runWindowsService starts running Tailscale under the Windows -// Service environment. -// -// At this point we're still the parent process that -// Windows started. -func runWindowsService(pol *logpolicy.Policy) error { - go func() { - logger.Logf(log.Printf).JSON(1, "SupportInfo", osdiag.SupportInfo(osdiag.LogSupportInfoReasonStartup)) - }() - - if logSCMInteractions, _ := syspolicy.GetBoolean(syspolicy.LogSCMInteractions, false); logSCMInteractions { - syslog, err := eventlog.Open(serviceName) - if err == nil { - syslogf = func(format string, args ...any) { - syslog.Info(0, fmt.Sprintf(format, args...)) - } - defer syslog.Close() - } - } - - syslogf("Service entering svc.Run") - defer syslogf("Service exiting svc.Run") - return svc.Run(serviceName, &ipnService{Policy: pol}) -} - -type ipnService struct { - Policy *logpolicy.Policy -} - -// Called by Windows to execute the windows service. -func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { - defer syslogf("SvcStopped notification imminent") - - changes <- svc.Status{State: svc.StartPending} - syslogf("Service start pending") - - svcAccepts := svc.AcceptStop - if flushDNSOnSessionUnlock, _ := syspolicy.GetBoolean(syspolicy.FlushDNSOnSessionUnlock, false); flushDNSOnSessionUnlock { - svcAccepts |= svc.AcceptSessionChange - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - args := []string{"/subproc", service.Policy.PublicID.String()} - // Make a logger without a date prefix, as filelogger - // and logtail both already add their own. All we really want - // from the log package is the automatic newline. - // We start with log.Default().Writer(), which is the logtail - // writer that logpolicy already installed as the global - // output. - logger := log.New(log.Default().Writer(), "", 0) - babysitProc(ctx, args, logger.Printf) - }() - - changes <- svc.Status{State: svc.Running, Accepts: svcAccepts} - syslogf("Service running") - - for { - select { - case <-doneCh: - return false, windows.NO_ERROR - case cmd := <-r: - log.Printf("Got Windows Service event: %v", cmdName(cmd.Cmd)) - switch cmd.Cmd { - case svc.Stop: - changes <- svc.Status{State: svc.StopPending} - syslogf("Service stop pending") - cancel() // so BabysitProc will kill the child process - case svc.Interrogate: - syslogf("Service interrogation") - changes <- cmd.CurrentStatus - case svc.SessionChange: - syslogf("Service session change notification") - handleSessionChange(cmd) - changes <- cmd.CurrentStatus - case cmdUninstallWinTun: - syslogf("Stopping tailscaled child process and uninstalling WinTun") - // At this point, doneCh is the channel which will be closed when the - // tailscaled subprocess exits. We save that to childDoneCh. - childDoneCh := doneCh - // We reset doneCh to a new channel that will keep the event loop - // running until the uninstallation is done. - doneCh = make(chan struct{}) - // Trigger subprocess shutdown. - cancel() - go func() { - // When this goroutine completes, tell the service to break out of its - // event loop. - defer close(doneCh) - // Wait for the subprocess to shutdown. - <-childDoneCh - // Now uninstall WinTun. - uninstallWinTun(log.Printf) - }() - changes <- svc.Status{State: svc.StopPending} - } - } - } -} - -func cmdName(c svc.Cmd) string { - switch c { - case svc.Stop: - return "Stop" - case svc.Pause: - return "Pause" - case svc.Continue: - return "Continue" - case svc.Interrogate: - return "Interrogate" - case svc.Shutdown: - return "Shutdown" - case svc.ParamChange: - return "ParamChange" - case svc.NetBindAdd: - return "NetBindAdd" - case svc.NetBindRemove: - return "NetBindRemove" - case svc.NetBindEnable: - return "NetBindEnable" - case svc.NetBindDisable: - return "NetBindDisable" - case svc.DeviceEvent: - return "DeviceEvent" - case svc.HardwareProfileChange: - return "HardwareProfileChange" - case svc.PowerEvent: - return "PowerEvent" - case svc.SessionChange: - return "SessionChange" - case svc.PreShutdown: - return "PreShutdown" - case cmdUninstallWinTun: - return "(Application Defined) Uninstall WinTun" - } - return fmt.Sprintf("Unknown-Service-Cmd-%d", c) -} - -func beWindowsSubprocess() bool { - if beFirewallKillswitch() { - return true - } - - if len(os.Args) != 3 || os.Args[1] != "/subproc" { - return false - } - logID := os.Args[2] - - // Remove the date/time prefix; the logtail + file loggers add it. - log.SetFlags(0) - - log.Printf("Program starting: v%v: %#v", version.Long(), os.Args) - log.Printf("subproc mode: logid=%v", logID) - if err := envknob.ApplyDiskConfigError(); err != nil { - log.Printf("Error reading environment config: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - go func() { - b := make([]byte, 16) - for { - _, err := os.Stdin.Read(b) - if err == io.EOF { - // Parent wants us to shut down gracefully. - log.Printf("subproc received EOF from stdin") - cancel() - return - } - if err != nil { - log.Fatalf("stdin err (parent process died): %v", err) - } - } - }() - - // Pre-load wintun.dll using a fully-qualified path so that wintun-go - // loads our copy and not some (possibly outdated) copy dropped in system32. - // (OSS Issue #10023) - fqWintunPath := fullyQualifiedWintunPath(log.Printf) - if _, err := windows.LoadDLL(fqWintunPath); err != nil { - log.Printf("Error pre-loading \"%s\": %v", fqWintunPath, err) - } - - sys := new(tsd.System) - netMon, err := netmon.New(log.Printf) - if err != nil { - log.Fatalf("Could not create netMon: %v", err) - } - sys.Set(netMon) - - sys.Set(driveimpl.NewFileSystemForRemote(log.Printf)) - - publicLogID, _ := logid.ParsePublicID(logID) - err = startIPNServer(ctx, log.Printf, publicLogID, sys) - if err != nil { - log.Fatalf("ipnserver: %v", err) - } - return true -} - -func beFirewallKillswitch() bool { - if len(os.Args) != 3 || os.Args[1] != "/firewall" { - return false - } - - log.SetFlags(0) - log.Printf("killswitch subprocess starting, tailscale GUID is %s", os.Args[2]) - - guid, err := windows.GUIDFromString(os.Args[2]) - if err != nil { - log.Fatalf("invalid GUID %q: %v", os.Args[2], err) - } - - luid, err := winipcfg.LUIDFromGUID(&guid) - if err != nil { - log.Fatalf("no interface with GUID %q: %v", guid, err) - } - - start := time.Now() - fw, err := wf.New(uint64(luid)) - if err != nil { - log.Fatalf("failed to enable firewall: %v", err) - } - log.Printf("killswitch enabled, took %s", time.Since(start)) - - // Note(maisem): when local lan access toggled, tailscaled needs to - // inform the firewall to let local routes through. The set of routes - // is passed in via stdin encoded in json. - dcd := json.NewDecoder(os.Stdin) - for { - var routes []netip.Prefix - if err := dcd.Decode(&routes); err != nil { - log.Fatalf("parent process died or requested exit, exiting (%v)", err) - } - if err := fw.UpdatePermittedRoutes(routes); err != nil { - log.Fatalf("failed to update routes (%v)", err) - } - } -} - -func handleSessionChange(chgRequest svc.ChangeRequest) { - if chgRequest.Cmd != svc.SessionChange || chgRequest.EventType != windows.WTS_SESSION_UNLOCK { - return - } - - log.Printf("Received WTS_SESSION_UNLOCK event, initiating DNS flush.") - go func() { - err := dns.Flush() - if err != nil { - log.Printf("Error flushing DNS on session unlock: %v", err) - } - }() -} - -var ( - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - getTickCount64Proc = kernel32.NewProc("GetTickCount64") -) - -func windowsUptime() time.Duration { - r, _, _ := getTickCount64Proc.Call() - return time.Duration(int64(r)) * time.Millisecond -} - -// babysitProc runs the current executable as a child process with the -// provided args, capturing its output, writing it to files, and -// restarting the process on any crashes. -func babysitProc(ctx context.Context, args []string, logf logger.Logf) { - - executable, err := os.Executable() - if err != nil { - panic("cannot determine executable: " + err.Error()) - } - - var proc struct { - mu sync.Mutex - p *os.Process - wStdin *os.File - } - - done := make(chan struct{}) - go func() { - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) - var sig os.Signal - select { - case sig = <-interrupt: - logf("babysitProc: got signal: %v", sig) - close(done) - proc.mu.Lock() - proc.p.Signal(sig) - proc.mu.Unlock() - case <-ctx.Done(): - logf("babysitProc: context done") - close(done) - proc.mu.Lock() - // Closing wStdin gives the subprocess a chance to shut down cleanly, - // which is important for cleaning up DNS settings etc. - proc.wStdin.Close() - proc.mu.Unlock() - } - }() - - bo := backoff.NewBackoff("babysitProc", logf, 30*time.Second) - - for { - startTime := time.Now() - log.Printf("exec: %#v %v", executable, args) - cmd := exec.Command(executable, args...) - cmd.SysProcAttr = &syscall.SysProcAttr{ - CreationFlags: windows.DETACHED_PROCESS, - } - - // Create a pipe object to use as the subproc's stdin. - // When the writer goes away, the reader gets EOF. - // A subproc can watch its stdin and exit when it gets EOF; - // this is a very reliable way to have a subproc die when - // its parent (us) disappears. - // We never need to actually write to wStdin. - rStdin, wStdin, err := os.Pipe() - if err != nil { - log.Printf("os.Pipe 1: %v", err) - return - } - - // Create a pipe object to use as the subproc's stdout/stderr. - // We'll read from this pipe and send it to logf, line by line. - // We can't use os.exec's io.Writer for this because it - // doesn't care about lines, and thus ends up merging multiple - // log lines into one or splitting one line into multiple - // logf() calls. bufio is more appropriate. - rStdout, wStdout, err := os.Pipe() - if err != nil { - log.Printf("os.Pipe 2: %v", err) - } - go func(r *os.File) { - defer r.Close() - rb := bufio.NewReader(r) - for { - s, err := rb.ReadString('\n') - if s != "" { - logf("%s", s) - } - if err != nil { - break - } - } - }(rStdout) - - cmd.Stdin = rStdin - cmd.Stdout = wStdout - cmd.Stderr = wStdout - err = cmd.Start() - - // Now that the subproc is started, get rid of our copy of the - // pipe reader. Bad things happen on Windows if more than one - // process owns the read side of a pipe. - rStdin.Close() - wStdout.Close() - - if err != nil { - log.Printf("starting subprocess failed: %v", err) - } else { - proc.mu.Lock() - proc.p = cmd.Process - proc.wStdin = wStdin - proc.mu.Unlock() - - err = cmd.Wait() - log.Printf("subprocess exited: %v", err) - } - - // If the process finishes, clean up the write side of the - // pipe. We'll make a new one when we restart the subproc. - wStdin.Close() - - if os.Getenv("TS_DEBUG_RESTART_CRASHED") == "0" { - log.Fatalf("Process ended.") - } - - if time.Since(startTime) < 60*time.Second { - bo.BackOff(ctx, fmt.Errorf("subproc early exit: %v", err)) - } else { - // Reset the timeout, since the process ran for a while. - bo.BackOff(ctx, nil) - } - - select { - case <-done: - return - default: - } - } -} - -func uninstallWinTun(logf logger.Logf) { - dll := windows.NewLazyDLL(fullyQualifiedWintunPath(logf)) - if err := dll.Load(); err != nil { - logf("Cannot load wintun.dll for uninstall: %v", err) - return - } - - logf("Removing wintun driver...") - err := wintun.Uninstall() - logf("Uninstall: %v", err) -} - -func fullyQualifiedWintunPath(logf logger.Logf) string { - var dir string - imgName, err := winutil.ProcessImageName(windows.CurrentProcess()) - if err != nil { - logf("ProcessImageName failed: %v", err) - } else { - dir = filepath.Dir(imgName) - } - - return filepath.Join(dir, "wintun.dll") -} diff --git a/cmd/tailscaled/windows-manifest.xml b/cmd/tailscaled/windows-manifest.xml deleted file mode 100644 index 6c5f46058387f..0000000000000 --- a/cmd/tailscaled/windows-manifest.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/cmd/tailscaled/with_cli.go b/cmd/tailscaled/with_cli.go deleted file mode 100644 index a8554eb8ce9dc..0000000000000 --- a/cmd/tailscaled/with_cli.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ts_include_cli - -package main - -import ( - "fmt" - "os" - - "tailscale.com/cmd/tailscale/cli" -) - -func init() { - beCLI = func() { - args := os.Args[1:] - if err := cli.Run(args); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - } -} diff --git a/cmd/testcontrol/testcontrol.go b/cmd/testcontrol/testcontrol.go deleted file mode 100644 index b05b3128df0ef..0000000000000 --- a/cmd/testcontrol/testcontrol.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Program testcontrol runs a simple test control server. -package main - -import ( - "flag" - "log" - "net/http" - "testing" - - "tailscale.com/tstest/integration" - "tailscale.com/tstest/integration/testcontrol" - "tailscale.com/types/logger" -) - -var ( - flagNFake = flag.Int("nfake", 0, "number of fake nodes to add to network") -) - -func main() { - flag.Parse() - - var t fakeTB - derpMap := integration.RunDERPAndSTUN(t, logger.Discard, "127.0.0.1") - - control := &testcontrol.Server{ - DERPMap: derpMap, - ExplicitBaseURL: "http://127.0.0.1:9911", - } - for range *flagNFake { - control.AddFakeNode() - } - mux := http.NewServeMux() - mux.Handle("/", control) - addr := "127.0.0.1:9911" - log.Printf("listening on %s", addr) - err := http.ListenAndServe(addr, mux) - log.Fatal(err) -} - -type fakeTB struct { - *testing.T -} - -func (t fakeTB) Cleanup(_ func()) {} -func (t fakeTB) Error(args ...any) { - t.Fatal(args...) -} -func (t fakeTB) Errorf(format string, args ...any) { - t.Fatalf(format, args...) -} -func (t fakeTB) Fail() { - t.Fatal("failed") -} -func (t fakeTB) FailNow() { - t.Fatal("failed") -} -func (t fakeTB) Failed() bool { - return false -} -func (t fakeTB) Fatal(args ...any) { - log.Fatal(args...) -} -func (t fakeTB) Fatalf(format string, args ...any) { - log.Fatalf(format, args...) -} -func (t fakeTB) Helper() {} -func (t fakeTB) Log(args ...any) { - log.Print(args...) -} -func (t fakeTB) Logf(format string, args ...any) { - log.Printf(format, args...) -} -func (t fakeTB) Name() string { - return "faketest" -} -func (t fakeTB) Setenv(key string, value string) { - panic("not implemented") -} -func (t fakeTB) Skip(args ...any) { - t.Fatal("skipped") -} -func (t fakeTB) SkipNow() { - t.Fatal("skipnow") -} -func (t fakeTB) Skipf(format string, args ...any) { - t.Logf(format, args...) - t.Fatal("skipped") -} -func (t fakeTB) Skipped() bool { - return false -} -func (t fakeTB) TempDir() string { - panic("not implemented") -} diff --git a/cmd/testwrapper/args.go b/cmd/testwrapper/args.go deleted file mode 100644 index 95157bc34efee..0000000000000 --- a/cmd/testwrapper/args.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "flag" - "io" - "os" - "slices" - "strings" - "testing" -) - -// registerTestFlags registers all flags from the testing package with the -// provided flag set. It does so by calling testing.Init() and then iterating -// over all flags registered on flag.CommandLine. -func registerTestFlags(fs *flag.FlagSet) { - testing.Init() - type bv interface { - IsBoolFlag() bool - } - - flag.CommandLine.VisitAll(func(f *flag.Flag) { - if b, ok := f.Value.(bv); ok && b.IsBoolFlag() { - fs.Bool(f.Name, f.DefValue == "true", f.Usage) - if name, ok := strings.CutPrefix(f.Name, "test."); ok { - fs.Bool(name, f.DefValue == "true", f.Usage) - } - return - } - - // We don't actually care about the value of the flag, so we just - // register it as a string. The values will be passed to `go test` which - // will parse and validate them anyway. - fs.String(f.Name, f.DefValue, f.Usage) - if name, ok := strings.CutPrefix(f.Name, "test."); ok { - fs.String(name, f.DefValue, f.Usage) - } - }) -} - -// splitArgs splits args into three parts as consumed by go test. -// -// go test [build/test flags] [packages] [build/test flags & test binary flags] -// -// We return these as three slices of strings [pre] [pkgs] [post]. -// -// It is used to split the arguments passed to testwrapper into the arguments -// passed to go test and the arguments passed to the tests. -func splitArgs(args []string) (pre, pkgs, post []string, _ error) { - if len(args) == 0 { - return nil, nil, nil, nil - } - - fs := newTestFlagSet() - // Parse stops at the first non-flag argument, so this allows us - // to parse those as values and then reconstruct them as args. - if err := fs.Parse(args); err != nil { - return nil, nil, nil, err - } - fs.Visit(func(f *flag.Flag) { - if f.Value.String() != f.DefValue && f.DefValue != "false" { - pre = append(pre, "-"+f.Name, f.Value.String()) - } else { - pre = append(pre, "-"+f.Name) - } - }) - - // fs.Args() now contains [packages]+[build/test flags & test binary flags], - // to split it we need to find the first non-flag argument. - rem := fs.Args() - ix := slices.IndexFunc(rem, func(s string) bool { return strings.HasPrefix(s, "-") }) - if ix == -1 { - return pre, rem, nil, nil - } - pkgs = rem[:ix] - post = rem[ix:] - return pre, pkgs, post, nil -} - -func newTestFlagSet() *flag.FlagSet { - fs := flag.NewFlagSet("testwrapper", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - // Register all flags from the testing package. - registerTestFlags(fs) - // Also register the -exec flag, which is not part of the testing package. - // TODO(maisem): figure out what other flags we need to register explicitly. - fs.String("exec", "", "Command to run tests with") - fs.Bool("race", false, "build with race detector") - return fs -} - -// testingVerbose reports whether the test is being run with verbose logging. -var testingVerbose = func() bool { - verbose := false - - // Likely doesn't matter, but to be correct follow the go flag parsing logic - // of overriding previous values. - for _, arg := range os.Args[1:] { - switch arg { - case "-test.v", "--test.v", - "-test.v=true", "--test.v=true", - "-v", "--v", - "-v=true", "--v=true": - verbose = true - case "-test.v=false", "--test.v=false", - "-v=false", "--v=false": - verbose = false - } - } - return verbose -}() diff --git a/cmd/testwrapper/args_test.go b/cmd/testwrapper/args_test.go deleted file mode 100644 index 10063d7bcf6e1..0000000000000 --- a/cmd/testwrapper/args_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "slices" - "testing" -) - -func TestSplitArgs(t *testing.T) { - tests := []struct { - name string - in []string - pre, pkgs, post []string - }{ - { - name: "empty", - }, - { - name: "all", - in: []string{"-v", "pkg1", "pkg2", "-run", "TestFoo", "-timeout=20s"}, - pre: []string{"-v"}, - pkgs: []string{"pkg1", "pkg2"}, - post: []string{"-run", "TestFoo", "-timeout=20s"}, - }, - { - name: "only_pkgs", - in: []string{"./..."}, - pkgs: []string{"./..."}, - }, - { - name: "pkgs_and_post", - in: []string{"pkg1", "-run", "TestFoo"}, - pkgs: []string{"pkg1"}, - post: []string{"-run", "TestFoo"}, - }, - { - name: "pkgs_and_post", - in: []string{"-v", "pkg2"}, - pre: []string{"-v"}, - pkgs: []string{"pkg2"}, - }, - { - name: "only_args", - in: []string{"-v", "-run=TestFoo"}, - pre: []string{"-run", "TestFoo", "-v"}, // sorted - }, - { - name: "space_in_pre_arg", - in: []string{"-run", "TestFoo", "./cmd/testwrapper"}, - pre: []string{"-run", "TestFoo"}, - pkgs: []string{"./cmd/testwrapper"}, - }, - { - name: "space_in_arg", - in: []string{"-exec", "sudo -E", "./cmd/testwrapper"}, - pre: []string{"-exec", "sudo -E"}, - pkgs: []string{"./cmd/testwrapper"}, - }, - { - name: "test-arg", - in: []string{"-exec", "sudo -E", "./cmd/testwrapper", "--", "--some-flag"}, - pre: []string{"-exec", "sudo -E"}, - pkgs: []string{"./cmd/testwrapper"}, - post: []string{"--", "--some-flag"}, - }, - { - name: "dupe-args", - in: []string{"-v", "-v", "-race", "-race", "./cmd/testwrapper", "--", "--some-flag"}, - pre: []string{"-race", "-v"}, - pkgs: []string{"./cmd/testwrapper"}, - post: []string{"--", "--some-flag"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pre, pkgs, post, err := splitArgs(tt.in) - if err != nil { - t.Fatal(err) - } - if !slices.Equal(pre, tt.pre) { - t.Errorf("pre = %q; want %q", pre, tt.pre) - } - if !slices.Equal(pkgs, tt.pkgs) { - t.Errorf("pattern = %q; want %q", pkgs, tt.pkgs) - } - if !slices.Equal(post, tt.post) { - t.Errorf("post = %q; want %q", post, tt.post) - } - if t.Failed() { - t.Logf("SplitArgs(%q) = %q %q %q", tt.in, pre, pkgs, post) - } - }) - } -} diff --git a/cmd/testwrapper/flakytest/flakytest.go b/cmd/testwrapper/flakytest/flakytest.go deleted file mode 100644 index 494ed080b26a1..0000000000000 --- a/cmd/testwrapper/flakytest/flakytest.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package flakytest contains test helpers for marking a test as flaky. For -// tests run using cmd/testwrapper, a failed flaky test will cause tests to be -// re-run a few time until they succeed or exceed our iteration limit. -package flakytest - -import ( - "fmt" - "os" - "regexp" - "testing" -) - -// FlakyTestLogMessage is a sentinel value that is printed to stderr when a -// flaky test is marked. This is used by cmd/testwrapper to detect flaky tests -// and retry them. -const FlakyTestLogMessage = "flakytest: this is a known flaky test" - -// FlakeAttemptEnv is an environment variable that is set by cmd/testwrapper -// when a flaky test is being (re)tried. It contains the attempt number, -// starting at 1. -const FlakeAttemptEnv = "TS_TESTWRAPPER_ATTEMPT" - -var issueRegexp = regexp.MustCompile(`\Ahttps://github\.com/tailscale/[a-zA-Z0-9_.-]+/issues/\d+\z`) - -// Mark sets the current test as a flaky test, such that if it fails, it will -// be retried a few times on failure. issue must be a GitHub issue that tracks -// the status of the flaky test being marked, of the format: -// -// https://github.com/tailscale/myRepo-H3re/issues/12345 -func Mark(t testing.TB, issue string) { - if !issueRegexp.MatchString(issue) { - t.Fatalf("bad issue format: %q", issue) - } - if _, ok := os.LookupEnv(FlakeAttemptEnv); ok { - // We're being run under cmd/testwrapper so send our sentinel message - // to stderr. (We avoid doing this when the env is absent to avoid - // spamming people running tests without the wrapper) - fmt.Fprintf(os.Stderr, "%s: %s\n", FlakyTestLogMessage, issue) - } - t.Logf("flakytest: issue tracking this flaky test: %s", issue) -} diff --git a/cmd/testwrapper/flakytest/flakytest_test.go b/cmd/testwrapper/flakytest/flakytest_test.go deleted file mode 100644 index 85e77a939c75d..0000000000000 --- a/cmd/testwrapper/flakytest/flakytest_test.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package flakytest - -import ( - "os" - "testing" -) - -func TestIssueFormat(t *testing.T) { - testCases := []struct { - issue string - want bool - }{ - {"https://github.com/tailscale/cOrp/issues/1234", true}, - {"https://github.com/otherproject/corp/issues/1234", false}, - {"https://github.com/tailscale/corp/issues/", false}, - } - for _, testCase := range testCases { - if issueRegexp.MatchString(testCase.issue) != testCase.want { - ss := "" - if !testCase.want { - ss = " not" - } - t.Errorf("expected issueRegexp to%s match %q", ss, testCase.issue) - } - } -} - -// TestFlakeRun is a test that fails when run in the testwrapper -// for the first time, but succeeds on the second run. -// It's used to test whether the testwrapper retries flaky tests. -func TestFlakeRun(t *testing.T) { - Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue - e := os.Getenv(FlakeAttemptEnv) - if e == "" { - t.Skip("not running in testwrapper") - } - if e == "1" { - t.Fatal("First run in testwrapper, failing so that test is retried. This is expected.") - } -} diff --git a/cmd/testwrapper/testwrapper.go b/cmd/testwrapper/testwrapper.go deleted file mode 100644 index f6ff8f00a93ab..0000000000000 --- a/cmd/testwrapper/testwrapper.go +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// testwrapper is a wrapper for retrying flaky tests. It is an alternative to -// `go test` and re-runs failed marked flaky tests (using the flakytest pkg). It -// takes different arguments than go test and requires the first positional -// argument to be the pattern to test. -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "os" - "os/exec" - "slices" - "sort" - "strings" - "time" - "unicode" - - "github.com/dave/courtney/scanner" - "github.com/dave/courtney/shared" - "github.com/dave/courtney/tester" - "github.com/dave/patsy" - "github.com/dave/patsy/vos" - xmaps "golang.org/x/exp/maps" - "tailscale.com/cmd/testwrapper/flakytest" -) - -const ( - maxAttempts = 3 -) - -type testAttempt struct { - pkg string // "tailscale.com/types/key" - testName string // "TestFoo" - outcome string // "pass", "fail", "skip" - logs bytes.Buffer - start, end time.Time - isMarkedFlaky bool // set if the test is marked as flaky - issueURL string // set if the test is marked as flaky - - pkgFinished bool -} - -// packageTests describes what to run. -// It's also JSON-marshalled to output for analysys tools to parse -// so the fields are all exported. -// TODO(bradfitz): move this type to its own types package? -type packageTests struct { - // Pattern is the package Pattern to run. - // Must be a single Pattern, not a list of patterns. - Pattern string // "./...", "./types/key" - // Tests is a list of Tests to run. If empty, all Tests in the package are - // run. - Tests []string // ["TestFoo", "TestBar"] - // IssueURLs maps from a test name to a URL tracking its flake. - IssueURLs map[string]string // "TestFoo" => "https://github.com/foo/bar/issue/123" -} - -type goTestOutput struct { - Time time.Time - Action string - Package string - Test string - Output string -} - -var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != "" - -// runTests runs the tests in pt and sends the results on ch. It sends a -// testAttempt for each test and a final testAttempt per pkg with pkgFinished -// set to true. Package build errors will not emit a testAttempt (as no valid -// JSON is produced) but the [os/exec.ExitError] will be returned. -// It calls close(ch) when it's done. -func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, testArgs []string, ch chan<- *testAttempt) error { - defer close(ch) - args := []string{"test"} - args = append(args, goTestArgs...) - args = append(args, pt.Pattern) - if len(pt.Tests) > 0 { - runArg := strings.Join(pt.Tests, "|") - args = append(args, "--run", runArg) - } - args = append(args, testArgs...) - args = append(args, "-json") - if debug { - fmt.Println("running", strings.Join(args, " ")) - } - cmd := exec.CommandContext(ctx, "go", args...) - if len(pt.Tests) > 0 { - cmd.Env = append(os.Environ(), "TS_TEST_SHARD=") // clear test shard; run all tests we say to run - } - r, err := cmd.StdoutPipe() - if err != nil { - log.Printf("error creating stdout pipe: %v", err) - } - defer r.Close() - cmd.Stderr = os.Stderr - - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", flakytest.FlakeAttemptEnv, attempt)) - - if err := cmd.Start(); err != nil { - log.Printf("error starting test: %v", err) - os.Exit(1) - } - - s := bufio.NewScanner(r) - resultMap := make(map[string]map[string]*testAttempt) // pkg -> test -> testAttempt - for s.Scan() { - var goOutput goTestOutput - if err := json.Unmarshal(s.Bytes(), &goOutput); err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) { - break - } - - // `go test -json` outputs invalid JSON when a build fails. - // In that case, discard the the output and start reading again. - // The build error will be printed to stderr. - // See: https://github.com/golang/go/issues/35169 - if _, ok := err.(*json.SyntaxError); ok { - fmt.Println(s.Text()) - continue - } - panic(err) - } - pkg := goOutput.Package - pkgTests := resultMap[pkg] - if pkgTests == nil { - pkgTests = make(map[string]*testAttempt) - resultMap[pkg] = pkgTests - } - if goOutput.Test == "" { - switch goOutput.Action { - case "start": - pkgTests[""] = &testAttempt{start: goOutput.Time} - case "fail", "pass", "skip": - for _, test := range pkgTests { - if test.testName != "" && test.outcome == "" { - test.outcome = "fail" - ch <- test - } - } - ch <- &testAttempt{ - pkg: goOutput.Package, - outcome: goOutput.Action, - start: pkgTests[""].start, - end: goOutput.Time, - pkgFinished: true, - } - } - continue - } - testName := goOutput.Test - if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest { - testName = test - if goOutput.Action == "output" { - resultMap[pkg][testName].logs.WriteString(goOutput.Output) - } - continue - } - switch goOutput.Action { - case "start": - // ignore - case "run": - pkgTests[testName] = &testAttempt{ - pkg: pkg, - testName: testName, - start: goOutput.Time, - } - case "skip", "pass", "fail": - pkgTests[testName].end = goOutput.Time - pkgTests[testName].outcome = goOutput.Action - ch <- pkgTests[testName] - case "output": - if suffix, ok := strings.CutPrefix(strings.TrimSpace(goOutput.Output), flakytest.FlakyTestLogMessage); ok { - pkgTests[testName].isMarkedFlaky = true - pkgTests[testName].issueURL = strings.TrimPrefix(suffix, ": ") - } else { - pkgTests[testName].logs.WriteString(goOutput.Output) - } - } - } - if err := cmd.Wait(); err != nil { - return err - } - if err := s.Err(); err != nil { - return fmt.Errorf("reading go test stdout: %w", err) - } - return nil -} - -func main() { - goTestArgs, packages, testArgs, err := splitArgs(os.Args[1:]) - if err != nil { - log.Fatal(err) - return - } - if len(packages) == 0 { - fmt.Println("testwrapper: no packages specified") - return - } - - ctx := context.Background() - type nextRun struct { - tests []*packageTests - attempt int // starting at 1 - } - firstRun := &nextRun{ - attempt: 1, - } - for _, pkg := range packages { - firstRun.tests = append(firstRun.tests, &packageTests{Pattern: pkg}) - } - toRun := []*nextRun{firstRun} - printPkgOutcome := func(pkg, outcome string, attempt int, runtime time.Duration) { - if outcome == "skip" { - fmt.Printf("?\t%s [skipped/no tests] \n", pkg) - return - } - if outcome == "pass" { - outcome = "ok" - } - if outcome == "fail" { - outcome = "FAIL" - } - if attempt > 1 { - fmt.Printf("%s\t%s\t%.3fs\t[attempt=%d]\n", outcome, pkg, runtime.Seconds(), attempt) - return - } - fmt.Printf("%s\t%s\t%.3fs\n", outcome, pkg, runtime.Seconds()) - } - - // Check for -coverprofile argument and filter it out - combinedCoverageFilename := "" - filteredGoTestArgs := make([]string, 0, len(goTestArgs)) - preceededByCoverProfile := false - for _, arg := range goTestArgs { - if arg == "-coverprofile" { - preceededByCoverProfile = true - } else if preceededByCoverProfile { - combinedCoverageFilename = strings.TrimSpace(arg) - preceededByCoverProfile = false - } else { - filteredGoTestArgs = append(filteredGoTestArgs, arg) - } - } - goTestArgs = filteredGoTestArgs - - runningWithCoverage := combinedCoverageFilename != "" - if runningWithCoverage { - fmt.Printf("Will log coverage to %v\n", combinedCoverageFilename) - } - - // Keep track of all test coverage files. With each retry, we'll end up - // with additional coverage files that will be combined when we finish. - coverageFiles := make([]string, 0) - for len(toRun) > 0 { - var thisRun *nextRun - thisRun, toRun = toRun[0], toRun[1:] - - if thisRun.attempt > maxAttempts { - fmt.Println("max attempts reached") - os.Exit(1) - } - if thisRun.attempt > 1 { - j, _ := json.Marshal(thisRun.tests) - fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\nflakytest failures JSON: %s\n\n", thisRun.attempt, j) - } - - goTestArgsWithCoverage := testArgs - if runningWithCoverage { - coverageFile := fmt.Sprintf("/tmp/coverage_%d.out", thisRun.attempt) - coverageFiles = append(coverageFiles, coverageFile) - goTestArgsWithCoverage = make([]string, len(goTestArgs), len(goTestArgs)+2) - copy(goTestArgsWithCoverage, goTestArgs) - goTestArgsWithCoverage = append( - goTestArgsWithCoverage, - fmt.Sprintf("-coverprofile=%v", coverageFile), - "-covermode=set", - "-coverpkg=./...", - ) - } - - toRetry := make(map[string][]*testAttempt) // pkg -> tests to retry - for _, pt := range thisRun.tests { - ch := make(chan *testAttempt) - runErr := make(chan error, 1) - go func() { - defer close(runErr) - runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgsWithCoverage, testArgs, ch) - }() - - var failed bool - for tr := range ch { - // Go assigns the package name "command-line-arguments" when you - // `go test FILE` rather than `go test PKG`. It's more - // convenient for us to to specify files in tests, so fix tr.pkg - // so that subsequent testwrapper attempts run correctly. - if tr.pkg == "command-line-arguments" { - tr.pkg = packages[0] - } - if tr.pkgFinished { - if tr.outcome == "fail" && len(toRetry[tr.pkg]) == 0 { - // If a package fails and we don't have any tests to - // retry, then we should fail. This typically happens - // when a package times out. - failed = true - } - printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt, tr.end.Sub(tr.start)) - continue - } - if testingVerbose || tr.outcome == "fail" { - io.Copy(os.Stdout, &tr.logs) - } - if tr.outcome != "fail" { - continue - } - if tr.isMarkedFlaky { - toRetry[tr.pkg] = append(toRetry[tr.pkg], tr) - } else { - failed = true - } - } - if failed { - fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.") - os.Exit(1) - } - - // If there's nothing to retry and no non-retryable tests have - // failed then we've probably hit a build error. - if err := <-runErr; len(toRetry) == 0 && err != nil { - var exit *exec.ExitError - if errors.As(err, &exit) { - if code := exit.ExitCode(); code > -1 { - os.Exit(exit.ExitCode()) - } - } - log.Printf("testwrapper: %s", err) - os.Exit(1) - } - } - if len(toRetry) == 0 { - continue - } - pkgs := xmaps.Keys(toRetry) - sort.Strings(pkgs) - nextRun := &nextRun{ - attempt: thisRun.attempt + 1, - } - for _, pkg := range pkgs { - tests := toRetry[pkg] - slices.SortFunc(tests, func(a, b *testAttempt) int { return strings.Compare(a.testName, b.testName) }) - issueURLs := map[string]string{} // test name => URL - var testNames []string - for _, ta := range tests { - issueURLs[ta.testName] = ta.issueURL - testNames = append(testNames, ta.testName) - } - nextRun.tests = append(nextRun.tests, &packageTests{ - Pattern: pkg, - Tests: testNames, - IssueURLs: issueURLs, - }) - } - toRun = append(toRun, nextRun) - } - - if runningWithCoverage { - intermediateCoverageFilename := "/tmp/coverage.out_intermediate" - if err := combineCoverageFiles(intermediateCoverageFilename, coverageFiles); err != nil { - fmt.Printf("error combining coverage files: %v\n", err) - os.Exit(2) - } - - if err := processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename, testArgs); err != nil { - fmt.Printf("error processing coverage with courtney: %v\n", err) - os.Exit(3) - } - - fmt.Printf("Wrote combined coverage to %v\n", combinedCoverageFilename) - } -} - -func combineCoverageFiles(intermediateCoverageFilename string, coverageFiles []string) error { - combinedCoverageFile, err := os.OpenFile(intermediateCoverageFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return fmt.Errorf("create /tmp/coverage.out: %w", err) - } - defer combinedCoverageFile.Close() - w := bufio.NewWriter(combinedCoverageFile) - defer w.Flush() - - for fileNumber, coverageFile := range coverageFiles { - f, err := os.Open(coverageFile) - if err != nil { - return fmt.Errorf("open %v: %w", coverageFile, err) - } - defer f.Close() - in := bufio.NewReader(f) - line := 0 - for { - r, _, err := in.ReadRune() - if err != nil { - if err != io.EOF { - return fmt.Errorf("read %v: %w", coverageFile, err) - } - break - } - - // On all but the first coverage file, skip the coverage file header - if fileNumber > 0 && line == 0 { - continue - } - if r == '\n' { - line++ - } - - // filter for only printable characters because coverage file sometimes includes junk on 2nd line - if unicode.IsPrint(r) || r == '\n' { - if _, err := w.WriteRune(r); err != nil { - return fmt.Errorf("write %v: %w", combinedCoverageFile.Name(), err) - } - } - } - } - - return nil -} - -// processCoverageWithCourtney post-processes code coverage to exclude less -// meaningful sections like 'if err != nil { return err}', as well as -// anything marked with a '// notest' comment. -// -// instead of running the courtney as a separate program, this embeds -// courtney for easier integration. -func processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename string, testArgs []string) error { - env := vos.Os() - - setup := &shared.Setup{ - Env: vos.Os(), - Paths: patsy.NewCache(env), - TestArgs: testArgs, - Load: intermediateCoverageFilename, - Output: combinedCoverageFilename, - } - if err := setup.Parse(testArgs); err != nil { - return fmt.Errorf("parse args: %w", err) - } - - s := scanner.New(setup) - if err := s.LoadProgram(); err != nil { - return fmt.Errorf("load program: %w", err) - } - if err := s.ScanPackages(); err != nil { - return fmt.Errorf("scan packages: %w", err) - } - - t := tester.New(setup) - if err := t.Load(); err != nil { - return fmt.Errorf("load: %w", err) - } - if err := t.ProcessExcludes(s.Excludes); err != nil { - return fmt.Errorf("process excludes: %w", err) - } - if err := t.Save(); err != nil { - return fmt.Errorf("save: %w", err) - } - - return nil -} diff --git a/cmd/testwrapper/testwrapper_test.go b/cmd/testwrapper/testwrapper_test.go deleted file mode 100644 index fb2ed2c52cb2e..0000000000000 --- a/cmd/testwrapper/testwrapper_test.go +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main_test - -import ( - "bytes" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "sync" - "testing" -) - -var ( - buildPath string - buildErr error - buildOnce sync.Once -) - -func cmdTestwrapper(t *testing.T, args ...string) *exec.Cmd { - buildOnce.Do(func() { - buildPath, buildErr = buildTestWrapper() - }) - if buildErr != nil { - t.Fatalf("building testwrapper: %s", buildErr) - } - return exec.Command(buildPath, args...) -} - -func buildTestWrapper() (string, error) { - dir, err := os.MkdirTemp("", "testwrapper") - if err != nil { - return "", fmt.Errorf("making temp dir: %w", err) - } - _, err = exec.Command("go", "build", "-o", dir, ".").Output() - if err != nil { - return "", fmt.Errorf("go build: %w", err) - } - return filepath.Join(dir, "testwrapper"), nil -} - -func TestRetry(t *testing.T) { - t.Parallel() - - testfile := filepath.Join(t.TempDir(), "retry_test.go") - code := []byte(`package retry_test - -import ( - "os" - "testing" - "tailscale.com/cmd/testwrapper/flakytest" -) - -func TestOK(t *testing.T) {} - -func TestFlakeRun(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue - e := os.Getenv(flakytest.FlakeAttemptEnv) - if e == "" { - t.Skip("not running in testwrapper") - } - if e == "1" { - t.Fatal("First run in testwrapper, failing so that test is retried. This is expected.") - } -} -`) - if err := os.WriteFile(testfile, code, 0o644); err != nil { - t.Fatalf("writing package: %s", err) - } - - out, err := cmdTestwrapper(t, "-v", testfile).CombinedOutput() - if err != nil { - t.Fatalf("go run . %s: %s with output:\n%s", testfile, err, out) - } - - // Replace the unpredictable timestamp with "0.00s". - out = regexp.MustCompile(`\t\d+\.\d\d\ds\t`).ReplaceAll(out, []byte("\t0.00s\t")) - - want := []byte("ok\t" + testfile + "\t0.00s\t[attempt=2]") - if !bytes.Contains(out, want) { - t.Fatalf("wanted output containing %q but got:\n%s", want, out) - } - - if okRuns := bytes.Count(out, []byte("=== RUN TestOK")); okRuns != 1 { - t.Fatalf("expected TestOK to be run once but was run %d times in output:\n%s", okRuns, out) - } - if flakeRuns := bytes.Count(out, []byte("=== RUN TestFlakeRun")); flakeRuns != 2 { - t.Fatalf("expected TestFlakeRun to be run twice but was run %d times in output:\n%s", flakeRuns, out) - } - - if testing.Verbose() { - t.Logf("success - output:\n%s", out) - } -} - -func TestNoRetry(t *testing.T) { - t.Parallel() - - testfile := filepath.Join(t.TempDir(), "noretry_test.go") - code := []byte(`package noretry_test - -import ( - "testing" - "tailscale.com/cmd/testwrapper/flakytest" -) - -func TestFlakeRun(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/0") // random issue - t.Error("shouldn't be retried") -} - -func TestAlwaysError(t *testing.T) { - t.Error("error") -} -`) - if err := os.WriteFile(testfile, code, 0o644); err != nil { - t.Fatalf("writing package: %s", err) - } - - out, err := cmdTestwrapper(t, "-v", testfile).Output() - if err == nil { - t.Fatalf("go run . %s: expected error but it succeeded with output:\n%s", testfile, out) - } - if code, ok := errExitCode(err); ok && code != 1 { - t.Fatalf("expected exit code 1 but got %d", code) - } - - want := []byte("Not retrying flaky tests because non-flaky tests failed.") - if !bytes.Contains(out, want) { - t.Fatalf("wanted output containing %q but got:\n%s", want, out) - } - - if flakeRuns := bytes.Count(out, []byte("=== RUN TestFlakeRun")); flakeRuns != 1 { - t.Fatalf("expected TestFlakeRun to be run once but was run %d times in output:\n%s", flakeRuns, out) - } - - if testing.Verbose() { - t.Logf("success - output:\n%s", out) - } -} - -func TestBuildError(t *testing.T) { - t.Parallel() - - // Construct our broken package. - testfile := filepath.Join(t.TempDir(), "builderror_test.go") - code := []byte("package builderror_test\n\nderp") - err := os.WriteFile(testfile, code, 0o644) - if err != nil { - t.Fatalf("writing package: %s", err) - } - - buildErr := []byte("builderror_test.go:3:1: expected declaration, found derp\nFAIL command-line-arguments [setup failed]") - - // Confirm `go test` exits with code 1. - goOut, err := exec.Command("go", "test", testfile).CombinedOutput() - if code, ok := errExitCode(err); !ok || code != 1 { - t.Fatalf("go test %s: expected error with exit code 0 but got: %v", testfile, err) - } - if !bytes.Contains(goOut, buildErr) { - t.Fatalf("go test %s: expected build error containing %q but got:\n%s", testfile, buildErr, goOut) - } - - // Confirm `testwrapper` exits with code 1. - twOut, err := cmdTestwrapper(t, testfile).CombinedOutput() - if code, ok := errExitCode(err); !ok || code != 1 { - t.Fatalf("testwrapper %s: expected error with exit code 0 but got: %v", testfile, err) - } - if !bytes.Contains(twOut, buildErr) { - t.Fatalf("testwrapper %s: expected build error containing %q but got:\n%s", testfile, buildErr, twOut) - } - - if testing.Verbose() { - t.Logf("success - output:\n%s", twOut) - } -} - -func TestTimeout(t *testing.T) { - t.Parallel() - - // Construct our broken package. - testfile := filepath.Join(t.TempDir(), "timeout_test.go") - code := []byte(`package noretry_test - -import ( - "testing" - "time" -) - -func TestTimeout(t *testing.T) { - time.Sleep(500 * time.Millisecond) -} -`) - err := os.WriteFile(testfile, code, 0o644) - if err != nil { - t.Fatalf("writing package: %s", err) - } - - out, err := cmdTestwrapper(t, testfile, "-timeout=20ms").CombinedOutput() - if code, ok := errExitCode(err); !ok || code != 1 { - t.Fatalf("testwrapper %s: expected error with exit code 0 but got: %v; output was:\n%s", testfile, err, out) - } - if want := "panic: test timed out after 20ms"; !bytes.Contains(out, []byte(want)) { - t.Fatalf("testwrapper %s: expected build error containing %q but got:\n%s", testfile, buildErr, out) - } - - if testing.Verbose() { - t.Logf("success - output:\n%s", out) - } -} - -func errExitCode(err error) (int, bool) { - var exit *exec.ExitError - if errors.As(err, &exit) { - return exit.ExitCode(), true - } - return 0, false -} diff --git a/cmd/tl-longchain/tl-longchain.go b/cmd/tl-longchain/tl-longchain.go deleted file mode 100644 index c92714505b8be..0000000000000 --- a/cmd/tl-longchain/tl-longchain.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Program tl-longchain prints commands to re-sign Tailscale nodes that have -// long rotation signature chains. -// -// There is an implicit limit on the number of rotation signatures that can -// be chained before the signature becomes too long. This program helps -// tailnet admins to identify nodes that have signatures with long chains and -// prints commands to re-sign those node keys with a fresh direct signature. -// Commands are printed to stdout, while log messages are printed to stderr. -// -// Note that the Tailscale client this command is executed on must have -// ACL visibility to all other nodes to be able to see their signatures. -// https://tailscale.com/kb/1087/device-visibility -package main - -import ( - "context" - "flag" - "fmt" - "log" - "time" - - "tailscale.com/client/tailscale" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tka" - "tailscale.com/types/key" -) - -var ( - flagSocket = flag.String("socket", "", "custom path to tailscaled socket") - maxRotations = flag.Int("rotations", 10, "number of rotation signatures before re-signing (max 16)") - showFiltered = flag.Bool("show-filtered", false, "include nodes with invalid signatures") -) - -func main() { - flag.Parse() - - lc := tailscale.LocalClient{Socket: *flagSocket} - if lc.Socket != "" { - lc.UseSocketOnly = true - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - st, err := lc.NetworkLockStatus(ctx) - if err != nil { - log.Fatalf("could not get Tailnet Lock status: %v", err) - } - if !st.Enabled { - log.Print("Tailnet Lock is not enabled") - return - } - print("Self", *st.NodeKey, *st.NodeKeySignature) - if len(st.VisiblePeers) > 0 { - log.Print("Visible peers with valid signatures:") - for _, peer := range st.VisiblePeers { - print(peerInfo(peer), peer.NodeKey, peer.NodeKeySignature) - } - } - if *showFiltered && len(st.FilteredPeers) > 0 { - log.Print("Visible peers with invalid signatures:") - for _, peer := range st.FilteredPeers { - print(peerInfo(peer), peer.NodeKey, peer.NodeKeySignature) - } - } -} - -// peerInfo returns a string with information about a peer. -func peerInfo(peer *ipnstate.TKAPeer) string { - return fmt.Sprintf("Peer %s (%s) nodeid=%s, current signature kind=%v", peer.Name, peer.TailscaleIPs[0], peer.StableID, peer.NodeKeySignature.SigKind) -} - -// print prints a message about a node key signature and a re-signing command if needed. -func print(info string, nodeKey key.NodePublic, sig tka.NodeKeySignature) { - if l := chainLength(sig); l > *maxRotations { - log.Printf("%s: chain length %d, printing command to re-sign", info, l) - wrapping, _ := sig.UnverifiedWrappingPublic() - fmt.Printf("tailscale lock sign %s %s\n", nodeKey, key.NLPublicFromEd25519Unsafe(wrapping).CLIString()) - } else { - log.Printf("%s: does not need re-signing", info) - } -} - -// chainLength returns the length of the rotation signature chain. -func chainLength(sig tka.NodeKeySignature) int { - if sig.SigKind != tka.SigRotation { - return 1 - } - return 1 + chainLength(*sig.Nested) -} diff --git a/cmd/tsconnect/.gitignore b/cmd/tsconnect/.gitignore deleted file mode 100644 index 13615d1213d63..0000000000000 --- a/cmd/tsconnect/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -/dist -/pkg diff --git a/cmd/tsconnect/README.md b/cmd/tsconnect/README.md deleted file mode 100644 index 536cd7bbf562c..0000000000000 --- a/cmd/tsconnect/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# tsconnect - -The tsconnect command builds and serves the static site that is generated for -the Tailscale Connect JS/WASM client. - -## Development - -To start the development server: - -``` -./tool/go run ./cmd/tsconnect dev -``` - -The site is served at http://localhost:9090/. JavaScript, CSS and Go `wasm` package changes can be picked up with a browser reload. Server-side Go changes require the server to be stopped and restarted. In development mode the state the Tailscale client state is stored in `sessionStorage` and will thus survive page reloads (but not the tab being closed). - -## Deployment - -To build the static assets necessary for serving, run: - -``` -./tool/go run ./cmd/tsconnect build -``` - -To serve them, run: - -``` -./tool/go run ./cmd/tsconnect serve -``` - -By default the build output is placed in the `dist/` directory and embedded in the binary, but this can be controlled by the `-distdir` flag. The `-addr` flag controls the interface and port that the serve listens on. - -# Library / NPM Package - -The client is also available as [an NPM package](https://www.npmjs.com/package/@tailscale/connect). To build it, run: - -``` -./tool/go run ./cmd/tsconnect build-pkg -``` - -That places the output in the `pkg/` directory, which may then be uploaded to a package registry (or installed from the file path directly). - -To do two-sided development (on both the NPM package and code that uses it), run: - -``` -./tool/go run ./cmd/tsconnect dev-pkg - -``` - -This serves the module at http://localhost:9090/pkg/pkg.js and the generated wasm file at http://localhost:9090/pkg/main.wasm. The two files can be used as drop-in replacements for normal imports of the NPM module. diff --git a/cmd/tsconnect/README.pkg.md b/cmd/tsconnect/README.pkg.md deleted file mode 100644 index df8d66789894d..0000000000000 --- a/cmd/tsconnect/README.pkg.md +++ /dev/null @@ -1,3 +0,0 @@ -# @tailscale/connect - -NPM package that contains a WebAssembly-based Tailscale client, see [the `cmd/tsconnect` directory in the tailscale repo](https://github.com/tailscale/tailscale/tree/main/cmd/tsconnect#library--npm-package) for more details. diff --git a/cmd/tsconnect/build-pkg.go b/cmd/tsconnect/build-pkg.go deleted file mode 100644 index 047504858ae0c..0000000000000 --- a/cmd/tsconnect/build-pkg.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path" - - "github.com/tailscale/hujson" - "tailscale.com/util/precompress" - "tailscale.com/version" -) - -func runBuildPkg() { - buildOptions, err := commonPkgSetup(prodMode) - if err != nil { - log.Fatalf("Cannot setup: %v", err) - } - - log.Printf("Linting...\n") - if err := runYarn("lint"); err != nil { - log.Fatalf("Linting failed: %v", err) - } - - if err := cleanDir(*pkgDir); err != nil { - log.Fatalf("Cannot clean %s: %v", *pkgDir, err) - } - - buildOptions.Write = true - buildOptions.MinifyWhitespace = true - buildOptions.MinifyIdentifiers = true - buildOptions.MinifySyntax = true - - runEsbuild(*buildOptions) - - if err := precompressWasm(); err != nil { - log.Fatalf("Could not pre-recompress wasm: %v", err) - } - - log.Printf("Generating types...\n") - if err := runYarn("pkg-types"); err != nil { - log.Fatalf("Type generation failed: %v", err) - } - - if err := updateVersion(); err != nil { - log.Fatalf("Cannot update version: %v", err) - } - - if err := copyReadme(); err != nil { - log.Fatalf("Cannot copy readme: %v", err) - } - - log.Printf("Built package version %s", version.Long()) -} - -func precompressWasm() error { - log.Printf("Pre-compressing main.wasm...\n") - return precompress.Precompress(path.Join(*pkgDir, "main.wasm"), precompress.Options{ - FastCompression: *fastCompression, - }) -} - -func updateVersion() error { - packageJSONBytes, err := os.ReadFile("package.json.tmpl") - if err != nil { - return fmt.Errorf("Could not read package.json: %w", err) - } - - var packageJSON map[string]any - packageJSONBytes, err = hujson.Standardize(packageJSONBytes) - if err != nil { - return fmt.Errorf("Could not standardize template package.json: %w", err) - } - if err := json.Unmarshal(packageJSONBytes, &packageJSON); err != nil { - return fmt.Errorf("Could not unmarshal package.json: %w", err) - } - packageJSON["version"] = version.Long() - - packageJSONBytes, err = json.MarshalIndent(packageJSON, "", " ") - if err != nil { - return fmt.Errorf("Could not marshal package.json: %w", err) - } - - return os.WriteFile(path.Join(*pkgDir, "package.json"), packageJSONBytes, 0644) -} - -func copyReadme() error { - readmeBytes, err := os.ReadFile("README.pkg.md") - if err != nil { - return fmt.Errorf("Could not read README.pkg.md: %w", err) - } - return os.WriteFile(path.Join(*pkgDir, "README.md"), readmeBytes, 0644) -} diff --git a/cmd/tsconnect/build.go b/cmd/tsconnect/build.go deleted file mode 100644 index 364ebf5366dfe..0000000000000 --- a/cmd/tsconnect/build.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path" - "path/filepath" - - "tailscale.com/util/precompress" -) - -func runBuild() { - buildOptions, err := commonSetup(prodMode) - if err != nil { - log.Fatalf("Cannot setup: %v", err) - } - - log.Printf("Linting...\n") - if err := runYarn("lint"); err != nil { - log.Fatalf("Linting failed: %v", err) - } - - if err := cleanDir(*distDir, "placeholder"); err != nil { - log.Fatalf("Cannot clean %s: %v", *distDir, err) - } - - buildOptions.Write = true - buildOptions.MinifyWhitespace = true - buildOptions.MinifyIdentifiers = true - buildOptions.MinifySyntax = true - - buildOptions.EntryNames = "[dir]/[name]-[hash]" - buildOptions.AssetNames = "[name]-[hash]" - buildOptions.Metafile = true - - result := runEsbuild(*buildOptions) - - // Preserve build metadata so we can extract hashed file names for serving. - metadataBytes, err := fixEsbuildMetadataPaths(result.Metafile) - if err != nil { - log.Fatalf("Cannot fix esbuild metadata paths: %v", err) - } - if err := os.WriteFile(path.Join(*distDir, "/esbuild-metadata.json"), metadataBytes, 0666); err != nil { - log.Fatalf("Cannot write metadata: %v", err) - } - - if er := precompressDist(*fastCompression); err != nil { - log.Fatalf("Cannot precompress resources: %v", er) - } -} - -// fixEsbuildMetadataPaths re-keys the esbuild metadata file to use paths -// relative to the dist directory (it normally uses paths relative to the cwd, -// which are awkward if we're running with a different cwd at serving time). -func fixEsbuildMetadataPaths(metadataStr string) ([]byte, error) { - var metadata EsbuildMetadata - if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { - return nil, fmt.Errorf("Cannot parse metadata: %w", err) - } - distAbsPath, err := filepath.Abs(*distDir) - if err != nil { - return nil, fmt.Errorf("Cannot get absolute path from %s: %w", *distDir, err) - } - for outputPath, output := range metadata.Outputs { - outputAbsPath, err := filepath.Abs(outputPath) - if err != nil { - return nil, fmt.Errorf("Cannot get absolute path from %s: %w", outputPath, err) - } - outputRelPath, err := filepath.Rel(distAbsPath, outputAbsPath) - if err != nil { - return nil, fmt.Errorf("Cannot get relative path from %s: %w", outputRelPath, err) - } - delete(metadata.Outputs, outputPath) - metadata.Outputs[outputRelPath] = output - } - return json.Marshal(metadata) -} - -func precompressDist(fastCompression bool) error { - log.Printf("Pre-compressing files in %s/...\n", *distDir) - return precompress.PrecompressDir(*distDir, precompress.Options{ - FastCompression: fastCompression, - ProgressFn: func(path string) { - log.Printf("Pre-compressing %v\n", path) - }, - }) -} diff --git a/cmd/tsconnect/common.go b/cmd/tsconnect/common.go deleted file mode 100644 index 0b0813226383a..0000000000000 --- a/cmd/tsconnect/common.go +++ /dev/null @@ -1,334 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "fmt" - "log" - "net" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - "slices" - "strconv" - "time" - - esbuild "github.com/evanw/esbuild/pkg/api" -) - -const ( - devMode = true - prodMode = false -) - -// commonSetup performs setup that is common to both dev and build modes. -func commonSetup(dev bool) (*esbuild.BuildOptions, error) { - // Change cwd to to where this file lives -- that's where all inputs for - // esbuild and other build steps live. - root, err := findRepoRoot() - if err != nil { - return nil, err - } - if *yarnPath == "" { - *yarnPath = path.Join(root, "tool", "yarn") - } - tsConnectDir := filepath.Join(root, "cmd", "tsconnect") - if err := os.Chdir(tsConnectDir); err != nil { - return nil, fmt.Errorf("Cannot change cwd: %w", err) - } - if err := installJSDeps(); err != nil { - return nil, fmt.Errorf("Cannot install JS deps: %w", err) - } - - return &esbuild.BuildOptions{ - EntryPoints: []string{"src/app/index.ts", "src/app/index.css"}, - Outdir: *distDir, - Bundle: true, - Sourcemap: esbuild.SourceMapLinked, - LogLevel: esbuild.LogLevelInfo, - Define: map[string]string{"DEBUG": strconv.FormatBool(dev)}, - Target: esbuild.ES2017, - Plugins: []esbuild.Plugin{ - { - Name: "tailscale-tailwind", - Setup: func(build esbuild.PluginBuild) { - setupEsbuildTailwind(build, dev) - }, - }, - { - Name: "tailscale-go-wasm-exec-js", - Setup: setupEsbuildWasmExecJS, - }, - { - Name: "tailscale-wasm", - Setup: func(build esbuild.PluginBuild) { - setupEsbuildWasm(build, dev) - }, - }, - }, - JSX: esbuild.JSXAutomatic, - }, nil -} - -func findRepoRoot() (string, error) { - if *rootDir != "" { - return *rootDir, nil - } - cwd, err := os.Getwd() - if err != nil { - return "", err - } - for { - if _, err := os.Stat(path.Join(cwd, "go.mod")); err == nil { - return cwd, nil - } - if cwd == "/" { - return "", fmt.Errorf("Cannot find repo root") - } - cwd = path.Dir(cwd) - } -} - -func commonPkgSetup(dev bool) (*esbuild.BuildOptions, error) { - buildOptions, err := commonSetup(dev) - if err != nil { - return nil, err - } - buildOptions.EntryPoints = []string{"src/pkg/pkg.ts", "src/pkg/pkg.css"} - buildOptions.Outdir = *pkgDir - buildOptions.Format = esbuild.FormatESModule - buildOptions.AssetNames = "[name]" - return buildOptions, nil -} - -// cleanDir removes files from dirPath, except the ones specified by -// preserveFiles. -func cleanDir(dirPath string, preserveFiles ...string) error { - log.Printf("Cleaning %s...\n", dirPath) - files, err := os.ReadDir(dirPath) - if err != nil { - if os.IsNotExist(err) { - return os.MkdirAll(dirPath, 0755) - } - return err - } - - for _, file := range files { - if !slices.Contains(preserveFiles, file.Name()) { - if err := os.Remove(filepath.Join(dirPath, file.Name())); err != nil { - return err - } - } - } - return nil -} - -func runEsbuildServe(buildOptions esbuild.BuildOptions) { - host, portStr, err := net.SplitHostPort(*addr) - if err != nil { - log.Fatalf("Cannot parse addr: %v", err) - } - port, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - log.Fatalf("Cannot parse port: %v", err) - } - buildContext, ctxErr := esbuild.Context(buildOptions) - if ctxErr != nil { - log.Fatalf("Cannot create esbuild context: %v", err) - } - result, err := buildContext.Serve(esbuild.ServeOptions{ - Port: uint16(port), - Host: host, - Servedir: "./", - }) - if err != nil { - log.Fatalf("Cannot start esbuild server: %v", err) - } - log.Printf("Listening on http://%s:%d\n", result.Host, result.Port) - select {} -} - -func runEsbuild(buildOptions esbuild.BuildOptions) esbuild.BuildResult { - log.Printf("Running esbuild...\n") - result := esbuild.Build(buildOptions) - if len(result.Errors) > 0 { - log.Printf("ESBuild Error:\n") - for _, e := range result.Errors { - log.Printf("%v", e) - } - log.Fatal("Build failed") - } - if len(result.Warnings) > 0 { - log.Printf("ESBuild Warnings:\n") - for _, w := range result.Warnings { - log.Printf("%v", w) - } - } - return result -} - -// setupEsbuildWasmExecJS generates an esbuild plugin that serves the current -// wasm_exec.js runtime helper library from the Go toolchain. -func setupEsbuildWasmExecJS(build esbuild.PluginBuild) { - wasmExecSrcPath := filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js") - build.OnResolve(esbuild.OnResolveOptions{ - Filter: "./wasm_exec$", - }, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { - return esbuild.OnResolveResult{Path: wasmExecSrcPath}, nil - }) -} - -// setupEsbuildWasm generates an esbuild plugin that builds the Tailscale wasm -// binary and serves it as a file that the JS can load. -func setupEsbuildWasm(build esbuild.PluginBuild, dev bool) { - // Add a resolve hook to convince esbuild that the path exists. - build.OnResolve(esbuild.OnResolveOptions{ - Filter: "./main.wasm$", - }, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { - return esbuild.OnResolveResult{ - Path: "./src/main.wasm", - Namespace: "generated", - }, nil - }) - build.OnLoad(esbuild.OnLoadOptions{ - Filter: "./src/main.wasm$", - }, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { - contents, err := buildWasm(dev) - if err != nil { - return esbuild.OnLoadResult{}, fmt.Errorf("Cannot build main.wasm: %w", err) - } - contentsStr := string(contents) - return esbuild.OnLoadResult{ - Contents: &contentsStr, - Loader: esbuild.LoaderFile, - }, nil - }) -} - -func buildWasm(dev bool) ([]byte, error) { - start := time.Now() - outputFile, err := os.CreateTemp("", "main.*.wasm") - if err != nil { - return nil, fmt.Errorf("Cannot create main.wasm output file: %w", err) - } - outputPath := outputFile.Name() - - defer os.Remove(outputPath) - // Running defer (*os.File).Close() in defer order before os.Remove - // because on some systems like Windows, it is possible for os.Remove - // to fail for unclosed files. - defer outputFile.Close() - - args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"} - if !dev { - if *devControl != "" { - return nil, fmt.Errorf("Development control URL can only be used in dev mode.") - } - // Omit long paths and debug symbols in release builds, to reduce the - // generated WASM binary size. - args = append(args, "-trimpath", "-ldflags", "-s -w") - } else if *devControl != "" { - args = append(args, "-ldflags", fmt.Sprintf("-X 'main.ControlURL=%v'", *devControl)) - } - - args = append(args, "-o", outputPath, "./wasm") - cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...) - cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - return nil, fmt.Errorf("Cannot build main.wasm: %w", err) - } - log.Printf("Built wasm in %v\n", time.Since(start).Round(time.Millisecond)) - - if !dev { - err := runWasmOpt(outputPath) - if err != nil { - return nil, fmt.Errorf("Cannot run wasm-opt: %w", err) - } - } - - return os.ReadFile(outputPath) -} - -func runWasmOpt(path string) error { - start := time.Now() - stat, err := os.Stat(path) - if err != nil { - return fmt.Errorf("Cannot stat %v: %w", path, err) - } - startSize := stat.Size() - cmd := exec.Command("../../tool/wasm-opt", "--enable-bulk-memory", "-Oz", path, "-o", path) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - return fmt.Errorf("Cannot run wasm-opt: %w", err) - } - stat, err = os.Stat(path) - if err != nil { - return fmt.Errorf("Cannot stat %v: %w", path, err) - } - endSize := stat.Size() - log.Printf("Ran wasm-opt in %v, size dropped by %dK\n", time.Since(start).Round(time.Millisecond), (startSize-endSize)/1024) - return nil -} - -// installJSDeps installs the JavaScript dependencies specified by package.json -func installJSDeps() error { - log.Printf("Installing JS deps...\n") - return runYarn() -} - -func runYarn(args ...string) error { - cmd := exec.Command(*yarnPath, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// EsbuildMetadata is the subset of metadata struct (described by -// https://esbuild.github.io/api/#metafile) that we care about for mapping -// from entry points to hashed file names. -type EsbuildMetadata struct { - Outputs map[string]struct { - Inputs map[string]struct { - BytesInOutput int64 `json:"bytesInOutput"` - } `json:"inputs,omitempty"` - EntryPoint string `json:"entryPoint,omitempty"` - } `json:"outputs,omitempty"` -} - -func setupEsbuildTailwind(build esbuild.PluginBuild, dev bool) { - build.OnLoad(esbuild.OnLoadOptions{ - Filter: "./src/.*\\.css$", - }, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { - start := time.Now() - yarnArgs := []string{"--silent", "tailwind", "-i", args.Path} - if !dev { - yarnArgs = append(yarnArgs, "--minify") - } - cmd := exec.Command(*yarnPath, yarnArgs...) - tailwindOutput, err := cmd.Output() - log.Printf("Ran tailwind in %v\n", time.Since(start).Round(time.Millisecond)) - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - log.Printf("Tailwind stderr: %s", exitErr.Stderr) - } - return esbuild.OnLoadResult{}, fmt.Errorf("Cannot run tailwind: %w", err) - } - tailwindOutputStr := string(tailwindOutput) - return esbuild.OnLoadResult{ - Contents: &tailwindOutputStr, - Loader: esbuild.LoaderCSS, - }, nil - - }) -} diff --git a/cmd/tsconnect/dev-pkg.go b/cmd/tsconnect/dev-pkg.go deleted file mode 100644 index de534c3b20625..0000000000000 --- a/cmd/tsconnect/dev-pkg.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "log" -) - -func runDevPkg() { - buildOptions, err := commonPkgSetup(devMode) - if err != nil { - log.Fatalf("Cannot setup: %v", err) - } - runEsbuildServe(*buildOptions) -} diff --git a/cmd/tsconnect/dev.go b/cmd/tsconnect/dev.go deleted file mode 100644 index 87b10adaf49c8..0000000000000 --- a/cmd/tsconnect/dev.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "log" -) - -func runDev() { - buildOptions, err := commonSetup(devMode) - if err != nil { - log.Fatalf("Cannot setup: %v", err) - } - runEsbuildServe(*buildOptions) -} diff --git a/cmd/tsconnect/dist/placeholder b/cmd/tsconnect/dist/placeholder deleted file mode 100644 index 4af99d997207f..0000000000000 --- a/cmd/tsconnect/dist/placeholder +++ /dev/null @@ -1,2 +0,0 @@ -This is here to make sure the dist/ directory exists for the go:embed command -in serve.go. diff --git a/cmd/tsconnect/index.html b/cmd/tsconnect/index.html deleted file mode 100644 index 3db45fdef2bca..0000000000000 --- a/cmd/tsconnect/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - Tailscale Connect - - - - - -
-
-

Tailscale Connect

-
Loading…
-
-
- - diff --git a/cmd/tsconnect/package.json b/cmd/tsconnect/package.json deleted file mode 100644 index bf4eb7c099aac..0000000000000 --- a/cmd/tsconnect/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "tsconnect", - "version": "0.0.1", - "license": "BSD-3-Clause", - "devDependencies": { - "@types/golang-wasm-exec": "^1.15.0", - "@types/qrcode": "^1.4.2", - "dts-bundle-generator": "^6.12.0", - "preact": "^10.10.0", - "qrcode": "^1.5.0", - "tailwindcss": "^3.1.6", - "typescript": "^4.7.4", - "xterm": "^5.1.0", - "xterm-addon-fit": "^0.7.0", - "xterm-addon-web-links": "^0.8.0" - }, - "scripts": { - "lint": "tsc --noEmit", - "pkg-types": "dts-bundle-generator --inline-declare-global=true --no-banner -o pkg/pkg.d.ts src/pkg/pkg.ts" - }, - "prettier": { - "semi": false, - "printWidth": 80 - } -} diff --git a/cmd/tsconnect/package.json.tmpl b/cmd/tsconnect/package.json.tmpl deleted file mode 100644 index 404b896eaf89e..0000000000000 --- a/cmd/tsconnect/package.json.tmpl +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Template for the package.json that is generated by the build-pkg command. -// The version number will be replaced by the current Tailscale client version -// number. -{ - "author": "Tailscale Inc.", - "description": "Tailscale Connect SDK", - "license": "BSD-3-Clause", - "name": "@tailscale/connect", - "type": "module", - "main": "./pkg.js", - "types": "./pkg.d.ts", - "version": "AUTO_GENERATED" -} diff --git a/cmd/tsconnect/serve.go b/cmd/tsconnect/serve.go deleted file mode 100644 index d780bdd57c3e3..0000000000000 --- a/cmd/tsconnect/serve.go +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package main - -import ( - "bytes" - "embed" - "encoding/json" - "fmt" - "io" - "io/fs" - "log" - "net/http" - "os" - "path" - "time" - - "tailscale.com/tsweb" - "tailscale.com/util/precompress" -) - -//go:embed index.html -var embeddedFS embed.FS - -//go:embed dist/* -var embeddedDistFS embed.FS - -var serveStartTime = time.Now() - -func runServe() { - mux := http.NewServeMux() - - var distFS fs.FS - if *distDir == "./dist" { - var err error - distFS, err = fs.Sub(embeddedDistFS, "dist") - if err != nil { - log.Fatalf("Could not drop dist/ prefix from embedded FS: %v", err) - } - } else { - distFS = os.DirFS(*distDir) - } - - indexBytes, err := generateServeIndex(distFS) - if err != nil { - log.Fatalf("Could not generate index.html: %v", err) - } - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.ServeContent(w, r, "index.html", serveStartTime, bytes.NewReader(indexBytes)) - })) - mux.Handle("/dist/", http.StripPrefix("/dist/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handleServeDist(w, r, distFS) - }))) - tsweb.Debugger(mux) - - log.Printf("Listening on %s", *addr) - err = http.ListenAndServe(*addr, mux) - if err != nil { - log.Fatal(err) - } -} - -func generateServeIndex(distFS fs.FS) ([]byte, error) { - log.Printf("Generating index.html...\n") - rawIndexBytes, err := embeddedFS.ReadFile("index.html") - if err != nil { - return nil, fmt.Errorf("Could not read index.html: %w", err) - } - - esbuildMetadataFile, err := distFS.Open("esbuild-metadata.json") - if err != nil { - return nil, fmt.Errorf("Could not open esbuild-metadata.json: %w", err) - } - defer esbuildMetadataFile.Close() - esbuildMetadataBytes, err := io.ReadAll(esbuildMetadataFile) - if err != nil { - return nil, fmt.Errorf("Could not read esbuild-metadata.json: %w", err) - } - var esbuildMetadata EsbuildMetadata - if err := json.Unmarshal(esbuildMetadataBytes, &esbuildMetadata); err != nil { - return nil, fmt.Errorf("Could not parse esbuild-metadata.json: %w", err) - } - entryPointsToHashedDistPaths := make(map[string]string) - mainWasmPath := "" - for outputPath, output := range esbuildMetadata.Outputs { - if output.EntryPoint != "" { - entryPointsToHashedDistPaths[output.EntryPoint] = path.Join("dist", outputPath) - } - if path.Ext(outputPath) == ".wasm" { - for input := range output.Inputs { - if input == "src/main.wasm" { - mainWasmPath = path.Join("dist", outputPath) - break - } - } - } - } - - indexBytes := rawIndexBytes - for entryPointPath, defaultDistPath := range entryPointsToDefaultDistPaths { - hashedDistPath := entryPointsToHashedDistPaths[entryPointPath] - if hashedDistPath != "" { - indexBytes = bytes.ReplaceAll(indexBytes, []byte(defaultDistPath), []byte(hashedDistPath)) - } - } - if mainWasmPath != "" { - mainWasmPrefetch := fmt.Sprintf("\n", mainWasmPath) - indexBytes = bytes.ReplaceAll(indexBytes, []byte(""), []byte(mainWasmPrefetch)) - } - - return indexBytes, nil -} - -var entryPointsToDefaultDistPaths = map[string]string{ - "src/app/index.css": "dist/index.css", - "src/app/index.ts": "dist/index.js", -} - -func handleServeDist(w http.ResponseWriter, r *http.Request, distFS fs.FS) { - path := r.URL.Path - f, err := precompress.OpenPrecompressedFile(w, r, path, distFS) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - defer f.Close() - - // fs.File does not claim to implement Seeker, but in practice it does. - fSeeker, ok := f.(io.ReadSeeker) - if !ok { - http.Error(w, "Not seekable", http.StatusInternalServerError) - return - } - - // Aggressively cache static assets, since we cache-bust our assets with - // hashed filenames. - w.Header().Set("Cache-Control", "public, max-age=31535996") - w.Header().Set("Vary", "Accept-Encoding") - - http.ServeContent(w, r, path, serveStartTime, fSeeker) -} diff --git a/cmd/tsconnect/src/app/app.tsx b/cmd/tsconnect/src/app/app.tsx deleted file mode 100644 index ee538eaeac506..0000000000000 --- a/cmd/tsconnect/src/app/app.tsx +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import { render, Component } from "preact" -import { URLDisplay } from "./url-display" -import { Header } from "./header" -import { GoPanicDisplay } from "./go-panic-display" -import { SSH } from "./ssh" - -type AppState = { - ipn?: IPN - ipnState: IPNState - netMap?: IPNNetMap - browseToURL?: string - goPanicError?: string -} - -class App extends Component<{}, AppState> { - state: AppState = { ipnState: "NoState" } - #goPanicTimeout?: number - - render() { - const { ipn, ipnState, goPanicError, netMap, browseToURL } = this.state - - let goPanicDisplay - if (goPanicError) { - goPanicDisplay = ( - - ) - } - - let urlDisplay - if (browseToURL) { - urlDisplay = - } - - let machineAuthInstructions - if (ipnState === "NeedsMachineAuth") { - machineAuthInstructions = ( -
- An administrator needs to approve this device. -
- ) - } - - const lockedOut = netMap?.lockedOut - let lockedOutInstructions - if (lockedOut) { - lockedOutInstructions = ( -
-

This instance of Tailscale Connect needs to be signed, due to - {" "}tailnet lock{" "} - being enabled on this domain. -

- -

- Run the following command on a device with a trusted tailnet lock key: -

tailscale lock sign {netMap.self.nodeKey}
-

-
- ) - } - - let ssh - if (ipn && ipnState === "Running" && netMap && !lockedOut) { - ssh = - } - - return ( - <> -
- {goPanicDisplay} -
- {urlDisplay} - {machineAuthInstructions} - {lockedOutInstructions} - {ssh} -
- - ) - } - - runWithIPN(ipn: IPN) { - this.setState({ ipn }, () => { - ipn.run({ - notifyState: this.handleIPNState, - notifyNetMap: this.handleNetMap, - notifyBrowseToURL: this.handleBrowseToURL, - notifyPanicRecover: this.handleGoPanic, - }) - }) - } - - handleIPNState = (state: IPNState) => { - const { ipn } = this.state - this.setState({ ipnState: state }) - if (state === "NeedsLogin") { - ipn?.login() - } else if (["Running", "NeedsMachineAuth"].includes(state)) { - this.setState({ browseToURL: undefined }) - } - } - - handleNetMap = (netMapStr: string) => { - const netMap = JSON.parse(netMapStr) as IPNNetMap - if (DEBUG) { - console.log("Received net map: " + JSON.stringify(netMap, null, 2)) - } - this.setState({ netMap }) - } - - handleBrowseToURL = (url: string) => { - if (this.state.ipnState === "Running") { - // Ignore URL requests if we're already running -- it's most likely an - // SSH check mode trigger and we already linkify the displayed URL - // in the terminal. - return - } - this.setState({ browseToURL: url }) - } - - handleGoPanic = (error: string) => { - if (DEBUG) { - console.error("Go panic", error) - } - this.setState({ goPanicError: error }) - if (this.#goPanicTimeout) { - window.clearTimeout(this.#goPanicTimeout) - } - this.#goPanicTimeout = window.setTimeout(this.clearGoPanic, 10000) - } - - clearGoPanic = () => { - window.clearTimeout(this.#goPanicTimeout) - this.#goPanicTimeout = undefined - this.setState({ goPanicError: undefined }) - } -} - -export function renderApp(): Promise { - return new Promise((resolve) => { - render( - (app ? resolve(app) : undefined)} />, - document.body - ) - }) -} diff --git a/cmd/tsconnect/src/app/go-panic-display.tsx b/cmd/tsconnect/src/app/go-panic-display.tsx deleted file mode 100644 index 5dd7095a27c7d..0000000000000 --- a/cmd/tsconnect/src/app/go-panic-display.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -export function GoPanicDisplay({ - error, - dismiss, -}: { - error: string - dismiss: () => void -}) { - return ( -
- Tailscale has encountered an error. -
Click to reload
-
- ) -} diff --git a/cmd/tsconnect/src/app/header.tsx b/cmd/tsconnect/src/app/header.tsx deleted file mode 100644 index 099ff2f8c2f7d..0000000000000 --- a/cmd/tsconnect/src/app/header.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -export function Header({ state, ipn }: { state: IPNState; ipn?: IPN }) { - const stateText = STATE_LABELS[state] - - let logoutButton - if (state === "Running") { - logoutButton = ( - - ) - } - return ( -
-
-

Tailscale Connect

-
{stateText}
- {logoutButton} -
-
- ) -} - -const STATE_LABELS = { - NoState: "Initializing…", - InUseOtherUser: "In-use by another user", - NeedsLogin: "Needs login", - NeedsMachineAuth: "Needs approval", - Stopped: "Stopped", - Starting: "Starting…", - Running: "Running", -} as const diff --git a/cmd/tsconnect/src/app/index.css b/cmd/tsconnect/src/app/index.css deleted file mode 100644 index 751b313d9f362..0000000000000 --- a/cmd/tsconnect/src/app/index.css +++ /dev/null @@ -1,74 +0,0 @@ -/* Copyright (c) Tailscale Inc & AUTHORS */ -/* SPDX-License-Identifier: BSD-3-Clause */ - -@import "xterm/css/xterm.css"; - -@tailwind base; -@tailwind components; -@tailwind utilities; - -.link { - @apply text-blue-600; -} - -.link:hover { - @apply underline; -} - -.button { - @apply font-medium py-1 px-2 rounded-md border border-transparent text-center cursor-pointer; - transition-property: background-color, border-color, color, box-shadow; - transition-duration: 120ms; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); - min-width: 80px; -} -.button:focus { - @apply outline-none ring; -} -.button:disabled { - @apply pointer-events-none select-none; -} - -.input { - @apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors px-3; - height: 2.375rem; -} - -.input::placeholder { - @apply text-gray-400; -} - -.input:disabled { - @apply border-gray-200; - @apply bg-gray-50; - @apply cursor-not-allowed; -} - -.input:focus { - @apply outline-none ring border-transparent; -} - -.select { - @apply appearance-none py-2 px-3 leading-tight rounded-md bg-white border border-gray-300; -} - -.select-with-arrow { - @apply relative; -} - -.select-with-arrow .select { - width: 100%; -} - -.select-with-arrow::after { - @apply absolute; - content: ""; - top: 50%; - right: 0.5rem; - transform: translate(-0.3em, -0.15em); - width: 0.6em; - height: 0.4em; - opacity: 0.6; - background-color: currentColor; - clip-path: polygon(100% 0%, 0 0%, 50% 100%); -} diff --git a/cmd/tsconnect/src/app/index.ts b/cmd/tsconnect/src/app/index.ts deleted file mode 100644 index 24ca4543921ae..0000000000000 --- a/cmd/tsconnect/src/app/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import "../wasm_exec" -import wasmUrl from "./main.wasm" -import { sessionStateStorage } from "../lib/js-state-store" -import { renderApp } from "./app" - -async function main() { - const app = await renderApp() - const go = new Go() - const wasmInstance = await WebAssembly.instantiateStreaming( - fetch(`./dist/${wasmUrl}`), - go.importObject - ) - // The Go process should never exit, if it does then it's an unhandled panic. - go.run(wasmInstance.instance).then(() => - app.handleGoPanic("Unexpected shutdown") - ) - - const params = new URLSearchParams(window.location.search) - const authKey = params.get("authkey") ?? undefined - - const ipn = newIPN({ - // Persist IPN state in sessionStorage in development, so that we don't need - // to re-authorize every time we reload the page. - stateStorage: DEBUG ? sessionStateStorage : undefined, - // authKey allows for an auth key to be - // specified as a url param which automatically - // authorizes the client for use. - authKey: DEBUG ? authKey : undefined, - }) - app.runWithIPN(ipn) -} - -main() diff --git a/cmd/tsconnect/src/app/ssh.tsx b/cmd/tsconnect/src/app/ssh.tsx deleted file mode 100644 index df81745bd3fd7..0000000000000 --- a/cmd/tsconnect/src/app/ssh.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import { useState, useCallback, useMemo, useEffect, useRef } from "preact/hooks" -import { createPortal } from "preact/compat" -import type { VNode } from "preact" -import { runSSHSession, SSHSessionDef } from "../lib/ssh" - -export function SSH({ netMap, ipn }: { netMap: IPNNetMap; ipn: IPN }) { - const [sshSessionDef, setSSHSessionDef] = useState( - null - ) - const clearSSHSessionDef = useCallback(() => setSSHSessionDef(null), []) - if (sshSessionDef) { - const sshSession = ( - - ) - if (sshSessionDef.newWindow) { - return {sshSession} - } - return sshSession - } - const sshPeers = netMap.peers.filter( - (p) => p.tailscaleSSHEnabled && p.online !== false - ) - - if (sshPeers.length == 0) { - return - } - - return -} - -type SSHFormSessionDef = SSHSessionDef & { newWindow?: boolean } - -function SSHSession({ - def, - ipn, - onDone, -}: { - def: SSHSessionDef - ipn: IPN - onDone: () => void -}) { - const ref = useRef(null) - useEffect(() => { - if (ref.current) { - runSSHSession(ref.current, def, ipn, { - onConnectionProgress: (p) => console.log("Connection progress", p), - onConnected() {}, - onError: (err) => console.error(err), - onDone, - }) - } - }, [ref]) - - return
-} - -function NoSSHPeers() { - return ( -
- None of your machines have{" "} - - Tailscale SSH - - {" "}enabled. Give it a try! -
- ) -} - -function SSHForm({ - sshPeers, - onSubmit, -}: { - sshPeers: IPNNetMapPeerNode[] - onSubmit: (def: SSHFormSessionDef) => void -}) { - sshPeers = sshPeers.slice().sort((a, b) => a.name.localeCompare(b.name)) - const [username, setUsername] = useState("") - const [hostname, setHostname] = useState(sshPeers[0].name) - return ( -
{ - e.preventDefault() - onSubmit({ username, hostname }) - }} - > - setUsername(e.currentTarget.value)} - /> -
- -
- { - if (e.altKey) { - e.preventDefault() - e.stopPropagation() - onSubmit({ username, hostname, newWindow: true }) - } - }} - /> -
- ) -} - -const NewWindow = ({ - children, - close, -}: { - children: VNode - close: () => void -}) => { - const newWindow = useMemo(() => { - const newWindow = window.open(undefined, undefined, "width=600,height=400") - if (newWindow) { - const containerNode = newWindow.document.createElement("div") - containerNode.className = "h-screen flex flex-col overflow-hidden" - newWindow.document.body.appendChild(containerNode) - - for (const linkNode of document.querySelectorAll( - "head link[rel=stylesheet]" - )) { - const newLink = document.createElement("link") - newLink.rel = "stylesheet" - newLink.href = (linkNode as HTMLLinkElement).href - newWindow.document.head.appendChild(newLink) - } - } - return newWindow - }, []) - if (!newWindow) { - console.error("Could not open window") - return null - } - newWindow.onbeforeunload = () => { - close() - } - - useEffect(() => () => newWindow.close(), []) - return createPortal(children, newWindow.document.body.lastChild as Element) -} diff --git a/cmd/tsconnect/src/app/url-display.tsx b/cmd/tsconnect/src/app/url-display.tsx deleted file mode 100644 index fc82c7fb91b3c..0000000000000 --- a/cmd/tsconnect/src/app/url-display.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import { useState } from "preact/hooks" -import * as qrcode from "qrcode" - -export function URLDisplay({ url }: { url: string }) { - const [dataURL, setDataURL] = useState("") - qrcode.toDataURL(url, { width: 512 }, (err, dataURL) => { - if (err) { - console.error("Error generating QR code", err) - } else { - setDataURL(dataURL) - } - }) - - return ( - - ) -} diff --git a/cmd/tsconnect/src/lib/js-state-store.ts b/cmd/tsconnect/src/lib/js-state-store.ts deleted file mode 100644 index e57dfd98efabd..0000000000000 --- a/cmd/tsconnect/src/lib/js-state-store.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -/** @fileoverview Callbacks used by jsStateStore to persist IPN state. */ - -export const sessionStateStorage: IPNStateStorage = { - setState(id, value) { - window.sessionStorage[`ipn-state-${id}`] = value - }, - getState(id) { - return window.sessionStorage[`ipn-state-${id}`] || "" - }, -} diff --git a/cmd/tsconnect/src/lib/ssh.ts b/cmd/tsconnect/src/lib/ssh.ts deleted file mode 100644 index 9c6f71aee4b41..0000000000000 --- a/cmd/tsconnect/src/lib/ssh.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import { Terminal, ITerminalOptions } from "xterm" -import { FitAddon } from "xterm-addon-fit" -import { WebLinksAddon } from "xterm-addon-web-links" - -export type SSHSessionDef = { - username: string - hostname: string - /** Defaults to 5 seconds */ - timeoutSeconds?: number -} - -export type SSHSessionCallbacks = { - onConnectionProgress: (messsage: string) => void - onConnected: () => void - onDone: () => void - onError?: (err: string) => void -} - -export function runSSHSession( - termContainerNode: HTMLDivElement, - def: SSHSessionDef, - ipn: IPN, - callbacks: SSHSessionCallbacks, - terminalOptions?: ITerminalOptions -) { - const parentWindow = termContainerNode.ownerDocument.defaultView ?? window - const term = new Terminal({ - cursorBlink: true, - allowProposedApi: true, - ...terminalOptions, - }) - - const fitAddon = new FitAddon() - term.loadAddon(fitAddon) - term.open(termContainerNode) - fitAddon.fit() - - const webLinksAddon = new WebLinksAddon((event, uri) => - event.view?.open(uri, "_blank", "noopener") - ) - term.loadAddon(webLinksAddon) - - let onDataHook: ((data: string) => void) | undefined - term.onData((e) => { - onDataHook?.(e) - }) - - term.focus() - - let resizeObserver: ResizeObserver | undefined - let handleUnload: ((e: Event) => void) | undefined - - const sshSession = ipn.ssh(def.hostname, def.username, { - writeFn(input) { - term.write(input) - }, - writeErrorFn(err) { - callbacks.onError?.(err) - term.write(err) - }, - setReadFn(hook) { - onDataHook = hook - }, - rows: term.rows, - cols: term.cols, - onConnectionProgress: callbacks.onConnectionProgress, - onConnected: callbacks.onConnected, - onDone() { - resizeObserver?.disconnect() - term.dispose() - if (handleUnload) { - parentWindow.removeEventListener("unload", handleUnload) - } - callbacks.onDone() - }, - timeoutSeconds: def.timeoutSeconds, - }) - - // Make terminal and SSH session track the size of the containing DOM node. - resizeObserver = new parentWindow.ResizeObserver(() => fitAddon.fit()) - resizeObserver.observe(termContainerNode) - term.onResize(({ rows, cols }) => sshSession.resize(rows, cols)) - - // Close the session if the user closes the window without an explicit - // exit. - handleUnload = () => sshSession.close() - parentWindow.addEventListener("unload", handleUnload) -} diff --git a/cmd/tsconnect/src/pkg/pkg.css b/cmd/tsconnect/src/pkg/pkg.css deleted file mode 100644 index 76ea21f5b53b2..0000000000000 --- a/cmd/tsconnect/src/pkg/pkg.css +++ /dev/null @@ -1,8 +0,0 @@ -/* Copyright (c) Tailscale Inc & AUTHORS */ -/* SPDX-License-Identifier: BSD-3-Clause */ - -@import "xterm/css/xterm.css"; - -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/cmd/tsconnect/src/pkg/pkg.ts b/cmd/tsconnect/src/pkg/pkg.ts deleted file mode 100644 index 4d535cb404015..0000000000000 --- a/cmd/tsconnect/src/pkg/pkg.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Type definitions need to be manually imported for dts-bundle-generator to -// discover them. -/// -/// - -import "../wasm_exec" -import wasmURL from "./main.wasm" - -/** - * Superset of the IPNConfig type, with additional configuration that is - * needed for the package to function. - */ -type IPNPackageConfig = IPNConfig & { - // Auth key used to initialize the Tailscale client (required) - authKey: string - // URL of the main.wasm file that is included in the page, if it is not - // accessible via a relative URL. - wasmURL?: string - // Function invoked if the Go process panics or unexpectedly exits. - panicHandler: (err: string) => void -} - -export async function createIPN(config: IPNPackageConfig): Promise { - const go = new Go() - const wasmInstance = await WebAssembly.instantiateStreaming( - fetch(config.wasmURL ?? wasmURL), - go.importObject - ) - // The Go process should never exit, if it does then it's an unhandled panic. - go.run(wasmInstance.instance).then(() => - config.panicHandler("Unexpected shutdown") - ) - - return newIPN(config) -} - -export { runSSHSession } from "../lib/ssh" diff --git a/cmd/tsconnect/src/types/esbuild.d.ts b/cmd/tsconnect/src/types/esbuild.d.ts deleted file mode 100644 index ef28f7b1cf556..0000000000000 --- a/cmd/tsconnect/src/types/esbuild.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -/** - * @fileoverview Type definitions for types generated by the esbuild build - * process. - */ - -declare module "*.wasm" { - const path: string - export default path -} - -declare const DEBUG: boolean diff --git a/cmd/tsconnect/src/types/wasm_js.d.ts b/cmd/tsconnect/src/types/wasm_js.d.ts deleted file mode 100644 index 492197ccb1a9b..0000000000000 --- a/cmd/tsconnect/src/types/wasm_js.d.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -/** - * @fileoverview Type definitions for types exported by the wasm_js.go Go - * module. - */ - -declare global { - function newIPN(config: IPNConfig): IPN - - interface IPN { - run(callbacks: IPNCallbacks): void - login(): void - logout(): void - ssh( - host: string, - username: string, - termConfig: { - writeFn: (data: string) => void - writeErrorFn: (err: string) => void - setReadFn: (readFn: (data: string) => void) => void - rows: number - cols: number - /** Defaults to 5 seconds */ - timeoutSeconds?: number - onConnectionProgress: (message: string) => void - onConnected: () => void - onDone: () => void - } - ): IPNSSHSession - fetch(url: string): Promise<{ - status: number - statusText: string - text: () => Promise - }> - } - - interface IPNSSHSession { - resize(rows: number, cols: number): boolean - close(): boolean - } - - interface IPNStateStorage { - setState(id: string, value: string): void - getState(id: string): string - } - - type IPNConfig = { - stateStorage?: IPNStateStorage - authKey?: string - controlURL?: string - hostname?: string - } - - type IPNCallbacks = { - notifyState: (state: IPNState) => void - notifyNetMap: (netMapStr: string) => void - notifyBrowseToURL: (url: string) => void - notifyPanicRecover: (err: string) => void - } - - type IPNNetMap = { - self: IPNNetMapSelfNode - peers: IPNNetMapPeerNode[] - lockedOut: boolean - } - - type IPNNetMapNode = { - name: string - addresses: string[] - machineKey: string - nodeKey: string - } - - type IPNNetMapSelfNode = IPNNetMapNode & { - machineStatus: IPNMachineStatus - } - - type IPNNetMapPeerNode = IPNNetMapNode & { - online?: boolean - tailscaleSSHEnabled: boolean - } - - /** Mirrors values from ipn/backend.go */ - type IPNState = - | "NoState" - | "InUseOtherUser" - | "NeedsLogin" - | "NeedsMachineAuth" - | "Stopped" - | "Starting" - | "Running" - - /** Mirrors values from MachineStatus in tailcfg.go */ - type IPNMachineStatus = - | "MachineUnknown" - | "MachineUnauthorized" - | "MachineAuthorized" - | "MachineInvalid" -} - -export {} diff --git a/cmd/tsconnect/tailwind.config.js b/cmd/tsconnect/tailwind.config.js deleted file mode 100644 index 31823000b6139..0000000000000 --- a/cmd/tsconnect/tailwind.config.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./index.html", "./src/**/*.ts", "./src/**/*.tsx"], - theme: { - extend: {}, - }, - plugins: [], -} diff --git a/cmd/tsconnect/tsconfig.json b/cmd/tsconnect/tsconfig.json deleted file mode 100644 index 52c25c7271f7c..0000000000000 --- a/cmd/tsconnect/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2017", - "module": "ES2020", - "moduleResolution": "node", - "isolatedModules": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "sourceMap": true, - "jsx": "react-jsx", - "jsxImportSource": "preact" - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} diff --git a/cmd/tsconnect/tsconnect.go b/cmd/tsconnect/tsconnect.go deleted file mode 100644 index 4c8a0a52ece34..0000000000000 --- a/cmd/tsconnect/tsconnect.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// The tsconnect command builds and serves the static site that is generated for -// the Tailscale Connect JS/WASM client. Can be run in 3 modes: -// - dev: builds the site and serves it. JS and CSS changes can be picked up -// with a reload. -// - build: builds the site and writes it to dist/ -// - serve: serves the site from dist/ (embedded in the binary) -package main // import "tailscale.com/cmd/tsconnect" - -import ( - "flag" - "fmt" - "log" - "os" -) - -var ( - addr = flag.String("addr", ":9090", "address to listen on") - distDir = flag.String("distdir", "./dist", "path of directory to place build output in") - pkgDir = flag.String("pkgdir", "./pkg", "path of directory to place NPM package build output in") - yarnPath = flag.String("yarnpath", "", "path yarn executable used to install JavaScript dependencies") - fastCompression = flag.Bool("fast-compression", false, "Use faster compression when building, to speed up build time. Meant to iterative/debugging use only.") - devControl = flag.String("dev-control", "", "URL of a development control server to be used with dev. If provided without specifying dev, an error will be returned.") - rootDir = flag.String("rootdir", "", "Root directory of repo. If not specified, will be inferred from the cwd.") -) - -func main() { - flag.Usage = usage - flag.Parse() - if len(flag.Args()) != 1 { - flag.Usage() - } - - switch flag.Arg(0) { - case "dev": - runDev() - case "dev-pkg": - runDevPkg() - case "build": - runBuild() - case "build-pkg": - runBuildPkg() - case "serve": - runServe() - default: - log.Printf("Unknown command: %s", flag.Arg(0)) - flag.Usage() - } -} - -func usage() { - fmt.Fprintf(os.Stderr, ` -usage: tsconnect {dev|build|serve} -`[1:]) - - flag.PrintDefaults() - fmt.Fprintf(os.Stderr, ` - -tsconnect implements development/build/serving workflows for Tailscale Connect. -It can be invoked with one of three subcommands: - -- dev: Run in development mode, allowing JS and CSS changes to be picked up without a rebuilt or restart. -- build: Run in production build mode (generating static assets) -- serve: Run in production serve mode (serving static assets) -`[1:]) - os.Exit(2) -} diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go deleted file mode 100644 index 4ea1cd89713cd..0000000000000 --- a/cmd/tsconnect/wasm/wasm_js.go +++ /dev/null @@ -1,682 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The wasm package builds a WebAssembly module that provides a subset of -// Tailscale APIs to JavaScript. -// -// When run in the browser, a newIPN(config) function is added to the global JS -// namespace. When called it returns an ipn object with the methods -// run(callbacks), login(), logout(), and ssh(...). -package main - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "math/rand/v2" - "net" - "net/http" - "net/netip" - "strings" - "syscall/js" - "time" - - "golang.org/x/crypto/ssh" - "tailscale.com/control/controlclient" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/ipnserver" - "tailscale.com/ipn/store/mem" - "tailscale.com/logpolicy" - "tailscale.com/logtail" - "tailscale.com/net/netns" - "tailscale.com/net/tsdial" - "tailscale.com/safesocket" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/types/views" - "tailscale.com/wgengine" - "tailscale.com/wgengine/netstack" - "tailscale.com/words" -) - -// ControlURL defines the URL to be used for connection to Control. -var ControlURL = ipn.DefaultControlURL - -func main() { - js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 1 { - log.Fatal("Usage: newIPN(config)") - return nil - } - return newIPN(args[0]) - })) - // Keep Go runtime alive, otherwise it will be shut down before newIPN gets - // called. - <-make(chan bool) -} - -func newIPN(jsConfig js.Value) map[string]any { - netns.SetEnabled(false) - - var store ipn.StateStore - if jsStateStorage := jsConfig.Get("stateStorage"); !jsStateStorage.IsUndefined() { - store = &jsStateStore{jsStateStorage} - } else { - store = new(mem.Store) - } - - controlURL := ControlURL - if jsControlURL := jsConfig.Get("controlURL"); jsControlURL.Type() == js.TypeString { - controlURL = jsControlURL.String() - } - - var authKey string - if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString { - authKey = jsAuthKey.String() - } - - var hostname string - if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString { - hostname = jsHostname.String() - } else { - hostname = generateHostname() - } - - lpc := getOrCreateLogPolicyConfig(store) - c := logtail.Config{ - Collection: lpc.Collection, - PrivateID: lpc.PrivateID, - - // Compressed requests set HTTP headers that are not supported by the - // no-cors fetching mode: - CompressLogs: false, - - HTTPC: &http.Client{Transport: &noCORSTransport{http.DefaultTransport}}, - } - logtail := logtail.NewLogger(c, log.Printf) - logf := logtail.Logf - - sys := new(tsd.System) - sys.Set(store) - dialer := &tsdial.Dialer{Logf: logf} - eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ - Dialer: dialer, - SetSubsystem: sys.Set, - ControlKnobs: sys.ControlKnobs(), - HealthTracker: sys.HealthTracker(), - Metrics: sys.UserMetricsRegistry(), - }) - if err != nil { - log.Fatal(err) - } - sys.Set(eng) - - ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) - if err != nil { - log.Fatalf("netstack.Create: %v", err) - } - sys.Set(ns) - ns.ProcessLocalIPs = true - ns.ProcessSubnets = true - - dialer.UseNetstackForIP = func(ip netip.Addr) bool { - return true - } - dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - return ns.DialContextTCP(ctx, dst) - } - dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - return ns.DialContextUDP(ctx, dst) - } - sys.NetstackRouter.Set(true) - sys.Tun.Get().Start() - - logid := lpc.PublicID - srv := ipnserver.New(logf, logid, sys.NetMon.Get()) - lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral) - if err != nil { - log.Fatalf("ipnlocal.NewLocalBackend: %v", err) - } - if err := ns.Start(lb); err != nil { - log.Fatalf("failed to start netstack: %v", err) - } - srv.SetLocalBackend(lb) - - jsIPN := &jsIPN{ - dialer: dialer, - srv: srv, - lb: lb, - controlURL: controlURL, - authKey: authKey, - hostname: hostname, - } - - return map[string]any{ - "run": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 1 { - log.Fatal(`Usage: run({ - notifyState(state: int): void, - notifyNetMap(netMap: object): void, - notifyBrowseToURL(url: string): void, - notifyPanicRecover(err: string): void, - })`) - return nil - } - jsIPN.run(args[0]) - return nil - }), - "login": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 0 { - log.Printf("Usage: login()") - return nil - } - jsIPN.login() - return nil - }), - "logout": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 0 { - log.Printf("Usage: logout()") - return nil - } - jsIPN.logout() - return nil - }), - "ssh": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 3 { - log.Printf("Usage: ssh(hostname, userName, termConfig)") - return nil - } - return jsIPN.ssh( - args[0].String(), - args[1].String(), - args[2]) - }), - "fetch": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 1 { - log.Printf("Usage: fetch(url)") - return nil - } - - url := args[0].String() - return jsIPN.fetch(url) - }), - } -} - -type jsIPN struct { - dialer *tsdial.Dialer - srv *ipnserver.Server - lb *ipnlocal.LocalBackend - controlURL string - authKey string - hostname string -} - -var jsIPNState = map[ipn.State]string{ - ipn.NoState: "NoState", - ipn.InUseOtherUser: "InUseOtherUser", - ipn.NeedsLogin: "NeedsLogin", - ipn.NeedsMachineAuth: "NeedsMachineAuth", - ipn.Stopped: "Stopped", - ipn.Starting: "Starting", - ipn.Running: "Running", -} - -var jsMachineStatus = map[tailcfg.MachineStatus]string{ - tailcfg.MachineUnknown: "MachineUnknown", - tailcfg.MachineUnauthorized: "MachineUnauthorized", - tailcfg.MachineAuthorized: "MachineAuthorized", - tailcfg.MachineInvalid: "MachineInvalid", -} - -func (i *jsIPN) run(jsCallbacks js.Value) { - notifyState := func(state ipn.State) { - jsCallbacks.Call("notifyState", jsIPNState[state]) - } - notifyState(ipn.NoState) - - i.lb.SetNotifyCallback(func(n ipn.Notify) { - // Panics in the notify callback are likely due to be due to bugs in - // this bridging module (as opposed to actual bugs in Tailscale) and - // thus may be recoverable. Let the UI know, and allow the user to - // choose if they want to reload the page. - defer func() { - if r := recover(); r != nil { - fmt.Println("Panic recovered:", r) - jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r)) - } - }() - log.Printf("NOTIFY: %+v", n) - if n.State != nil { - notifyState(*n.State) - } - if nm := n.NetMap; nm != nil { - jsNetMap := jsNetMap{ - Self: jsNetMapSelfNode{ - jsNetMapNode: jsNetMapNode{ - Name: nm.Name, - Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }), - NodeKey: nm.NodeKey.String(), - MachineKey: nm.MachineKey.String(), - }, - MachineStatus: jsMachineStatus[nm.GetMachineStatus()], - }, - Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode { - name := p.Name() - if name == "" { - // In practice this should only happen for Hello. - name = p.Hostinfo().Hostname() - } - addrs := make([]string, p.Addresses().Len()) - for i, ap := range p.Addresses().All() { - addrs[i] = ap.Addr().String() - } - return jsNetMapPeerNode{ - jsNetMapNode: jsNetMapNode{ - Name: name, - Addresses: addrs, - MachineKey: p.Machine().String(), - NodeKey: p.Key().String(), - }, - Online: p.Online(), - TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), - } - }), - LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0, - } - if jsonNetMap, err := json.Marshal(jsNetMap); err == nil { - jsCallbacks.Call("notifyNetMap", string(jsonNetMap)) - } else { - log.Printf("Could not generate JSON netmap: %v", err) - } - } - if n.BrowseToURL != nil { - jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) - } - }) - - go func() { - err := i.lb.Start(ipn.Options{ - UpdatePrefs: &ipn.Prefs{ - ControlURL: i.controlURL, - RouteAll: false, - WantRunning: true, - Hostname: i.hostname, - }, - AuthKey: i.authKey, - }) - if err != nil { - log.Printf("Start error: %v", err) - } - }() - - go func() { - ln, err := safesocket.Listen("") - if err != nil { - log.Fatalf("safesocket.Listen: %v", err) - } - - err = i.srv.Run(context.Background(), ln) - log.Fatalf("ipnserver.Run exited: %v", err) - }() -} - -func (i *jsIPN) login() { - go i.lb.StartLoginInteractive(context.Background()) -} - -func (i *jsIPN) logout() { - if i.lb.State() == ipn.NoState { - log.Printf("Backend not running") - } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - i.lb.Logout(ctx) - }() -} - -func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any { - jsSSHSession := &jsSSHSession{ - jsIPN: i, - host: host, - username: username, - termConfig: termConfig, - } - - go jsSSHSession.Run() - - return map[string]any{ - "close": js.FuncOf(func(this js.Value, args []js.Value) any { - return jsSSHSession.Close() != nil - }), - "resize": js.FuncOf(func(this js.Value, args []js.Value) any { - rows := args[0].Int() - cols := args[1].Int() - return jsSSHSession.Resize(rows, cols) != nil - }), - } -} - -type jsSSHSession struct { - jsIPN *jsIPN - host string - username string - termConfig js.Value - session *ssh.Session - - pendingResizeRows int - pendingResizeCols int -} - -func (s *jsSSHSession) Run() { - writeFn := s.termConfig.Get("writeFn") - writeErrorFn := s.termConfig.Get("writeErrorFn") - setReadFn := s.termConfig.Get("setReadFn") - rows := s.termConfig.Get("rows").Int() - cols := s.termConfig.Get("cols").Int() - timeoutSeconds := 5.0 - if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber { - timeoutSeconds = jsTimeoutSeconds.Float() - } - onConnectionProgress := s.termConfig.Get("onConnectionProgress") - onConnected := s.termConfig.Get("onConnected") - onDone := s.termConfig.Get("onDone") - defer onDone.Invoke() - - writeError := func(label string, err error) { - writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err)) - } - reportProgress := func(message string) { - onConnectionProgress.Invoke(message) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second))) - defer cancel() - reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0])) - c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22")) - if err != nil { - writeError("Dial", err) - return - } - defer c.Close() - - config := &ssh.ClientConfig{ - HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { - // Host keys are not used with Tailscale SSH, but we can use this - // callback to know that the connection has been established. - reportProgress("SSH connection established…") - return nil - }, - User: s.username, - } - - reportProgress("Starting SSH client…") - sshConn, _, _, err := ssh.NewClientConn(c, s.host, config) - if err != nil { - writeError("SSH Connection", err) - return - } - defer sshConn.Close() - - sshClient := ssh.NewClient(sshConn, nil, nil) - defer sshClient.Close() - - session, err := sshClient.NewSession() - if err != nil { - writeError("SSH Session", err) - return - } - s.session = session - defer session.Close() - - stdin, err := session.StdinPipe() - if err != nil { - writeError("SSH Stdin", err) - return - } - - session.Stdout = termWriter{writeFn} - session.Stderr = termWriter{writeFn} - - setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any { - input := args[0].String() - _, err := stdin.Write([]byte(input)) - if err != nil { - writeError("Write Input", err) - } - return nil - })) - - // We might have gotten a resize notification since we started opening the - // session, pick up the latest size. - if s.pendingResizeRows != 0 { - rows = s.pendingResizeRows - } - if s.pendingResizeCols != 0 { - cols = s.pendingResizeCols - } - err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{}) - - if err != nil { - writeError("Pseudo Terminal", err) - return - } - - err = session.Shell() - if err != nil { - writeError("Shell", err) - return - } - - onConnected.Invoke() - err = session.Wait() - if err != nil { - writeError("Wait", err) - return - } -} - -func (s *jsSSHSession) Close() error { - if s.session == nil { - // We never had a chance to open the session, ignore the close request. - return nil - } - return s.session.Close() -} - -func (s *jsSSHSession) Resize(rows, cols int) error { - if s.session == nil { - s.pendingResizeRows = rows - s.pendingResizeCols = cols - return nil - } - return s.session.WindowChange(rows, cols) -} - -func (i *jsIPN) fetch(url string) js.Value { - return makePromise(func() (any, error) { - c := &http.Client{ - Transport: &http.Transport{ - DialContext: i.dialer.UserDial, - }, - } - res, err := c.Get(url) - if err != nil { - return nil, err - } - - return map[string]any{ - "status": res.StatusCode, - "statusText": res.Status, - "text": js.FuncOf(func(this js.Value, args []js.Value) any { - return makePromise(func() (any, error) { - defer res.Body.Close() - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(res.Body); err != nil { - return nil, err - } - return buf.String(), nil - }) - }), - // TODO: populate a more complete JS Response object - }, nil - }) -} - -type termWriter struct { - f js.Value -} - -func (w termWriter) Write(p []byte) (n int, err error) { - r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1) - w.f.Invoke(string(r)) - return len(p), nil -} - -type jsNetMap struct { - Self jsNetMapSelfNode `json:"self"` - Peers []jsNetMapPeerNode `json:"peers"` - LockedOut bool `json:"lockedOut"` -} - -type jsNetMapNode struct { - Name string `json:"name"` - Addresses []string `json:"addresses"` - MachineKey string `json:"machineKey"` - NodeKey string `json:"nodeKey"` -} - -type jsNetMapSelfNode struct { - jsNetMapNode - MachineStatus string `json:"machineStatus"` -} - -type jsNetMapPeerNode struct { - jsNetMapNode - Online *bool `json:"online,omitempty"` - TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"` -} - -type jsStateStore struct { - jsStateStorage js.Value -} - -func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) { - jsValue := s.jsStateStorage.Call("getState", string(id)) - if jsValue.String() == "" { - return nil, ipn.ErrStateNotExist - } - return hex.DecodeString(jsValue.String()) -} - -func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error { - s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs)) - return nil -} - -func mapSlice[T any, M any](a []T, f func(T) M) []M { - n := make([]M, len(a)) - for i, e := range a { - n[i] = f(e) - } - return n -} - -func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M { - n := make([]M, a.Len()) - for i, v := range a.All() { - n[i] = f(v) - } - return n -} - -func filterSlice[T any](a []T, f func(T) bool) []T { - n := make([]T, 0, len(a)) - for _, e := range a { - if f(e) { - n = append(n, e) - } - } - return n -} - -func generateHostname() string { - tails := words.Tails() - scales := words.Scales() - if rand.IntN(2) == 0 { - // JavaScript - tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") }) - scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") }) - } else { - // WebAssembly - tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") }) - scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") }) - } - - tail := tails[rand.IntN(len(tails))] - scale := scales[rand.IntN(len(scales))] - return fmt.Sprintf("%s-%s", tail, scale) -} - -// makePromise handles the boilerplate of wrapping goroutines with JS promises. -// f is run on a goroutine and its return value is used to resolve the promise -// (or reject it if an error is returned). -func makePromise(f func() (any, error)) js.Value { - handler := js.FuncOf(func(this js.Value, args []js.Value) any { - resolve := args[0] - reject := args[1] - go func() { - if res, err := f(); err == nil { - resolve.Invoke(res) - } else { - reject.Invoke(err.Error()) - } - }() - return nil - }) - - promiseConstructor := js.Global().Get("Promise") - return promiseConstructor.New(handler) -} - -const logPolicyStateKey = "log-policy" - -func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config { - if configBytes, err := state.ReadState(logPolicyStateKey); err == nil { - if config, err := logpolicy.ConfigFromBytes(configBytes); err == nil { - return config - } else { - log.Printf("Could not parse log policy config: %v", err) - } - } else if err != ipn.ErrStateNotExist { - log.Printf("Could not get log policy config from state store: %v", err) - } - config := logpolicy.NewConfig(logtail.CollectionNode) - if err := state.WriteState(logPolicyStateKey, config.ToBytes()); err != nil { - log.Printf("Could not save log policy config to state store: %v", err) - } - return config -} - -// noCORSTransport wraps a RoundTripper and forces the no-cors mode on requests, -// so that we can use it with non-CORS-aware servers. -type noCORSTransport struct { - http.RoundTripper -} - -func (t *noCORSTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("js.fetch:mode", "no-cors") - resp, err := t.RoundTripper.RoundTrip(req) - if err == nil { - // In no-cors mode no response properties are returned. Populate just - // the status so that callers do not think this was an error. - resp.StatusCode = http.StatusOK - resp.Status = http.StatusText(http.StatusOK) - } - return resp, err -} diff --git a/cmd/tsconnect/yarn.lock b/cmd/tsconnect/yarn.lock deleted file mode 100644 index 663a1244ebf69..0000000000000 --- a/cmd/tsconnect/yarn.lock +++ /dev/null @@ -1,713 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.3": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - -"@types/golang-wasm-exec@^1.15.0": - version "1.15.0" - resolved "https://registry.yarnpkg.com/@types/golang-wasm-exec/-/golang-wasm-exec-1.15.0.tgz#d0aafbb2b0dc07eaf45dfb83bfb6cdd5b2b3c55c" - integrity sha512-FrL97mp7WW8LqNinVkzTVKOIQKuYjQqgucnh41+1vRQ+bf1LT8uh++KRf9otZPXsa6H1p8ruIGz1BmCGttOL6Q== - -"@types/node@*": - version "18.6.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.1.tgz#828e4785ccca13f44e2fb6852ae0ef11e3e20ba5" - integrity sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg== - -"@types/qrcode@^1.4.2": - version "1.4.2" - resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.4.2.tgz#7d7142d6fa9921f195db342ed08b539181546c74" - integrity sha512-7uNT9L4WQTNJejHTSTdaJhfBSCN73xtXaHFyBJ8TSwiLhe4PRuTue7Iph0s2nG9R/ifUaSnGhLUOZavlBEqDWQ== - dependencies: - "@types/node" "*" - -acorn-node@^1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" - integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A== - dependencies: - acorn "^7.0.0" - acorn-walk "^7.0.0" - xtend "^4.0.2" - -acorn-walk@^7.0.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" - integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== - -acorn@^7.0.0: - version "7.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" - integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -anymatch@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" - integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -arg@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" - integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -camelcase-css@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" - integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== - -camelcase@^5.0.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -chokidar@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -cliui@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" - integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^6.2.0" - -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@^1.1.4, color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -cssesc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" - integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== - -decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= - -defined@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" - integrity sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ== - -detective@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034" - integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw== - dependencies: - acorn-node "^1.8.2" - defined "^1.0.0" - minimist "^1.2.6" - -didyoumean@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" - integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== - -dijkstrajs@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" - integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg== - -dlv@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" - integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== - -dts-bundle-generator@^6.12.0: - version "6.12.0" - resolved "https://registry.yarnpkg.com/dts-bundle-generator/-/dts-bundle-generator-6.12.0.tgz#0a221bdce5fdd309a56c8556e645f16ed87ab07d" - integrity sha512-k/QAvuVaLIdyWRUHduDrWBe4j8PcE6TDt06+f32KHbW7/SmUPbX1O23fFtQgKwUyTBkbIjJFOFtNrF97tJcKug== - dependencies: - typescript ">=3.0.1" - yargs "^17.2.1" - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -encode-utf8@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" - integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -fast-glob@^3.2.11: - version "3.2.11" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" - integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fastq@^1.6.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" - integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== - dependencies: - reusify "^1.0.4" - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -get-caller-file@^2.0.1, get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -glob-parent@^5.1.2, glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-core-module@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" - integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== - dependencies: - has "^1.0.3" - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -lilconfig@^2.0.5: - version "2.0.6" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" - integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -merge2@^1.3.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" - integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== - -micromatch@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -minimist@^1.2.6: - version "1.2.6" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" - integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== - -nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -object-hash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" - integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pify@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== - -pngjs@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" - integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== - -postcss-import@^14.1.0: - version "14.1.0" - resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0" - integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw== - dependencies: - postcss-value-parser "^4.0.0" - read-cache "^1.0.0" - resolve "^1.1.7" - -postcss-js@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00" - integrity sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ== - dependencies: - camelcase-css "^2.0.1" - -postcss-load-config@^3.1.4: - version "3.1.4" - resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855" - integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg== - dependencies: - lilconfig "^2.0.5" - yaml "^1.10.2" - -postcss-nested@5.0.6: - version "5.0.6" - resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-5.0.6.tgz#466343f7fc8d3d46af3e7dba3fcd47d052a945bc" - integrity sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA== - dependencies: - postcss-selector-parser "^6.0.6" - -postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.6: - version "6.0.10" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" - integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - -postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" - integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== - -postcss@^8.4.14: - version "8.4.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" - integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -preact@^10.10.0: - version "10.10.0" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.10.0.tgz#7434750a24b59dae1957d95dc0aa47a4a8e9a180" - integrity sha512-fszkg1iJJjq68I4lI8ZsmBiaoQiQHbxf1lNq+72EmC/mZOsFF5zn3k1yv9QGoFgIXzgsdSKtYymLJsrJPoamjQ== - -qrcode@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b" - integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ== - dependencies: - dijkstrajs "^1.0.1" - encode-utf8 "^1.0.3" - pngjs "^5.0.0" - yargs "^15.3.1" - -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -quick-lru@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" - integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== - -read-cache@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" - integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== - dependencies: - pify "^2.3.0" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= - -require-main-filename@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" - integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== - -resolve@^1.1.7, resolve@^1.22.1: - version "1.22.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" - integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== - dependencies: - is-core-module "^2.9.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - -source-map-js@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -tailwindcss@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.1.6.tgz#bcb719357776c39e6376a8d84e9834b2b19a49f1" - integrity sha512-7skAOY56erZAFQssT1xkpk+kWt2NrO45kORlxFPXUt3CiGsVPhH1smuH5XoDH6sGPXLyBv+zgCKA2HWBsgCytg== - dependencies: - arg "^5.0.2" - chokidar "^3.5.3" - color-name "^1.1.4" - detective "^5.2.1" - didyoumean "^1.2.2" - dlv "^1.1.3" - fast-glob "^3.2.11" - glob-parent "^6.0.2" - is-glob "^4.0.3" - lilconfig "^2.0.5" - normalize-path "^3.0.0" - object-hash "^3.0.0" - picocolors "^1.0.0" - postcss "^8.4.14" - postcss-import "^14.1.0" - postcss-js "^4.0.0" - postcss-load-config "^3.1.4" - postcss-nested "5.0.6" - postcss-selector-parser "^6.0.10" - postcss-value-parser "^4.2.0" - quick-lru "^5.1.1" - resolve "^1.22.1" - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -typescript@>=3.0.1, typescript@^4.7.4: - version "4.7.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" - integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== - -util-deprecate@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= - -wrap-ansi@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" - integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -xtend@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -xterm-addon-fit@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.7.0.tgz#b8ade6d96e63b47443862088f6670b49fb752c6a" - integrity sha512-tQgHGoHqRTgeROPnvmtEJywLKoC/V9eNs4bLLz7iyJr1aW/QFzRwfd3MGiJ6odJd9xEfxcW36/xRU47JkD5NKQ== - -xterm-addon-web-links@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.8.0.tgz#2cb1d57129271022569208578b0bf4774e7e6ea9" - integrity sha512-J4tKngmIu20ytX9SEJjAP3UGksah7iALqBtfTwT9ZnmFHVplCumYQsUJfKuS+JwMhjsjH61YXfndenLNvjRrEw== - -xterm@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.1.0.tgz#3e160d60e6801c864b55adf19171c49d2ff2b4fc" - integrity sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ== - -y18n@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" - integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yaml@^1.10.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - -yargs-parser@^18.1.2: - version "18.1.3" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" - integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-parser@^21.0.0: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs@^15.3.1: - version "15.4.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" - integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== - dependencies: - cliui "^6.0.0" - decamelize "^1.2.0" - find-up "^4.1.0" - get-caller-file "^2.0.1" - require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^4.2.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^18.1.2" - -yargs@^17.2.1: - version "17.5.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e" - integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.0.0" diff --git a/cmd/tsidp/tsidp.go b/cmd/tsidp/tsidp.go deleted file mode 100644 index 1bdca8919a085..0000000000000 --- a/cmd/tsidp/tsidp.go +++ /dev/null @@ -1,1059 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The tsidp command is an OpenID Connect Identity Provider server. -// -// See https://github.com/tailscale/tailscale/issues/10263 for background. -package main - -import ( - "bytes" - "context" - crand "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/pem" - "errors" - "flag" - "fmt" - "io" - "log" - "net" - "net/http" - "net/netip" - "net/url" - "os" - "os/signal" - "strconv" - "strings" - "sync" - "time" - - "gopkg.in/square/go-jose.v2" - "gopkg.in/square/go-jose.v2/jwt" - "tailscale.com/client/tailscale" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" - "tailscale.com/types/key" - "tailscale.com/types/lazy" - "tailscale.com/types/views" - "tailscale.com/util/mak" - "tailscale.com/util/must" - "tailscale.com/util/rands" - "tailscale.com/version" -) - -// ctxConn is a key to look up a net.Conn stored in an HTTP request's context. -type ctxConn struct{} - -// funnelClientsFile is the file where client IDs and secrets for OIDC clients -// accessing the IDP over Funnel are persisted. -const funnelClientsFile = "oidc-funnel-clients.json" - -var ( - flagVerbose = flag.Bool("verbose", false, "be verbose") - flagPort = flag.Int("port", 443, "port to listen on") - flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost") - flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", false, "use local tailscaled instead of tsnet") - flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet") - flagDir = flag.String("dir", "", "tsnet state directory; a default one will be created if not provided") -) - -func main() { - flag.Parse() - ctx := context.Background() - if !envknob.UseWIPCode() { - log.Fatal("cmd/tsidp is a work in progress and has not been security reviewed;\nits use requires TAILSCALE_USE_WIP_CODE=1 be set in the environment for now.") - } - - var ( - lc *tailscale.LocalClient - st *ipnstate.Status - err error - watcherChan chan error - cleanup func() - - lns []net.Listener - ) - if *flagUseLocalTailscaled { - lc = &tailscale.LocalClient{} - st, err = lc.StatusWithoutPeers(ctx) - if err != nil { - log.Fatalf("getting status: %v", err) - } - portStr := fmt.Sprint(*flagPort) - anySuccess := false - for _, ip := range st.TailscaleIPs { - ln, err := net.Listen("tcp", net.JoinHostPort(ip.String(), portStr)) - if err != nil { - log.Printf("failed to listen on %v: %v", ip, err) - continue - } - anySuccess = true - ln = tls.NewListener(ln, &tls.Config{ - GetCertificate: lc.GetCertificate, - }) - lns = append(lns, ln) - } - if !anySuccess { - log.Fatalf("failed to listen on any of %v", st.TailscaleIPs) - } - - // tailscaled needs to be setting an HTTP header for funneled requests - // that older versions don't provide. - // TODO(naman): is this the correct check? - if *flagFunnel && !version.AtLeast(st.Version, "1.71.0") { - log.Fatalf("Local tailscaled not new enough to support -funnel. Update Tailscale or use tsnet mode.") - } - cleanup, watcherChan, err = serveOnLocalTailscaled(ctx, lc, st, uint16(*flagPort), *flagFunnel) - if err != nil { - log.Fatalf("could not serve on local tailscaled: %v", err) - } - defer cleanup() - } else { - ts := &tsnet.Server{ - Hostname: "idp", - Dir: *flagDir, - } - if *flagVerbose { - ts.Logf = log.Printf - } - st, err = ts.Up(ctx) - if err != nil { - log.Fatal(err) - } - lc, err = ts.LocalClient() - if err != nil { - log.Fatalf("getting local client: %v", err) - } - var ln net.Listener - if *flagFunnel { - if err := ipn.CheckFunnelAccess(uint16(*flagPort), st.Self); err != nil { - log.Fatalf("%v", err) - } - ln, err = ts.ListenFunnel("tcp", fmt.Sprintf(":%d", *flagPort)) - } else { - ln, err = ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort)) - } - if err != nil { - log.Fatal(err) - } - lns = append(lns, ln) - } - - srv := &idpServer{ - lc: lc, - funnel: *flagFunnel, - localTSMode: *flagUseLocalTailscaled, - } - if *flagPort != 443 { - srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort) - } else { - srv.serverURL = fmt.Sprintf("https://%s", strings.TrimSuffix(st.Self.DNSName, ".")) - } - if *flagFunnel { - f, err := os.Open(funnelClientsFile) - if err == nil { - srv.funnelClients = make(map[string]*funnelClient) - if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil { - log.Fatalf("could not parse %s: %v", funnelClientsFile, err) - } - } else if !errors.Is(err, os.ErrNotExist) { - log.Fatalf("could not open %s: %v", funnelClientsFile, err) - } - } - - log.Printf("Running tsidp at %s ...", srv.serverURL) - - if *flagLocalPort != -1 { - log.Printf("Also running tsidp at %s ...", srv.loopbackURL) - srv.loopbackURL = fmt.Sprintf("http://localhost:%d", *flagLocalPort) - ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", *flagLocalPort)) - if err != nil { - log.Fatal(err) - } - lns = append(lns, ln) - } - - for _, ln := range lns { - server := http.Server{ - Handler: srv, - ConnContext: func(ctx context.Context, c net.Conn) context.Context { - return context.WithValue(ctx, ctxConn{}, c) - }, - } - go server.Serve(ln) - } - // need to catch os.Interrupt, otherwise deferred cleanup code doesn't run - exitChan := make(chan os.Signal, 1) - signal.Notify(exitChan, os.Interrupt) - select { - case <-exitChan: - log.Printf("interrupt, exiting") - return - case <-watcherChan: - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { - log.Printf("watcher closed, exiting") - return - } - log.Fatalf("watcher error: %v", err) - return - } -} - -// serveOnLocalTailscaled starts a serve session using an already-running -// tailscaled instead of starting a fresh tsnet server, making something -// listening on clientDNSName:dstPort accessible over serve/funnel. -func serveOnLocalTailscaled(ctx context.Context, lc *tailscale.LocalClient, st *ipnstate.Status, dstPort uint16, shouldFunnel bool) (cleanup func(), watcherChan chan error, err error) { - // In order to support funneling out in local tailscaled mode, we need - // to add a serve config to forward the listeners we bound above and - // allow those forwarders to be funneled out. - sc, err := lc.GetServeConfig(ctx) - if err != nil { - return nil, nil, fmt.Errorf("could not get serve config: %v", err) - } - if sc == nil { - sc = new(ipn.ServeConfig) - } - - // We watch the IPN bus just to get a session ID. The session expires - // when we stop watching the bus, and that auto-deletes the foreground - // serve/funnel configs we are creating below. - watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys) - if err != nil { - return nil, nil, fmt.Errorf("could not set up ipn bus watcher: %v", err) - } - defer func() { - if err != nil { - watcher.Close() - } - }() - n, err := watcher.Next() - if err != nil { - return nil, nil, fmt.Errorf("could not get initial state from ipn bus watcher: %v", err) - } - if n.SessionID == "" { - err = fmt.Errorf("missing sessionID in ipn.Notify") - return nil, nil, err - } - watcherChan = make(chan error) - go func() { - for { - _, err = watcher.Next() - if err != nil { - watcherChan <- err - return - } - } - }() - - // Create a foreground serve config that gets cleaned up when tsidp - // exits and the session ID associated with this config is invalidated. - foregroundSc := new(ipn.ServeConfig) - mak.Set(&sc.Foreground, n.SessionID, foregroundSc) - serverURL := strings.TrimSuffix(st.Self.DNSName, ".") - fmt.Printf("setting funnel for %s:%v\n", serverURL, dstPort) - - foregroundSc.SetFunnel(serverURL, dstPort, shouldFunnel) - foregroundSc.SetWebHandler(&ipn.HTTPHandler{ - Proxy: fmt.Sprintf("https://%s", net.JoinHostPort(serverURL, strconv.Itoa(int(dstPort)))), - }, serverURL, uint16(*flagPort), "/", true) - err = lc.SetServeConfig(ctx, sc) - if err != nil { - return nil, watcherChan, fmt.Errorf("could not set serve config: %v", err) - } - - return func() { watcher.Close() }, watcherChan, nil -} - -type idpServer struct { - lc *tailscale.LocalClient - loopbackURL string - serverURL string // "https://foo.bar.ts.net" - funnel bool - localTSMode bool - - lazyMux lazy.SyncValue[*http.ServeMux] - lazySigningKey lazy.SyncValue[*signingKey] - lazySigner lazy.SyncValue[jose.Signer] - - mu sync.Mutex // guards the fields below - code map[string]*authRequest // keyed by random hex - accessToken map[string]*authRequest // keyed by random hex - funnelClients map[string]*funnelClient // keyed by client ID -} - -type authRequest struct { - // localRP is true if the request is from a relying party running on the - // same machine as the idp server. It is mutually exclusive with rpNodeID - // and funnelRP. - localRP bool - - // rpNodeID is the NodeID of the relying party (who requested the auth, such - // as Proxmox or Synology), not the user node who is being authenticated. It - // is mutually exclusive with localRP and funnelRP. - rpNodeID tailcfg.NodeID - - // funnelRP is non-nil if the request is from a relying party outside the - // tailnet, via Tailscale Funnel. It is mutually exclusive with rpNodeID - // and localRP. - funnelRP *funnelClient - - // clientID is the "client_id" sent in the authorized request. - clientID string - - // nonce presented in the request. - nonce string - - // redirectURI is the redirect_uri presented in the request. - redirectURI string - - // remoteUser is the user who is being authenticated. - remoteUser *apitype.WhoIsResponse - - // validTill is the time until which the token is valid. - // As of 2023-11-14, it is 5 minutes. - // TODO: add routine to delete expired tokens. - validTill time.Time -} - -// allowRelyingParty validates that a relying party identified either by a -// known remoteAddr or a valid client ID/secret pair is allowed to proceed -// with the authorization flow associated with this authRequest. -func (ar *authRequest) allowRelyingParty(r *http.Request, lc *tailscale.LocalClient) error { - if ar.localRP { - ra, err := netip.ParseAddrPort(r.RemoteAddr) - if err != nil { - return err - } - if !ra.Addr().IsLoopback() { - return fmt.Errorf("tsidp: request from non-loopback address") - } - return nil - } - if ar.funnelRP != nil { - clientID, clientSecret, ok := r.BasicAuth() - if !ok { - clientID = r.FormValue("client_id") - clientSecret = r.FormValue("client_secret") - } - if ar.funnelRP.ID != clientID || ar.funnelRP.Secret != clientSecret { - return fmt.Errorf("tsidp: invalid client credentials") - } - return nil - } - who, err := lc.WhoIs(r.Context(), r.RemoteAddr) - if err != nil { - return fmt.Errorf("tsidp: error getting WhoIs: %w", err) - } - if ar.rpNodeID != who.Node.ID { - return fmt.Errorf("tsidp: token for different node") - } - return nil -} - -func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) { - // This URL is visited by the user who is being authenticated. If they are - // visiting the URL over Funnel, that means they are not part of the - // tailnet that they are trying to be authenticated for. - if isFunnelRequest(r) { - http.Error(w, "tsidp: unauthorized", http.StatusUnauthorized) - return - } - - uq := r.URL.Query() - - redirectURI := uq.Get("redirect_uri") - if redirectURI == "" { - http.Error(w, "tsidp: must specify redirect_uri", http.StatusBadRequest) - return - } - - var remoteAddr string - if s.localTSMode { - // in local tailscaled mode, the local tailscaled is forwarding us - // HTTP requests, so reading r.RemoteAddr will just get us our own - // address. - remoteAddr = r.Header.Get("X-Forwarded-For") - } else { - remoteAddr = r.RemoteAddr - } - who, err := s.lc.WhoIs(r.Context(), remoteAddr) - if err != nil { - log.Printf("Error getting WhoIs: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - code := rands.HexString(32) - ar := &authRequest{ - nonce: uq.Get("nonce"), - remoteUser: who, - redirectURI: redirectURI, - clientID: uq.Get("client_id"), - } - - if r.URL.Path == "/authorize/funnel" { - s.mu.Lock() - c, ok := s.funnelClients[ar.clientID] - s.mu.Unlock() - if !ok { - http.Error(w, "tsidp: invalid client ID", http.StatusBadRequest) - return - } - if ar.redirectURI != c.RedirectURI { - http.Error(w, "tsidp: redirect_uri mismatch", http.StatusBadRequest) - return - } - ar.funnelRP = c - } else if r.URL.Path == "/authorize/localhost" { - ar.localRP = true - } else { - var ok bool - ar.rpNodeID, ok = parseID[tailcfg.NodeID](strings.TrimPrefix(r.URL.Path, "/authorize/")) - if !ok { - http.Error(w, "tsidp: invalid node ID suffix after /authorize/", http.StatusBadRequest) - return - } - } - - s.mu.Lock() - mak.Set(&s.code, code, ar) - s.mu.Unlock() - - q := make(url.Values) - q.Set("code", code) - if state := uq.Get("state"); state != "" { - q.Set("state", state) - } - u := redirectURI + "?" + q.Encode() - log.Printf("Redirecting to %q", u) - - http.Redirect(w, r, u, http.StatusFound) -} - -func (s *idpServer) newMux() *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc(oidcJWKSPath, s.serveJWKS) - mux.HandleFunc(oidcConfigPath, s.serveOpenIDConfig) - mux.HandleFunc("/authorize/", s.authorize) - mux.HandleFunc("/userinfo", s.serveUserInfo) - mux.HandleFunc("/token", s.serveToken) - mux.HandleFunc("/clients/", s.serveClients) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - io.WriteString(w, "

Tailscale OIDC IdP

") - return - } - http.Error(w, "tsidp: not found", http.StatusNotFound) - }) - return mux -} - -func (s *idpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - log.Printf("%v %v", r.Method, r.URL) - s.lazyMux.Get(s.newMux).ServeHTTP(w, r) -} - -func (s *idpServer) serveUserInfo(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) - return - } - tk, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") - if !ok { - http.Error(w, "tsidp: invalid Authorization header", http.StatusBadRequest) - return - } - - s.mu.Lock() - ar, ok := s.accessToken[tk] - s.mu.Unlock() - if !ok { - http.Error(w, "tsidp: invalid token", http.StatusBadRequest) - return - } - - if ar.validTill.Before(time.Now()) { - http.Error(w, "tsidp: token expired", http.StatusBadRequest) - s.mu.Lock() - delete(s.accessToken, tk) - s.mu.Unlock() - } - - ui := userInfo{} - if ar.remoteUser.Node.IsTagged() { - http.Error(w, "tsidp: tagged nodes not supported", http.StatusBadRequest) - return - } - ui.Sub = ar.remoteUser.Node.User.String() - ui.Name = ar.remoteUser.UserProfile.DisplayName - ui.Email = ar.remoteUser.UserProfile.LoginName - ui.Picture = ar.remoteUser.UserProfile.ProfilePicURL - - // TODO(maisem): not sure if this is the right thing to do - ui.UserName, _, _ = strings.Cut(ar.remoteUser.UserProfile.LoginName, "@") - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(ui); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -type userInfo struct { - Sub string `json:"sub"` - Name string `json:"name"` - Email string `json:"email"` - Picture string `json:"picture"` - UserName string `json:"username"` -} - -func (s *idpServer) serveToken(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) - return - } - if r.FormValue("grant_type") != "authorization_code" { - http.Error(w, "tsidp: grant_type not supported", http.StatusBadRequest) - return - } - code := r.FormValue("code") - if code == "" { - http.Error(w, "tsidp: code is required", http.StatusBadRequest) - return - } - s.mu.Lock() - ar, ok := s.code[code] - if ok { - delete(s.code, code) - } - s.mu.Unlock() - if !ok { - http.Error(w, "tsidp: code not found", http.StatusBadRequest) - return - } - if err := ar.allowRelyingParty(r, s.lc); err != nil { - log.Printf("Error allowing relying party: %v", err) - http.Error(w, err.Error(), http.StatusForbidden) - return - } - if ar.redirectURI != r.FormValue("redirect_uri") { - http.Error(w, "tsidp: redirect_uri mismatch", http.StatusBadRequest) - return - } - signer, err := s.oidcSigner() - if err != nil { - log.Printf("Error getting signer: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - jti := rands.HexString(32) - who := ar.remoteUser - - // TODO(maisem): not sure if this is the right thing to do - userName, _, _ := strings.Cut(ar.remoteUser.UserProfile.LoginName, "@") - n := who.Node.View() - if n.IsTagged() { - http.Error(w, "tsidp: tagged nodes not supported", http.StatusBadRequest) - return - } - - now := time.Now() - _, tcd, _ := strings.Cut(n.Name(), ".") - tsClaims := tailscaleClaims{ - Claims: jwt.Claims{ - Audience: jwt.Audience{ar.clientID}, - Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)), - ID: jti, - IssuedAt: jwt.NewNumericDate(now), - Issuer: s.serverURL, - NotBefore: jwt.NewNumericDate(now), - Subject: n.User().String(), - }, - Nonce: ar.nonce, - Key: n.Key(), - Addresses: n.Addresses(), - NodeID: n.ID(), - NodeName: n.Name(), - Tailnet: tcd, - UserID: n.User(), - Email: who.UserProfile.LoginName, - UserName: userName, - } - if ar.localRP { - tsClaims.Issuer = s.loopbackURL - } - - // Create an OIDC token using this issuer's signer. - token, err := jwt.Signed(signer).Claims(tsClaims).CompactSerialize() - if err != nil { - log.Printf("Error getting token: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - at := rands.HexString(32) - s.mu.Lock() - ar.validTill = now.Add(5 * time.Minute) - mak.Set(&s.accessToken, at, ar) - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(oidcTokenResponse{ - AccessToken: at, - TokenType: "Bearer", - ExpiresIn: 5 * 60, - IDToken: token, - }); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -type oidcTokenResponse struct { - IDToken string `json:"id_token"` - TokenType string `json:"token_type"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` -} - -const ( - oidcJWKSPath = "/.well-known/jwks.json" - oidcConfigPath = "/.well-known/openid-configuration" -) - -func (s *idpServer) oidcSigner() (jose.Signer, error) { - return s.lazySigner.GetErr(func() (jose.Signer, error) { - sk, err := s.oidcPrivateKey() - if err != nil { - return nil, err - } - return jose.NewSigner(jose.SigningKey{ - Algorithm: jose.RS256, - Key: sk.k, - }, &jose.SignerOptions{EmbedJWK: false, ExtraHeaders: map[jose.HeaderKey]any{ - jose.HeaderType: "JWT", - "kid": fmt.Sprint(sk.kid), - }}) - }) -} - -func (s *idpServer) oidcPrivateKey() (*signingKey, error) { - return s.lazySigningKey.GetErr(func() (*signingKey, error) { - var sk signingKey - b, err := os.ReadFile("oidc-key.json") - if err == nil { - if err := sk.UnmarshalJSON(b); err == nil { - return &sk, nil - } else { - log.Printf("Error unmarshaling key: %v", err) - } - } - id, k := mustGenRSAKey(2048) - sk.k = k - sk.kid = id - b, err = sk.MarshalJSON() - if err != nil { - log.Fatalf("Error marshaling key: %v", err) - } - if err := os.WriteFile("oidc-key.json", b, 0600); err != nil { - log.Fatalf("Error writing key: %v", err) - } - return &sk, nil - }) -} - -func (s *idpServer) serveJWKS(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != oidcJWKSPath { - http.Error(w, "tsidp: not found", http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - sk, err := s.oidcPrivateKey() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - // TODO(maisem): maybe only marshal this once and reuse? - // TODO(maisem): implement key rotation. - je := json.NewEncoder(w) - je.SetIndent("", " ") - if err := je.Encode(jose.JSONWebKeySet{ - Keys: []jose.JSONWebKey{ - { - Key: sk.k.Public(), - Algorithm: string(jose.RS256), - Use: "sig", - KeyID: fmt.Sprint(sk.kid), - }, - }, - }); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return -} - -// openIDProviderMetadata is a partial representation of -// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata. -type openIDProviderMetadata struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` - TokenEndpoint string `json:"token_endpoint,omitempty"` - UserInfoEndpoint string `json:"userinfo_endpoint,omitempty"` - JWKS_URI string `json:"jwks_uri"` - ScopesSupported views.Slice[string] `json:"scopes_supported"` - ResponseTypesSupported views.Slice[string] `json:"response_types_supported"` - SubjectTypesSupported views.Slice[string] `json:"subject_types_supported"` - ClaimsSupported views.Slice[string] `json:"claims_supported"` - IDTokenSigningAlgValuesSupported views.Slice[string] `json:"id_token_signing_alg_values_supported"` - // TODO(maisem): maybe add other fields? - // Currently we fill out the REQUIRED fields, scopes_supported and claims_supported. -} - -type tailscaleClaims struct { - jwt.Claims `json:",inline"` - Nonce string `json:"nonce,omitempty"` // the nonce from the request - Key key.NodePublic `json:"key"` // the node public key - Addresses views.Slice[netip.Prefix] `json:"addresses"` // the Tailscale IPs of the node - NodeID tailcfg.NodeID `json:"nid"` // the stable node ID - NodeName string `json:"node"` // name of the node - Tailnet string `json:"tailnet"` // tailnet (like tail-scale.ts.net) - - // Email is the "emailish" value with an '@' sign. It might not be a valid email. - Email string `json:"email,omitempty"` // user emailish (like "alice@github" or "bob@example.com") - UserID tailcfg.UserID `json:"uid,omitempty"` - - // UserName is the local part of Email (without '@' and domain). - // It is a temporary (2023-11-15) hack during development. - // We should probably let this be configured via grants. - UserName string `json:"username,omitempty"` -} - -var ( - openIDSupportedClaims = views.SliceOf([]string{ - // Standard claims, these correspond to fields in jwt.Claims. - "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "username", "email", - - // Tailscale claims, these correspond to fields in tailscaleClaims. - "key", "addresses", "nid", "node", "tailnet", "tags", "user", "uid", - }) - - // As defined in the OpenID spec this should be "openid". - openIDSupportedScopes = views.SliceOf([]string{"openid", "email", "profile"}) - - // We only support getting the id_token. - openIDSupportedReponseTypes = views.SliceOf([]string{"id_token", "code"}) - - // The type of the "sub" field in the JWT, which means it is globally unique identifier. - // The other option is "pairwise", which means the identifier is different per receiving 3p. - openIDSupportedSubjectTypes = views.SliceOf([]string{"public"}) - - // The algo used for signing. The OpenID spec says "The algorithm RS256 MUST be included." - // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - openIDSupportedSigningAlgos = views.SliceOf([]string{string(jose.RS256)}) -) - -func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != oidcConfigPath { - http.Error(w, "tsidp: not found", http.StatusNotFound) - return - } - ap, err := netip.ParseAddrPort(r.RemoteAddr) - if err != nil { - log.Printf("Error parsing remote addr: %v", err) - return - } - var authorizeEndpoint string - rpEndpoint := s.serverURL - if isFunnelRequest(r) { - authorizeEndpoint = fmt.Sprintf("%s/authorize/funnel", s.serverURL) - } else if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil { - authorizeEndpoint = fmt.Sprintf("%s/authorize/%d", s.serverURL, who.Node.ID) - } else if ap.Addr().IsLoopback() { - rpEndpoint = s.loopbackURL - authorizeEndpoint = fmt.Sprintf("%s/authorize/localhost", s.serverURL) - } else { - log.Printf("Error getting WhoIs: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - je := json.NewEncoder(w) - je.SetIndent("", " ") - if err := je.Encode(openIDProviderMetadata{ - AuthorizationEndpoint: authorizeEndpoint, - Issuer: rpEndpoint, - JWKS_URI: rpEndpoint + oidcJWKSPath, - UserInfoEndpoint: rpEndpoint + "/userinfo", - TokenEndpoint: rpEndpoint + "/token", - ScopesSupported: openIDSupportedScopes, - ResponseTypesSupported: openIDSupportedReponseTypes, - SubjectTypesSupported: openIDSupportedSubjectTypes, - ClaimsSupported: openIDSupportedClaims, - IDTokenSigningAlgValuesSupported: openIDSupportedSigningAlgos, - }); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -// funnelClient represents an OIDC client/relying party that is accessing the -// IDP over Funnel. -type funnelClient struct { - ID string `json:"client_id"` - Secret string `json:"client_secret,omitempty"` - Name string `json:"name,omitempty"` - RedirectURI string `json:"redirect_uri"` -} - -// /clients is a privileged endpoint that allows the visitor to create new -// Funnel-capable OIDC clients, so it is only accessible over the tailnet. -func (s *idpServer) serveClients(w http.ResponseWriter, r *http.Request) { - if isFunnelRequest(r) { - http.Error(w, "tsidp: not found", http.StatusNotFound) - return - } - - path := strings.TrimPrefix(r.URL.Path, "/clients/") - - if path == "new" { - s.serveNewClient(w, r) - return - } - - if path == "" { - s.serveGetClientsList(w, r) - return - } - - s.mu.Lock() - c, ok := s.funnelClients[path] - s.mu.Unlock() - if !ok { - http.Error(w, "tsidp: not found", http.StatusNotFound) - return - } - - switch r.Method { - case "DELETE": - s.serveDeleteClient(w, r, path) - case "GET": - json.NewEncoder(w).Encode(&funnelClient{ - ID: c.ID, - Name: c.Name, - Secret: "", - RedirectURI: c.RedirectURI, - }) - default: - http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) - } -} - -func (s *idpServer) serveNewClient(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) - return - } - redirectURI := r.FormValue("redirect_uri") - if redirectURI == "" { - http.Error(w, "tsidp: must provide redirect_uri", http.StatusBadRequest) - return - } - clientID := rands.HexString(32) - clientSecret := rands.HexString(64) - newClient := funnelClient{ - ID: clientID, - Secret: clientSecret, - Name: r.FormValue("name"), - RedirectURI: redirectURI, - } - s.mu.Lock() - defer s.mu.Unlock() - mak.Set(&s.funnelClients, clientID, &newClient) - if err := s.storeFunnelClientsLocked(); err != nil { - log.Printf("could not write funnel clients db: %v", err) - http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError) - // delete the new client to avoid inconsistent state between memory - // and disk - delete(s.funnelClients, clientID) - return - } - json.NewEncoder(w).Encode(newClient) -} - -func (s *idpServer) serveGetClientsList(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) - return - } - s.mu.Lock() - redactedClients := make([]funnelClient, 0, len(s.funnelClients)) - for _, c := range s.funnelClients { - redactedClients = append(redactedClients, funnelClient{ - ID: c.ID, - Name: c.Name, - Secret: "", - RedirectURI: c.RedirectURI, - }) - } - s.mu.Unlock() - json.NewEncoder(w).Encode(redactedClients) -} - -func (s *idpServer) serveDeleteClient(w http.ResponseWriter, r *http.Request, clientID string) { - if r.Method != "DELETE" { - http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) - return - } - s.mu.Lock() - defer s.mu.Unlock() - if s.funnelClients == nil { - http.Error(w, "tsidp: client not found", http.StatusNotFound) - return - } - if _, ok := s.funnelClients[clientID]; !ok { - http.Error(w, "tsidp: client not found", http.StatusNotFound) - return - } - deleted := s.funnelClients[clientID] - delete(s.funnelClients, clientID) - if err := s.storeFunnelClientsLocked(); err != nil { - log.Printf("could not write funnel clients db: %v", err) - http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError) - // restore the deleted value to avoid inconsistent state between memory - // and disk - s.funnelClients[clientID] = deleted - return - } - w.WriteHeader(http.StatusNoContent) -} - -// storeFunnelClientsLocked writes the current mapping of OIDC client ID/secret -// pairs for RPs that access the IDP over funnel. s.mu must be held while -// calling this. -func (s *idpServer) storeFunnelClientsLocked() error { - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(s.funnelClients); err != nil { - return err - } - return os.WriteFile(funnelClientsFile, buf.Bytes(), 0600) -} - -const ( - minimumRSAKeySize = 2048 -) - -// mustGenRSAKey generates a new RSA key with the provided number of bits. It -// panics on failure. bits must be at least minimumRSAKeySizeBytes * 8. -func mustGenRSAKey(bits int) (kid uint64, k *rsa.PrivateKey) { - if bits < minimumRSAKeySize { - panic("request to generate a too-small RSA key") - } - kid = must.Get(readUint64(crand.Reader)) - k = must.Get(rsa.GenerateKey(crand.Reader, bits)) - return -} - -// readUint64 reads from r until 8 bytes represent a non-zero uint64. -func readUint64(r io.Reader) (uint64, error) { - for { - var b [8]byte - if _, err := io.ReadFull(r, b[:]); err != nil { - return 0, err - } - if v := binary.BigEndian.Uint64(b[:]); v != 0 { - return v, nil - } - } -} - -// rsaPrivateKeyJSONWrapper is the the JSON serialization -// format used by RSAPrivateKey. -type rsaPrivateKeyJSONWrapper struct { - Key string - ID uint64 -} - -type signingKey struct { - k *rsa.PrivateKey - kid uint64 -} - -func (sk *signingKey) MarshalJSON() ([]byte, error) { - b := pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(sk.k), - } - bts := pem.EncodeToMemory(&b) - return json.Marshal(rsaPrivateKeyJSONWrapper{ - Key: base64.URLEncoding.EncodeToString(bts), - ID: sk.kid, - }) -} - -func (sk *signingKey) UnmarshalJSON(b []byte) error { - var wrapper rsaPrivateKeyJSONWrapper - if err := json.Unmarshal(b, &wrapper); err != nil { - return err - } - if len(wrapper.Key) == 0 { - return nil - } - b64dec, err := base64.URLEncoding.DecodeString(wrapper.Key) - if err != nil { - return err - } - blk, _ := pem.Decode(b64dec) - k, err := x509.ParsePKCS1PrivateKey(blk.Bytes) - if err != nil { - return err - } - sk.k = k - sk.kid = wrapper.ID - return nil -} - -// parseID takes a string input and returns a typed IntID T and true, or a zero -// value and false if the input is unhandled syntax or out of a valid range. -func parseID[T ~int64](input string) (_ T, ok bool) { - if input == "" { - return 0, false - } - i, err := strconv.ParseInt(input, 10, 64) - if err != nil { - return 0, false - } - if i < 0 { - return 0, false - } - return T(i), true -} - -// isFunnelRequest checks if an HTTP request is coming over Tailscale Funnel. -func isFunnelRequest(r *http.Request) bool { - // If we're funneling through the local tailscaled, it will set this HTTP - // header. - if r.Header.Get("Tailscale-Funnel-Request") != "" { - return true - } - - // If the funneled connection is from tsnet, then the net.Conn will be of - // type ipn.FunnelConn. - netConn := r.Context().Value(ctxConn{}) - // if the conn is wrapped inside TLS, unwrap it - if tlsConn, ok := netConn.(*tls.Conn); ok { - netConn = tlsConn.NetConn() - } - if _, ok := netConn.(*ipn.FunnelConn); ok { - return true - } - return false -} diff --git a/cmd/tsshd/tsshd.go b/cmd/tsshd/tsshd.go deleted file mode 100644 index 950eb661cdb23..0000000000000 --- a/cmd/tsshd/tsshd.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ignore - -// The tsshd binary was an experimental SSH server that accepts connections -// from anybody on the same Tailscale network. -// -// Its functionality moved into tailscaled. -// -// See https://github.com/tailscale/tailscale/issues/3802 -package main diff --git a/cmd/tta/fw_linux.go b/cmd/tta/fw_linux.go deleted file mode 100644 index a4ceabad8bc05..0000000000000 --- a/cmd/tta/fw_linux.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "encoding/binary" - - "github.com/google/nftables" - "github.com/google/nftables/expr" - "tailscale.com/types/ptr" -) - -func init() { - addFirewall = addFirewallLinux -} - -func addFirewallLinux() error { - c, err := nftables.New() - if err != nil { - return err - } - - // Create a new table - table := &nftables.Table{ - Family: nftables.TableFamilyIPv4, // TableFamilyINet doesn't work (why?. oh well.) - Name: "filter", - } - c.AddTable(table) - - // Create a new chain for incoming traffic - inputChain := &nftables.Chain{ - Name: "input", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - Policy: ptr.To(nftables.ChainPolicyDrop), - } - c.AddChain(inputChain) - - // Allow traffic from the loopback interface - c.AddRule(&nftables.Rule{ - Table: table, - Chain: inputChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte("lo"), - }, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - }, - }) - - // Accept established and related connections - c.AddRule(&nftables.Rule{ - Table: table, - Chain: inputChain, - Exprs: []expr.Any{ - &expr.Ct{ - Register: 1, - Key: expr.CtKeySTATE, - }, - &expr.Bitwise{ - SourceRegister: 1, - DestRegister: 1, - Len: 4, - Mask: binary.NativeEndian.AppendUint32(nil, 0x06), // CT_STATE_BIT_ESTABLISHED | CT_STATE_BIT_RELATED - Xor: binary.NativeEndian.AppendUint32(nil, 0), - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: binary.NativeEndian.AppendUint32(nil, 0x00), - }, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - }, - }) - - // Allow TCP packets in that don't have the SYN bit set, even if they're not - // ESTABLISHED or RELATED. This is because the test suite gets TCP - // connections up & idle (for HTTP) before it conditionally installs these - // firewall rules. But because conntrack wasn't previously active, existing - // TCP flows aren't ESTABLISHED and get dropped. So this rule allows - // previously established TCP connections that predates the firewall rules - // to continue working, as they don't have conntrack state. - c.AddRule(&nftables.Rule{ - Table: table, - Chain: inputChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{0x06}, // TCP - }, - &expr.Payload{ // get TCP flags - DestRegister: 1, - Base: 2, - Offset: 13, // flags - Len: 1, - }, - &expr.Bitwise{ - SourceRegister: 1, - DestRegister: 1, - Len: 1, - Mask: []byte{2}, // TCP_SYN - Xor: []byte{0}, - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: []byte{2}, // TCP_SYN - }, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - }, - }) - - return c.Flush() -} diff --git a/cmd/tta/tta.go b/cmd/tta/tta.go deleted file mode 100644 index 4a4c4a6beebfa..0000000000000 --- a/cmd/tta/tta.go +++ /dev/null @@ -1,349 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The tta server is the Tailscale Test Agent. -// -// It runs on each Tailscale node being integration tested and permits the test -// harness to control the node. It connects out to the test drver (rather than -// accepting any TCP connections inbound, which might be blocked depending on -// the scenario being tested) and then the test driver turns the TCP connection -// around and sends request back. -package main - -import ( - "bytes" - "context" - "errors" - "flag" - "io" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "os" - "os/exec" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "tailscale.com/atomicfile" - "tailscale.com/client/tailscale" - "tailscale.com/hostinfo" - "tailscale.com/util/mak" - "tailscale.com/util/must" - "tailscale.com/util/set" - "tailscale.com/version/distro" -) - -var ( - driverAddr = flag.String("driver", "test-driver.tailscale:8008", "address of the test driver; by default we use the DNS name test-driver.tailscale which is special cased in the emulated network's DNS server") -) - -func absify(cmd string) string { - if distro.Get() == distro.Gokrazy && !strings.Contains(cmd, "/") { - return "/user/" + cmd - } - return cmd -} - -func serveCmd(w http.ResponseWriter, cmd string, args ...string) { - log.Printf("Got serveCmd for %q %v", cmd, args) - out, err := exec.Command(absify(cmd), args...).CombinedOutput() - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - if err != nil { - w.Header().Set("Exec-Err", err.Error()) - w.WriteHeader(500) - log.Printf("Err on serveCmd for %q %v, %d bytes of output: %v", cmd, args, len(out), err) - } else { - log.Printf("Did serveCmd for %q %v, %d bytes of output", cmd, args, len(out)) - } - w.Write(out) -} - -type localClientRoundTripper struct { - lc tailscale.LocalClient -} - -func (rt *localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - req.RequestURI = "" - return rt.lc.DoLocalRequest(req) -} - -func main() { - var logBuf logBuffer - log.SetOutput(io.MultiWriter(os.Stderr, &logBuf)) - - if distro.Get() == distro.Gokrazy { - if !hostinfo.IsNATLabGuestVM() { - // "Exiting immediately with status code 0 when the - // GOKRAZY_FIRST_START=1 environment variable is set means “don’t - // start the program on boot”" - return - } - } - flag.Parse() - - debug := false - if distro.Get() == distro.Gokrazy { - cmdLine, _ := os.ReadFile("/proc/cmdline") - explicitNS := false - for _, s := range strings.Fields(string(cmdLine)) { - if ns, ok := strings.CutPrefix(s, "tta.nameserver="); ok { - err := atomicfile.WriteFile("/tmp/resolv.conf", []byte("nameserver "+ns+"\n"), 0644) - log.Printf("Wrote /tmp/resolv.conf: %v", err) - explicitNS = true - continue - } - if v, ok := strings.CutPrefix(s, "tta.debug="); ok { - debug, _ = strconv.ParseBool(v) - continue - } - } - if !explicitNS { - nsRx := regexp.MustCompile(`(?m)^nameserver (.*)`) - for t := time.Now(); time.Since(t) < 10*time.Second; time.Sleep(10 * time.Millisecond) { - all, _ := os.ReadFile("/etc/resolv.conf") - if nsRx.Match(all) { - break - } - } - } - } - - log.Printf("Tailscale Test Agent running.") - - gokRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://gokrazy"))) - gokRP.Transport = &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - if network != "tcp" { - return nil, errors.New("unexpected network") - } - if addr != "gokrazy:80" { - return nil, errors.New("unexpected addr") - } - var d net.Dialer - return d.DialContext(ctx, "unix", "/run/gokrazy-http.sock") - }, - } - - var ttaMux http.ServeMux // agent mux - var serveMux http.ServeMux - serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-TTA-GoKrazy") == "1" { - gokRP.ServeHTTP(w, r) - return - } - ttaMux.ServeHTTP(w, r) - }) - var hs http.Server - hs.Handler = &serveMux - revSt := revDialState{ - needConnCh: make(chan bool, 1), - debug: debug, - } - hs.ConnState = revSt.connState - conns := make(chan net.Conn, 1) - - lcRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://local-tailscaled.sock"))) - lcRP.Transport = new(localClientRoundTripper) - ttaMux.HandleFunc("/localapi/", func(w http.ResponseWriter, r *http.Request) { - log.Printf("Got localapi request: %v", r.URL) - t0 := time.Now() - lcRP.ServeHTTP(w, r) - log.Printf("Did localapi request in %v: %v", time.Since(t0).Round(time.Millisecond), r.URL) - }) - - ttaMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - io.WriteString(w, "TTA\n") - return - }) - ttaMux.HandleFunc("/up", func(w http.ResponseWriter, r *http.Request) { - serveCmd(w, "tailscale", "up", "--login-server=http://control.tailscale") - }) - ttaMux.HandleFunc("/fw", addFirewallHandler) - ttaMux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - logBuf.mu.Lock() - defer logBuf.mu.Unlock() - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write(logBuf.buf.Bytes()) - }) - go hs.Serve(chanListener(conns)) - - // For doing agent operations locally from gokrazy: - // (e.g. with "wget -O - localhost:8123/fw" or "wget -O - localhost:8123/logs" - // to get early tta logs before the port 124 connection is established) - go func() { - err := http.ListenAndServe("127.0.0.1:8123", &ttaMux) - if err != nil { - log.Fatalf("ListenAndServe: %v", err) - } - }() - - revSt.runDialOutLoop(conns) -} - -func connect() (net.Conn, error) { - var d net.Dialer - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - c, err := d.DialContext(ctx, "tcp", *driverAddr) - if err != nil { - return nil, err - } - return c, nil -} - -type chanListener <-chan net.Conn - -func (cl chanListener) Accept() (net.Conn, error) { - c, ok := <-cl - if !ok { - return nil, errors.New("closed") - } - return c, nil -} - -func (cl chanListener) Close() error { - return nil -} - -func (cl chanListener) Addr() net.Addr { - return &net.TCPAddr{ - IP: net.ParseIP("52.0.0.34"), // TS..DR(iver) - Port: 123, - } -} - -type revDialState struct { - needConnCh chan bool - debug bool - - mu sync.Mutex - newSet set.Set[net.Conn] // conns in StateNew - onNew map[net.Conn]func() -} - -func (s *revDialState) connState(c net.Conn, cs http.ConnState) { - s.mu.Lock() - defer s.mu.Unlock() - oldLen := len(s.newSet) - switch cs { - case http.StateNew: - if f, ok := s.onNew[c]; ok { - f() - delete(s.onNew, c) - } - s.newSet.Make() - s.newSet.Add(c) - default: - s.newSet.Delete(c) - } - s.vlogf("ConnState: %p now %v; newSet %v=>%v", c, s, oldLen, len(s.newSet)) - if len(s.newSet) < 2 { - select { - case s.needConnCh <- true: - default: - } - } -} - -func (s *revDialState) waitNeedConnect() { - for { - s.mu.Lock() - need := len(s.newSet) < 2 - s.mu.Unlock() - if need { - return - } - <-s.needConnCh - } -} - -func (s *revDialState) vlogf(format string, arg ...any) { - if !s.debug { - return - } - log.Printf(format, arg...) -} - -func (s *revDialState) runDialOutLoop(conns chan<- net.Conn) { - var lastErr string - connected := false - - for { - s.vlogf("[dial-driver] waiting need connect...") - s.waitNeedConnect() - s.vlogf("[dial-driver] connecting...") - t0 := time.Now() - c, err := connect() - if err != nil { - s := err.Error() - if s != lastErr { - log.Printf("[dial-driver] connect failure: %v", s) - } - lastErr = s - time.Sleep(time.Second) - continue - } - if !connected { - connected = true - log.Printf("Connected to %v", *driverAddr) - } - s.vlogf("[dial-driver] connected %v => %v after %v", c.LocalAddr(), c.RemoteAddr(), time.Since(t0)) - - inHTTP := make(chan struct{}) - s.mu.Lock() - mak.Set(&s.onNew, c, func() { close(inHTTP) }) - s.mu.Unlock() - - s.vlogf("[dial-driver] sending...") - conns <- c - s.vlogf("[dial-driver] sent; waiting") - select { - case <-inHTTP: - s.vlogf("[dial-driver] conn in HTTP") - case <-time.After(2 * time.Second): - s.vlogf("[dial-driver] timeout waiting for conn to be accepted into HTTP") - } - } -} - -func addFirewallHandler(w http.ResponseWriter, r *http.Request) { - if addFirewall == nil { - http.Error(w, "firewall not supported", 500) - return - } - err := addFirewall() - if err != nil { - http.Error(w, err.Error(), 500) - return - } - io.WriteString(w, "OK\n") -} - -var addFirewall func() error // set by fw_linux.go - -// logBuffer is a bytes.Buffer that is safe for concurrent use -// intended to capture early logs from the process, even if -// gokrazy's syslog streaming isn't working or yet working. -// It only captures the first 1MB of logs, as that's considered -// plenty for early debugging. At runtime, it's assumed that -// syslog log streaming is working. -type logBuffer struct { - mu sync.Mutex - buf bytes.Buffer -} - -func (lb *logBuffer) Write(p []byte) (n int, err error) { - lb.mu.Lock() - defer lb.mu.Unlock() - const maxSize = 1 << 20 // more than plenty; see type comment - if lb.buf.Len() > maxSize { - return len(p), nil - } - return lb.buf.Write(p) -} diff --git a/cmd/viewer/tests/tests.go b/cmd/viewer/tests/tests.go deleted file mode 100644 index 14a4888615bc1..0000000000000 --- a/cmd/viewer/tests/tests.go +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package tests serves a list of tests for tailscale.com/cmd/viewer. -package tests - -import ( - "fmt" - "net/netip" - - "golang.org/x/exp/constraints" - "tailscale.com/types/ptr" - "tailscale.com/types/views" -) - -//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers,StructWithTypeAliasFields,GenericTypeAliasStruct --clone-only-type=OnlyGetClone - -type StructWithoutPtrs struct { - Int int - Pfx netip.Prefix -} - -type Map struct { - Int map[string]int - SliceInt map[string][]int - StructPtrWithPtr map[string]*StructWithPtrs - StructPtrWithoutPtr map[string]*StructWithoutPtrs - StructWithoutPtr map[string]StructWithoutPtrs - SlicesWithPtrs map[string][]*StructWithPtrs - SlicesWithoutPtrs map[string][]*StructWithoutPtrs - StructWithoutPtrKey map[StructWithoutPtrs]int `json:"-"` - StructWithPtr map[string]StructWithPtrs - - // Unsupported views. - SliceIntPtr map[string][]*int - PointerKey map[*string]int `json:"-"` - StructWithPtrKey map[StructWithPtrs]int `json:"-"` -} - -type StructWithPtrs struct { - Value *StructWithoutPtrs - Int *int - - NoCloneValue *StructWithoutPtrs `codegen:"noclone"` -} - -func (v *StructWithPtrs) String() string { return fmt.Sprintf("%v", v.Int) } - -func (v *StructWithPtrs) Equal(v2 *StructWithPtrs) bool { - return v.Value == v2.Value -} - -type StructWithSlices struct { - Values []StructWithoutPtrs - ValuePointers []*StructWithoutPtrs - StructPointers []*StructWithPtrs - - Slice []string - Prefixes []netip.Prefix - Data []byte - - // Unsupported views. - Structs []StructWithPtrs - Ints []*int -} - -type OnlyGetClone struct { - SinViewerPorFavor bool -} - -type StructWithEmbedded struct { - A *StructWithPtrs - StructWithSlices -} - -type GenericIntStruct[T constraints.Integer] struct { - Value T - Pointer *T - Slice []T - Map map[string]T - - // Unsupported views. - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T -} - -type BasicType interface { - ~bool | constraints.Integer | constraints.Float | constraints.Complex | ~string -} - -type GenericNoPtrsStruct[T StructWithoutPtrs | netip.Prefix | BasicType] struct { - Value T - Pointer *T - Slice []T - Map map[string]T - - // Unsupported views. - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T -} - -type GenericCloneableStruct[T views.ViewCloner[T, V], V views.StructView[T]] struct { - Value T - Slice []T - Map map[string]T - - // Unsupported views. - Pointer *T - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T -} - -// Container is a pre-defined container type, such as a collection, an optional -// value or a generic wrapper. -type Container[T any] struct { - Item T -} - -func (c *Container[T]) Clone() *Container[T] { - if c == nil { - return nil - } - if cloner, ok := any(c.Item).(views.Cloner[T]); ok { - return &Container[T]{cloner.Clone()} - } - if !views.ContainsPointers[T]() { - return ptr.To(*c) - } - panic(fmt.Errorf("%T contains pointers, but is not cloneable", c.Item)) -} - -// ContainerView is a pre-defined readonly view of a Container[T]. -type ContainerView[T views.ViewCloner[T, V], V views.StructView[T]] struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *Container[T] -} - -func (cv ContainerView[T, V]) Item() V { - return cv.ж.Item.View() -} - -func ContainerViewOf[T views.ViewCloner[T, V], V views.StructView[T]](c *Container[T]) ContainerView[T, V] { - return ContainerView[T, V]{c} -} - -// MapContainer is a predefined map-like container type. -// Unlike [Container], it has two type parameters, where the value -// is the second parameter. -type MapContainer[K comparable, V views.Cloner[V]] struct { - Items map[K]V -} - -func (c *MapContainer[K, V]) Clone() *MapContainer[K, V] { - if c == nil { - return nil - } - var m map[K]V - if c.Items != nil { - m = make(map[K]V, len(c.Items)) - for i := range m { - m[i] = c.Items[i].Clone() - } - } - return &MapContainer[K, V]{m} -} - -// MapContainerView is a pre-defined readonly view of a [MapContainer][K, T]. -type MapContainerView[K comparable, T views.ViewCloner[T, V], V views.StructView[T]] struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *MapContainer[K, T] -} - -func (cv MapContainerView[K, T, V]) Items() views.MapFn[K, T, V] { - return views.MapFnOf(cv.ж.Items, func(t T) V { return t.View() }) -} - -func MapContainerViewOf[K comparable, T views.ViewCloner[T, V], V views.StructView[T]](c *MapContainer[K, T]) MapContainerView[K, T, V] { - return MapContainerView[K, T, V]{c} -} - -type GenericBasicStruct[T BasicType] struct { - Value T -} - -type StructWithContainers struct { - IntContainer Container[int] - CloneableContainer Container[*StructWithPtrs] - BasicGenericContainer Container[GenericBasicStruct[int]] - CloneableGenericContainer Container[*GenericNoPtrsStruct[int]] - CloneableMap MapContainer[int, *StructWithPtrs] - CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]] -} - -type ( - StructWithPtrsAlias = StructWithPtrs - StructWithoutPtrsAlias = StructWithoutPtrs - StructWithPtrsAliasView = StructWithPtrsView - StructWithoutPtrsAliasView = StructWithoutPtrsView -) - -type StructWithTypeAliasFields struct { - WithPtr StructWithPtrsAlias - WithoutPtr StructWithoutPtrsAlias - - WithPtrByPtr *StructWithPtrsAlias - WithoutPtrByPtr *StructWithoutPtrsAlias - - SliceWithPtrs []*StructWithPtrsAlias - SliceWithoutPtrs []*StructWithoutPtrsAlias - - MapWithPtrs map[string]*StructWithPtrsAlias - MapWithoutPtrs map[string]*StructWithoutPtrsAlias - - MapOfSlicesWithPtrs map[string][]*StructWithPtrsAlias - MapOfSlicesWithoutPtrs map[string][]*StructWithoutPtrsAlias -} - -type integer = constraints.Integer - -type GenericTypeAliasStruct[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]] struct { - NonCloneable T - Cloneable T2 -} diff --git a/cmd/viewer/tests/tests_clone.go b/cmd/viewer/tests/tests_clone.go deleted file mode 100644 index 9131f5040c45d..0000000000000 --- a/cmd/viewer/tests/tests_clone.go +++ /dev/null @@ -1,545 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT. - -package tests - -import ( - "maps" - "net/netip" - - "golang.org/x/exp/constraints" - "tailscale.com/types/ptr" - "tailscale.com/types/views" -) - -// Clone makes a deep copy of StructWithPtrs. -// The result aliases no memory with the original. -func (src *StructWithPtrs) Clone() *StructWithPtrs { - if src == nil { - return nil - } - dst := new(StructWithPtrs) - *dst = *src - if dst.Value != nil { - dst.Value = ptr.To(*src.Value) - } - if dst.Int != nil { - dst.Int = ptr.To(*src.Int) - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithPtrsCloneNeedsRegeneration = StructWithPtrs(struct { - Value *StructWithoutPtrs - Int *int - NoCloneValue *StructWithoutPtrs -}{}) - -// Clone makes a deep copy of StructWithoutPtrs. -// The result aliases no memory with the original. -func (src *StructWithoutPtrs) Clone() *StructWithoutPtrs { - if src == nil { - return nil - } - dst := new(StructWithoutPtrs) - *dst = *src - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithoutPtrsCloneNeedsRegeneration = StructWithoutPtrs(struct { - Int int - Pfx netip.Prefix -}{}) - -// Clone makes a deep copy of Map. -// The result aliases no memory with the original. -func (src *Map) Clone() *Map { - if src == nil { - return nil - } - dst := new(Map) - *dst = *src - dst.Int = maps.Clone(src.Int) - if dst.SliceInt != nil { - dst.SliceInt = map[string][]int{} - for k := range src.SliceInt { - dst.SliceInt[k] = append([]int{}, src.SliceInt[k]...) - } - } - if dst.StructPtrWithPtr != nil { - dst.StructPtrWithPtr = map[string]*StructWithPtrs{} - for k, v := range src.StructPtrWithPtr { - if v == nil { - dst.StructPtrWithPtr[k] = nil - } else { - dst.StructPtrWithPtr[k] = v.Clone() - } - } - } - if dst.StructPtrWithoutPtr != nil { - dst.StructPtrWithoutPtr = map[string]*StructWithoutPtrs{} - for k, v := range src.StructPtrWithoutPtr { - if v == nil { - dst.StructPtrWithoutPtr[k] = nil - } else { - dst.StructPtrWithoutPtr[k] = ptr.To(*v) - } - } - } - dst.StructWithoutPtr = maps.Clone(src.StructWithoutPtr) - if dst.SlicesWithPtrs != nil { - dst.SlicesWithPtrs = map[string][]*StructWithPtrs{} - for k := range src.SlicesWithPtrs { - dst.SlicesWithPtrs[k] = append([]*StructWithPtrs{}, src.SlicesWithPtrs[k]...) - } - } - if dst.SlicesWithoutPtrs != nil { - dst.SlicesWithoutPtrs = map[string][]*StructWithoutPtrs{} - for k := range src.SlicesWithoutPtrs { - dst.SlicesWithoutPtrs[k] = append([]*StructWithoutPtrs{}, src.SlicesWithoutPtrs[k]...) - } - } - dst.StructWithoutPtrKey = maps.Clone(src.StructWithoutPtrKey) - if dst.StructWithPtr != nil { - dst.StructWithPtr = map[string]StructWithPtrs{} - for k, v := range src.StructWithPtr { - dst.StructWithPtr[k] = *(v.Clone()) - } - } - if dst.SliceIntPtr != nil { - dst.SliceIntPtr = map[string][]*int{} - for k := range src.SliceIntPtr { - dst.SliceIntPtr[k] = append([]*int{}, src.SliceIntPtr[k]...) - } - } - dst.PointerKey = maps.Clone(src.PointerKey) - dst.StructWithPtrKey = maps.Clone(src.StructWithPtrKey) - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _MapCloneNeedsRegeneration = Map(struct { - Int map[string]int - SliceInt map[string][]int - StructPtrWithPtr map[string]*StructWithPtrs - StructPtrWithoutPtr map[string]*StructWithoutPtrs - StructWithoutPtr map[string]StructWithoutPtrs - SlicesWithPtrs map[string][]*StructWithPtrs - SlicesWithoutPtrs map[string][]*StructWithoutPtrs - StructWithoutPtrKey map[StructWithoutPtrs]int - StructWithPtr map[string]StructWithPtrs - SliceIntPtr map[string][]*int - PointerKey map[*string]int - StructWithPtrKey map[StructWithPtrs]int -}{}) - -// Clone makes a deep copy of StructWithSlices. -// The result aliases no memory with the original. -func (src *StructWithSlices) Clone() *StructWithSlices { - if src == nil { - return nil - } - dst := new(StructWithSlices) - *dst = *src - dst.Values = append(src.Values[:0:0], src.Values...) - if src.ValuePointers != nil { - dst.ValuePointers = make([]*StructWithoutPtrs, len(src.ValuePointers)) - for i := range dst.ValuePointers { - if src.ValuePointers[i] == nil { - dst.ValuePointers[i] = nil - } else { - dst.ValuePointers[i] = ptr.To(*src.ValuePointers[i]) - } - } - } - if src.StructPointers != nil { - dst.StructPointers = make([]*StructWithPtrs, len(src.StructPointers)) - for i := range dst.StructPointers { - if src.StructPointers[i] == nil { - dst.StructPointers[i] = nil - } else { - dst.StructPointers[i] = src.StructPointers[i].Clone() - } - } - } - dst.Slice = append(src.Slice[:0:0], src.Slice...) - dst.Prefixes = append(src.Prefixes[:0:0], src.Prefixes...) - dst.Data = append(src.Data[:0:0], src.Data...) - if src.Structs != nil { - dst.Structs = make([]StructWithPtrs, len(src.Structs)) - for i := range dst.Structs { - dst.Structs[i] = *src.Structs[i].Clone() - } - } - if src.Ints != nil { - dst.Ints = make([]*int, len(src.Ints)) - for i := range dst.Ints { - if src.Ints[i] == nil { - dst.Ints[i] = nil - } else { - dst.Ints[i] = ptr.To(*src.Ints[i]) - } - } - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithSlicesCloneNeedsRegeneration = StructWithSlices(struct { - Values []StructWithoutPtrs - ValuePointers []*StructWithoutPtrs - StructPointers []*StructWithPtrs - Slice []string - Prefixes []netip.Prefix - Data []byte - Structs []StructWithPtrs - Ints []*int -}{}) - -// Clone makes a deep copy of OnlyGetClone. -// The result aliases no memory with the original. -func (src *OnlyGetClone) Clone() *OnlyGetClone { - if src == nil { - return nil - } - dst := new(OnlyGetClone) - *dst = *src - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _OnlyGetCloneCloneNeedsRegeneration = OnlyGetClone(struct { - SinViewerPorFavor bool -}{}) - -// Clone makes a deep copy of StructWithEmbedded. -// The result aliases no memory with the original. -func (src *StructWithEmbedded) Clone() *StructWithEmbedded { - if src == nil { - return nil - } - dst := new(StructWithEmbedded) - *dst = *src - dst.A = src.A.Clone() - dst.StructWithSlices = *src.StructWithSlices.Clone() - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithEmbeddedCloneNeedsRegeneration = StructWithEmbedded(struct { - A *StructWithPtrs - StructWithSlices -}{}) - -// Clone makes a deep copy of GenericIntStruct. -// The result aliases no memory with the original. -func (src *GenericIntStruct[T]) Clone() *GenericIntStruct[T] { - if src == nil { - return nil - } - dst := new(GenericIntStruct[T]) - *dst = *src - if dst.Pointer != nil { - dst.Pointer = ptr.To(*src.Pointer) - } - dst.Slice = append(src.Slice[:0:0], src.Slice...) - dst.Map = maps.Clone(src.Map) - if src.PtrSlice != nil { - dst.PtrSlice = make([]*T, len(src.PtrSlice)) - for i := range dst.PtrSlice { - if src.PtrSlice[i] == nil { - dst.PtrSlice[i] = nil - } else { - dst.PtrSlice[i] = ptr.To(*src.PtrSlice[i]) - } - } - } - dst.PtrKeyMap = maps.Clone(src.PtrKeyMap) - if dst.PtrValueMap != nil { - dst.PtrValueMap = map[string]*T{} - for k, v := range src.PtrValueMap { - if v == nil { - dst.PtrValueMap[k] = nil - } else { - dst.PtrValueMap[k] = ptr.To(*v) - } - } - } - if dst.SliceMap != nil { - dst.SliceMap = map[string][]T{} - for k := range src.SliceMap { - dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...) - } - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericIntStructCloneNeedsRegeneration[T constraints.Integer](GenericIntStruct[T]) { - _GenericIntStructCloneNeedsRegeneration(struct { - Value T - Pointer *T - Slice []T - Map map[string]T - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T - }{}) -} - -// Clone makes a deep copy of GenericNoPtrsStruct. -// The result aliases no memory with the original. -func (src *GenericNoPtrsStruct[T]) Clone() *GenericNoPtrsStruct[T] { - if src == nil { - return nil - } - dst := new(GenericNoPtrsStruct[T]) - *dst = *src - if dst.Pointer != nil { - dst.Pointer = ptr.To(*src.Pointer) - } - dst.Slice = append(src.Slice[:0:0], src.Slice...) - dst.Map = maps.Clone(src.Map) - if src.PtrSlice != nil { - dst.PtrSlice = make([]*T, len(src.PtrSlice)) - for i := range dst.PtrSlice { - if src.PtrSlice[i] == nil { - dst.PtrSlice[i] = nil - } else { - dst.PtrSlice[i] = ptr.To(*src.PtrSlice[i]) - } - } - } - dst.PtrKeyMap = maps.Clone(src.PtrKeyMap) - if dst.PtrValueMap != nil { - dst.PtrValueMap = map[string]*T{} - for k, v := range src.PtrValueMap { - if v == nil { - dst.PtrValueMap[k] = nil - } else { - dst.PtrValueMap[k] = ptr.To(*v) - } - } - } - if dst.SliceMap != nil { - dst.SliceMap = map[string][]T{} - for k := range src.SliceMap { - dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...) - } - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericNoPtrsStructCloneNeedsRegeneration[T StructWithoutPtrs | netip.Prefix | BasicType](GenericNoPtrsStruct[T]) { - _GenericNoPtrsStructCloneNeedsRegeneration(struct { - Value T - Pointer *T - Slice []T - Map map[string]T - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T - }{}) -} - -// Clone makes a deep copy of GenericCloneableStruct. -// The result aliases no memory with the original. -func (src *GenericCloneableStruct[T, V]) Clone() *GenericCloneableStruct[T, V] { - if src == nil { - return nil - } - dst := new(GenericCloneableStruct[T, V]) - *dst = *src - dst.Value = src.Value.Clone() - if src.Slice != nil { - dst.Slice = make([]T, len(src.Slice)) - for i := range dst.Slice { - dst.Slice[i] = src.Slice[i].Clone() - } - } - if dst.Map != nil { - dst.Map = map[string]T{} - for k, v := range src.Map { - dst.Map[k] = v.Clone() - } - } - if dst.Pointer != nil { - dst.Pointer = ptr.To((*src.Pointer).Clone()) - } - if src.PtrSlice != nil { - dst.PtrSlice = make([]*T, len(src.PtrSlice)) - for i := range dst.PtrSlice { - if src.PtrSlice[i] == nil { - dst.PtrSlice[i] = nil - } else { - dst.PtrSlice[i] = ptr.To((*src.PtrSlice[i]).Clone()) - } - } - } - dst.PtrKeyMap = maps.Clone(src.PtrKeyMap) - if dst.PtrValueMap != nil { - dst.PtrValueMap = map[string]*T{} - for k, v := range src.PtrValueMap { - if v == nil { - dst.PtrValueMap[k] = nil - } else { - dst.PtrValueMap[k] = ptr.To((*v).Clone()) - } - } - } - if dst.SliceMap != nil { - dst.SliceMap = map[string][]T{} - for k := range src.SliceMap { - dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...) - } - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericCloneableStructCloneNeedsRegeneration[T views.ViewCloner[T, V], V views.StructView[T]](GenericCloneableStruct[T, V]) { - _GenericCloneableStructCloneNeedsRegeneration(struct { - Value T - Slice []T - Map map[string]T - Pointer *T - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T - }{}) -} - -// Clone makes a deep copy of StructWithContainers. -// The result aliases no memory with the original. -func (src *StructWithContainers) Clone() *StructWithContainers { - if src == nil { - return nil - } - dst := new(StructWithContainers) - *dst = *src - dst.CloneableContainer = *src.CloneableContainer.Clone() - dst.CloneableGenericContainer = *src.CloneableGenericContainer.Clone() - dst.CloneableMap = *src.CloneableMap.Clone() - dst.CloneableGenericMap = *src.CloneableGenericMap.Clone() - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithContainersCloneNeedsRegeneration = StructWithContainers(struct { - IntContainer Container[int] - CloneableContainer Container[*StructWithPtrs] - BasicGenericContainer Container[GenericBasicStruct[int]] - CloneableGenericContainer Container[*GenericNoPtrsStruct[int]] - CloneableMap MapContainer[int, *StructWithPtrs] - CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]] -}{}) - -// Clone makes a deep copy of StructWithTypeAliasFields. -// The result aliases no memory with the original. -func (src *StructWithTypeAliasFields) Clone() *StructWithTypeAliasFields { - if src == nil { - return nil - } - dst := new(StructWithTypeAliasFields) - *dst = *src - dst.WithPtr = *src.WithPtr.Clone() - dst.WithPtrByPtr = src.WithPtrByPtr.Clone() - if dst.WithoutPtrByPtr != nil { - dst.WithoutPtrByPtr = ptr.To(*src.WithoutPtrByPtr) - } - if src.SliceWithPtrs != nil { - dst.SliceWithPtrs = make([]*StructWithPtrsAlias, len(src.SliceWithPtrs)) - for i := range dst.SliceWithPtrs { - if src.SliceWithPtrs[i] == nil { - dst.SliceWithPtrs[i] = nil - } else { - dst.SliceWithPtrs[i] = src.SliceWithPtrs[i].Clone() - } - } - } - if src.SliceWithoutPtrs != nil { - dst.SliceWithoutPtrs = make([]*StructWithoutPtrsAlias, len(src.SliceWithoutPtrs)) - for i := range dst.SliceWithoutPtrs { - if src.SliceWithoutPtrs[i] == nil { - dst.SliceWithoutPtrs[i] = nil - } else { - dst.SliceWithoutPtrs[i] = ptr.To(*src.SliceWithoutPtrs[i]) - } - } - } - if dst.MapWithPtrs != nil { - dst.MapWithPtrs = map[string]*StructWithPtrsAlias{} - for k, v := range src.MapWithPtrs { - if v == nil { - dst.MapWithPtrs[k] = nil - } else { - dst.MapWithPtrs[k] = v.Clone() - } - } - } - if dst.MapWithoutPtrs != nil { - dst.MapWithoutPtrs = map[string]*StructWithoutPtrsAlias{} - for k, v := range src.MapWithoutPtrs { - if v == nil { - dst.MapWithoutPtrs[k] = nil - } else { - dst.MapWithoutPtrs[k] = ptr.To(*v) - } - } - } - if dst.MapOfSlicesWithPtrs != nil { - dst.MapOfSlicesWithPtrs = map[string][]*StructWithPtrsAlias{} - for k := range src.MapOfSlicesWithPtrs { - dst.MapOfSlicesWithPtrs[k] = append([]*StructWithPtrsAlias{}, src.MapOfSlicesWithPtrs[k]...) - } - } - if dst.MapOfSlicesWithoutPtrs != nil { - dst.MapOfSlicesWithoutPtrs = map[string][]*StructWithoutPtrsAlias{} - for k := range src.MapOfSlicesWithoutPtrs { - dst.MapOfSlicesWithoutPtrs[k] = append([]*StructWithoutPtrsAlias{}, src.MapOfSlicesWithoutPtrs[k]...) - } - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithTypeAliasFieldsCloneNeedsRegeneration = StructWithTypeAliasFields(struct { - WithPtr StructWithPtrsAlias - WithoutPtr StructWithoutPtrsAlias - WithPtrByPtr *StructWithPtrsAlias - WithoutPtrByPtr *StructWithoutPtrsAlias - SliceWithPtrs []*StructWithPtrsAlias - SliceWithoutPtrs []*StructWithoutPtrsAlias - MapWithPtrs map[string]*StructWithPtrsAlias - MapWithoutPtrs map[string]*StructWithoutPtrsAlias - MapOfSlicesWithPtrs map[string][]*StructWithPtrsAlias - MapOfSlicesWithoutPtrs map[string][]*StructWithoutPtrsAlias -}{}) - -// Clone makes a deep copy of GenericTypeAliasStruct. -// The result aliases no memory with the original. -func (src *GenericTypeAliasStruct[T, T2, V2]) Clone() *GenericTypeAliasStruct[T, T2, V2] { - if src == nil { - return nil - } - dst := new(GenericTypeAliasStruct[T, T2, V2]) - *dst = *src - dst.Cloneable = src.Cloneable.Clone() - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericTypeAliasStructCloneNeedsRegeneration[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]](GenericTypeAliasStruct[T, T2, V2]) { - _GenericTypeAliasStructCloneNeedsRegeneration(struct { - NonCloneable T - Cloneable T2 - }{}) -} diff --git a/cmd/viewer/tests/tests_view.go b/cmd/viewer/tests/tests_view.go deleted file mode 100644 index 9c74c94261e08..0000000000000 --- a/cmd/viewer/tests/tests_view.go +++ /dev/null @@ -1,839 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by tailscale/cmd/viewer; DO NOT EDIT. - -package tests - -import ( - "encoding/json" - "errors" - "net/netip" - - "golang.org/x/exp/constraints" - "tailscale.com/types/views" -) - -//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers,StructWithTypeAliasFields,GenericTypeAliasStruct - -// View returns a readonly view of StructWithPtrs. -func (p *StructWithPtrs) View() StructWithPtrsView { - return StructWithPtrsView{ж: p} -} - -// StructWithPtrsView provides a read-only view over StructWithPtrs. -// -// Its methods should only be called if `Valid()` returns true. -type StructWithPtrsView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *StructWithPtrs -} - -// Valid reports whether underlying value is non-nil. -func (v StructWithPtrsView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v StructWithPtrsView) AsStruct() *StructWithPtrs { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v StructWithPtrsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *StructWithPtrsView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x StructWithPtrs - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v StructWithPtrsView) Value() *StructWithoutPtrs { - if v.ж.Value == nil { - return nil - } - x := *v.ж.Value - return &x -} - -func (v StructWithPtrsView) Int() *int { - if v.ж.Int == nil { - return nil - } - x := *v.ж.Int - return &x -} - -func (v StructWithPtrsView) NoCloneValue() *StructWithoutPtrs { return v.ж.NoCloneValue } -func (v StructWithPtrsView) String() string { return v.ж.String() } -func (v StructWithPtrsView) Equal(v2 StructWithPtrsView) bool { return v.ж.Equal(v2.ж) } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithPtrsViewNeedsRegeneration = StructWithPtrs(struct { - Value *StructWithoutPtrs - Int *int - NoCloneValue *StructWithoutPtrs -}{}) - -// View returns a readonly view of StructWithoutPtrs. -func (p *StructWithoutPtrs) View() StructWithoutPtrsView { - return StructWithoutPtrsView{ж: p} -} - -// StructWithoutPtrsView provides a read-only view over StructWithoutPtrs. -// -// Its methods should only be called if `Valid()` returns true. -type StructWithoutPtrsView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *StructWithoutPtrs -} - -// Valid reports whether underlying value is non-nil. -func (v StructWithoutPtrsView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v StructWithoutPtrsView) AsStruct() *StructWithoutPtrs { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v StructWithoutPtrsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *StructWithoutPtrsView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x StructWithoutPtrs - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v StructWithoutPtrsView) Int() int { return v.ж.Int } -func (v StructWithoutPtrsView) Pfx() netip.Prefix { return v.ж.Pfx } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithoutPtrsViewNeedsRegeneration = StructWithoutPtrs(struct { - Int int - Pfx netip.Prefix -}{}) - -// View returns a readonly view of Map. -func (p *Map) View() MapView { - return MapView{ж: p} -} - -// MapView provides a read-only view over Map. -// -// Its methods should only be called if `Valid()` returns true. -type MapView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *Map -} - -// Valid reports whether underlying value is non-nil. -func (v MapView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v MapView) AsStruct() *Map { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v MapView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *MapView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x Map - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v MapView) Int() views.Map[string, int] { return views.MapOf(v.ж.Int) } - -func (v MapView) SliceInt() views.MapSlice[string, int] { return views.MapSliceOf(v.ж.SliceInt) } - -func (v MapView) StructPtrWithPtr() views.MapFn[string, *StructWithPtrs, StructWithPtrsView] { - return views.MapFnOf(v.ж.StructPtrWithPtr, func(t *StructWithPtrs) StructWithPtrsView { - return t.View() - }) -} - -func (v MapView) StructPtrWithoutPtr() views.MapFn[string, *StructWithoutPtrs, StructWithoutPtrsView] { - return views.MapFnOf(v.ж.StructPtrWithoutPtr, func(t *StructWithoutPtrs) StructWithoutPtrsView { - return t.View() - }) -} - -func (v MapView) StructWithoutPtr() views.Map[string, StructWithoutPtrs] { - return views.MapOf(v.ж.StructWithoutPtr) -} - -func (v MapView) SlicesWithPtrs() views.MapFn[string, []*StructWithPtrs, views.SliceView[*StructWithPtrs, StructWithPtrsView]] { - return views.MapFnOf(v.ж.SlicesWithPtrs, func(t []*StructWithPtrs) views.SliceView[*StructWithPtrs, StructWithPtrsView] { - return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](t) - }) -} - -func (v MapView) SlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrs, views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView]] { - return views.MapFnOf(v.ж.SlicesWithoutPtrs, func(t []*StructWithoutPtrs) views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView] { - return views.SliceOfViews[*StructWithoutPtrs, StructWithoutPtrsView](t) - }) -} - -func (v MapView) StructWithoutPtrKey() views.Map[StructWithoutPtrs, int] { - return views.MapOf(v.ж.StructWithoutPtrKey) -} - -func (v MapView) StructWithPtr() views.MapFn[string, StructWithPtrs, StructWithPtrsView] { - return views.MapFnOf(v.ж.StructWithPtr, func(t StructWithPtrs) StructWithPtrsView { - return t.View() - }) -} -func (v MapView) SliceIntPtr() map[string][]*int { panic("unsupported") } -func (v MapView) PointerKey() map[*string]int { panic("unsupported") } -func (v MapView) StructWithPtrKey() map[StructWithPtrs]int { panic("unsupported") } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _MapViewNeedsRegeneration = Map(struct { - Int map[string]int - SliceInt map[string][]int - StructPtrWithPtr map[string]*StructWithPtrs - StructPtrWithoutPtr map[string]*StructWithoutPtrs - StructWithoutPtr map[string]StructWithoutPtrs - SlicesWithPtrs map[string][]*StructWithPtrs - SlicesWithoutPtrs map[string][]*StructWithoutPtrs - StructWithoutPtrKey map[StructWithoutPtrs]int - StructWithPtr map[string]StructWithPtrs - SliceIntPtr map[string][]*int - PointerKey map[*string]int - StructWithPtrKey map[StructWithPtrs]int -}{}) - -// View returns a readonly view of StructWithSlices. -func (p *StructWithSlices) View() StructWithSlicesView { - return StructWithSlicesView{ж: p} -} - -// StructWithSlicesView provides a read-only view over StructWithSlices. -// -// Its methods should only be called if `Valid()` returns true. -type StructWithSlicesView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *StructWithSlices -} - -// Valid reports whether underlying value is non-nil. -func (v StructWithSlicesView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v StructWithSlicesView) AsStruct() *StructWithSlices { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v StructWithSlicesView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *StructWithSlicesView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x StructWithSlices - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v StructWithSlicesView) Values() views.Slice[StructWithoutPtrs] { - return views.SliceOf(v.ж.Values) -} -func (v StructWithSlicesView) ValuePointers() views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView] { - return views.SliceOfViews[*StructWithoutPtrs, StructWithoutPtrsView](v.ж.ValuePointers) -} -func (v StructWithSlicesView) StructPointers() views.SliceView[*StructWithPtrs, StructWithPtrsView] { - return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](v.ж.StructPointers) -} -func (v StructWithSlicesView) Slice() views.Slice[string] { return views.SliceOf(v.ж.Slice) } -func (v StructWithSlicesView) Prefixes() views.Slice[netip.Prefix] { - return views.SliceOf(v.ж.Prefixes) -} -func (v StructWithSlicesView) Data() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Data) } -func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") } -func (v StructWithSlicesView) Ints() *int { panic("unsupported") } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithSlicesViewNeedsRegeneration = StructWithSlices(struct { - Values []StructWithoutPtrs - ValuePointers []*StructWithoutPtrs - StructPointers []*StructWithPtrs - Slice []string - Prefixes []netip.Prefix - Data []byte - Structs []StructWithPtrs - Ints []*int -}{}) - -// View returns a readonly view of StructWithEmbedded. -func (p *StructWithEmbedded) View() StructWithEmbeddedView { - return StructWithEmbeddedView{ж: p} -} - -// StructWithEmbeddedView provides a read-only view over StructWithEmbedded. -// -// Its methods should only be called if `Valid()` returns true. -type StructWithEmbeddedView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *StructWithEmbedded -} - -// Valid reports whether underlying value is non-nil. -func (v StructWithEmbeddedView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v StructWithEmbeddedView) AsStruct() *StructWithEmbedded { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v StructWithEmbeddedView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *StructWithEmbeddedView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x StructWithEmbedded - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v StructWithEmbeddedView) A() StructWithPtrsView { return v.ж.A.View() } -func (v StructWithEmbeddedView) StructWithSlices() StructWithSlicesView { - return v.ж.StructWithSlices.View() -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithEmbeddedViewNeedsRegeneration = StructWithEmbedded(struct { - A *StructWithPtrs - StructWithSlices -}{}) - -// View returns a readonly view of GenericIntStruct. -func (p *GenericIntStruct[T]) View() GenericIntStructView[T] { - return GenericIntStructView[T]{ж: p} -} - -// GenericIntStructView[T] provides a read-only view over GenericIntStruct[T]. -// -// Its methods should only be called if `Valid()` returns true. -type GenericIntStructView[T constraints.Integer] struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *GenericIntStruct[T] -} - -// Valid reports whether underlying value is non-nil. -func (v GenericIntStructView[T]) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v GenericIntStructView[T]) AsStruct() *GenericIntStruct[T] { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v GenericIntStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *GenericIntStructView[T]) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x GenericIntStruct[T] - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v GenericIntStructView[T]) Value() T { return v.ж.Value } -func (v GenericIntStructView[T]) Pointer() *T { - if v.ж.Pointer == nil { - return nil - } - x := *v.ж.Pointer - return &x -} - -func (v GenericIntStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) } - -func (v GenericIntStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) } -func (v GenericIntStructView[T]) PtrSlice() *T { panic("unsupported") } -func (v GenericIntStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") } -func (v GenericIntStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") } -func (v GenericIntStructView[T]) SliceMap() map[string][]T { panic("unsupported") } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericIntStructViewNeedsRegeneration[T constraints.Integer](GenericIntStruct[T]) { - _GenericIntStructViewNeedsRegeneration(struct { - Value T - Pointer *T - Slice []T - Map map[string]T - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T - }{}) -} - -// View returns a readonly view of GenericNoPtrsStruct. -func (p *GenericNoPtrsStruct[T]) View() GenericNoPtrsStructView[T] { - return GenericNoPtrsStructView[T]{ж: p} -} - -// GenericNoPtrsStructView[T] provides a read-only view over GenericNoPtrsStruct[T]. -// -// Its methods should only be called if `Valid()` returns true. -type GenericNoPtrsStructView[T StructWithoutPtrs | netip.Prefix | BasicType] struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *GenericNoPtrsStruct[T] -} - -// Valid reports whether underlying value is non-nil. -func (v GenericNoPtrsStructView[T]) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v GenericNoPtrsStructView[T]) AsStruct() *GenericNoPtrsStruct[T] { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v GenericNoPtrsStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *GenericNoPtrsStructView[T]) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x GenericNoPtrsStruct[T] - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v GenericNoPtrsStructView[T]) Value() T { return v.ж.Value } -func (v GenericNoPtrsStructView[T]) Pointer() *T { - if v.ж.Pointer == nil { - return nil - } - x := *v.ж.Pointer - return &x -} - -func (v GenericNoPtrsStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) } - -func (v GenericNoPtrsStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) } -func (v GenericNoPtrsStructView[T]) PtrSlice() *T { panic("unsupported") } -func (v GenericNoPtrsStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") } -func (v GenericNoPtrsStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") } -func (v GenericNoPtrsStructView[T]) SliceMap() map[string][]T { panic("unsupported") } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericNoPtrsStructViewNeedsRegeneration[T StructWithoutPtrs | netip.Prefix | BasicType](GenericNoPtrsStruct[T]) { - _GenericNoPtrsStructViewNeedsRegeneration(struct { - Value T - Pointer *T - Slice []T - Map map[string]T - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T - }{}) -} - -// View returns a readonly view of GenericCloneableStruct. -func (p *GenericCloneableStruct[T, V]) View() GenericCloneableStructView[T, V] { - return GenericCloneableStructView[T, V]{ж: p} -} - -// GenericCloneableStructView[T, V] provides a read-only view over GenericCloneableStruct[T, V]. -// -// Its methods should only be called if `Valid()` returns true. -type GenericCloneableStructView[T views.ViewCloner[T, V], V views.StructView[T]] struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *GenericCloneableStruct[T, V] -} - -// Valid reports whether underlying value is non-nil. -func (v GenericCloneableStructView[T, V]) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v GenericCloneableStructView[T, V]) AsStruct() *GenericCloneableStruct[T, V] { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v GenericCloneableStructView[T, V]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *GenericCloneableStructView[T, V]) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x GenericCloneableStruct[T, V] - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v GenericCloneableStructView[T, V]) Value() V { return v.ж.Value.View() } -func (v GenericCloneableStructView[T, V]) Slice() views.SliceView[T, V] { - return views.SliceOfViews[T, V](v.ж.Slice) -} - -func (v GenericCloneableStructView[T, V]) Map() views.MapFn[string, T, V] { - return views.MapFnOf(v.ж.Map, func(t T) V { - return t.View() - }) -} -func (v GenericCloneableStructView[T, V]) Pointer() map[string]T { panic("unsupported") } -func (v GenericCloneableStructView[T, V]) PtrSlice() *T { panic("unsupported") } -func (v GenericCloneableStructView[T, V]) PtrKeyMap() map[*T]string { panic("unsupported") } -func (v GenericCloneableStructView[T, V]) PtrValueMap() map[string]*T { panic("unsupported") } -func (v GenericCloneableStructView[T, V]) SliceMap() map[string][]T { panic("unsupported") } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericCloneableStructViewNeedsRegeneration[T views.ViewCloner[T, V], V views.StructView[T]](GenericCloneableStruct[T, V]) { - _GenericCloneableStructViewNeedsRegeneration(struct { - Value T - Slice []T - Map map[string]T - Pointer *T - PtrSlice []*T - PtrKeyMap map[*T]string `json:"-"` - PtrValueMap map[string]*T - SliceMap map[string][]T - }{}) -} - -// View returns a readonly view of StructWithContainers. -func (p *StructWithContainers) View() StructWithContainersView { - return StructWithContainersView{ж: p} -} - -// StructWithContainersView provides a read-only view over StructWithContainers. -// -// Its methods should only be called if `Valid()` returns true. -type StructWithContainersView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *StructWithContainers -} - -// Valid reports whether underlying value is non-nil. -func (v StructWithContainersView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v StructWithContainersView) AsStruct() *StructWithContainers { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v StructWithContainersView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *StructWithContainersView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x StructWithContainers - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v StructWithContainersView) IntContainer() Container[int] { return v.ж.IntContainer } -func (v StructWithContainersView) CloneableContainer() ContainerView[*StructWithPtrs, StructWithPtrsView] { - return ContainerViewOf(&v.ж.CloneableContainer) -} -func (v StructWithContainersView) BasicGenericContainer() Container[GenericBasicStruct[int]] { - return v.ж.BasicGenericContainer -} -func (v StructWithContainersView) CloneableGenericContainer() ContainerView[*GenericNoPtrsStruct[int], GenericNoPtrsStructView[int]] { - return ContainerViewOf(&v.ж.CloneableGenericContainer) -} -func (v StructWithContainersView) CloneableMap() MapContainerView[int, *StructWithPtrs, StructWithPtrsView] { - return MapContainerViewOf(&v.ж.CloneableMap) -} -func (v StructWithContainersView) CloneableGenericMap() MapContainerView[int, *GenericNoPtrsStruct[int], GenericNoPtrsStructView[int]] { - return MapContainerViewOf(&v.ж.CloneableGenericMap) -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithContainersViewNeedsRegeneration = StructWithContainers(struct { - IntContainer Container[int] - CloneableContainer Container[*StructWithPtrs] - BasicGenericContainer Container[GenericBasicStruct[int]] - CloneableGenericContainer Container[*GenericNoPtrsStruct[int]] - CloneableMap MapContainer[int, *StructWithPtrs] - CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]] -}{}) - -// View returns a readonly view of StructWithTypeAliasFields. -func (p *StructWithTypeAliasFields) View() StructWithTypeAliasFieldsView { - return StructWithTypeAliasFieldsView{ж: p} -} - -// StructWithTypeAliasFieldsView provides a read-only view over StructWithTypeAliasFields. -// -// Its methods should only be called if `Valid()` returns true. -type StructWithTypeAliasFieldsView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *StructWithTypeAliasFields -} - -// Valid reports whether underlying value is non-nil. -func (v StructWithTypeAliasFieldsView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v StructWithTypeAliasFieldsView) AsStruct() *StructWithTypeAliasFields { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v StructWithTypeAliasFieldsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *StructWithTypeAliasFieldsView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x StructWithTypeAliasFields - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v StructWithTypeAliasFieldsView) WithPtr() StructWithPtrsView { return v.ж.WithPtr.View() } -func (v StructWithTypeAliasFieldsView) WithoutPtr() StructWithoutPtrsAlias { return v.ж.WithoutPtr } -func (v StructWithTypeAliasFieldsView) WithPtrByPtr() StructWithPtrsAliasView { - return v.ж.WithPtrByPtr.View() -} -func (v StructWithTypeAliasFieldsView) WithoutPtrByPtr() *StructWithoutPtrsAlias { - if v.ж.WithoutPtrByPtr == nil { - return nil - } - x := *v.ж.WithoutPtrByPtr - return &x -} - -func (v StructWithTypeAliasFieldsView) SliceWithPtrs() views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView] { - return views.SliceOfViews[*StructWithPtrsAlias, StructWithPtrsAliasView](v.ж.SliceWithPtrs) -} -func (v StructWithTypeAliasFieldsView) SliceWithoutPtrs() views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView] { - return views.SliceOfViews[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView](v.ж.SliceWithoutPtrs) -} - -func (v StructWithTypeAliasFieldsView) MapWithPtrs() views.MapFn[string, *StructWithPtrsAlias, StructWithPtrsAliasView] { - return views.MapFnOf(v.ж.MapWithPtrs, func(t *StructWithPtrsAlias) StructWithPtrsAliasView { - return t.View() - }) -} - -func (v StructWithTypeAliasFieldsView) MapWithoutPtrs() views.MapFn[string, *StructWithoutPtrsAlias, StructWithoutPtrsAliasView] { - return views.MapFnOf(v.ж.MapWithoutPtrs, func(t *StructWithoutPtrsAlias) StructWithoutPtrsAliasView { - return t.View() - }) -} - -func (v StructWithTypeAliasFieldsView) MapOfSlicesWithPtrs() views.MapFn[string, []*StructWithPtrsAlias, views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView]] { - return views.MapFnOf(v.ж.MapOfSlicesWithPtrs, func(t []*StructWithPtrsAlias) views.SliceView[*StructWithPtrsAlias, StructWithPtrsAliasView] { - return views.SliceOfViews[*StructWithPtrsAlias, StructWithPtrsAliasView](t) - }) -} - -func (v StructWithTypeAliasFieldsView) MapOfSlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrsAlias, views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView]] { - return views.MapFnOf(v.ж.MapOfSlicesWithoutPtrs, func(t []*StructWithoutPtrsAlias) views.SliceView[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView] { - return views.SliceOfViews[*StructWithoutPtrsAlias, StructWithoutPtrsAliasView](t) - }) -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _StructWithTypeAliasFieldsViewNeedsRegeneration = StructWithTypeAliasFields(struct { - WithPtr StructWithPtrsAlias - WithoutPtr StructWithoutPtrsAlias - WithPtrByPtr *StructWithPtrsAlias - WithoutPtrByPtr *StructWithoutPtrsAlias - SliceWithPtrs []*StructWithPtrsAlias - SliceWithoutPtrs []*StructWithoutPtrsAlias - MapWithPtrs map[string]*StructWithPtrsAlias - MapWithoutPtrs map[string]*StructWithoutPtrsAlias - MapOfSlicesWithPtrs map[string][]*StructWithPtrsAlias - MapOfSlicesWithoutPtrs map[string][]*StructWithoutPtrsAlias -}{}) - -// View returns a readonly view of GenericTypeAliasStruct. -func (p *GenericTypeAliasStruct[T, T2, V2]) View() GenericTypeAliasStructView[T, T2, V2] { - return GenericTypeAliasStructView[T, T2, V2]{ж: p} -} - -// GenericTypeAliasStructView[T, T2, V2] provides a read-only view over GenericTypeAliasStruct[T, T2, V2]. -// -// Its methods should only be called if `Valid()` returns true. -type GenericTypeAliasStructView[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]] struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *GenericTypeAliasStruct[T, T2, V2] -} - -// Valid reports whether underlying value is non-nil. -func (v GenericTypeAliasStructView[T, T2, V2]) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v GenericTypeAliasStructView[T, T2, V2]) AsStruct() *GenericTypeAliasStruct[T, T2, V2] { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v GenericTypeAliasStructView[T, T2, V2]) MarshalJSON() ([]byte, error) { - return json.Marshal(v.ж) -} - -func (v *GenericTypeAliasStructView[T, T2, V2]) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x GenericTypeAliasStruct[T, T2, V2] - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v GenericTypeAliasStructView[T, T2, V2]) NonCloneable() T { return v.ж.NonCloneable } -func (v GenericTypeAliasStructView[T, T2, V2]) Cloneable() V2 { return v.ж.Cloneable.View() } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _GenericTypeAliasStructViewNeedsRegeneration[T integer, T2 views.ViewCloner[T2, V2], V2 views.StructView[T2]](GenericTypeAliasStruct[T, T2, V2]) { - _GenericTypeAliasStructViewNeedsRegeneration(struct { - NonCloneable T - Cloneable T2 - }{}) -} diff --git a/cmd/viewer/viewer.go b/cmd/viewer/viewer.go deleted file mode 100644 index 0c5868f3a86e6..0000000000000 --- a/cmd/viewer/viewer.go +++ /dev/null @@ -1,604 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Viewer is a tool to automate the creation of "view" wrapper types that -// provide read-only accessor methods to underlying fields. -package main - -import ( - "bytes" - "flag" - "fmt" - "go/types" - "html/template" - "log" - "os" - "slices" - "strings" - - "tailscale.com/util/codegen" - "tailscale.com/util/must" -) - -const viewTemplateStr = `{{define "common"}} -// View returns a readonly view of {{.StructName}}. -func (p *{{.StructName}}{{.TypeParamNames}}) View() {{.ViewName}}{{.TypeParamNames}} { - return {{.ViewName}}{{.TypeParamNames}}{ж: p} -} - -// {{.ViewName}}{{.TypeParamNames}} provides a read-only view over {{.StructName}}{{.TypeParamNames}}. -// -// Its methods should only be called if ` + "`Valid()`" + ` returns true. -type {{.ViewName}}{{.TypeParams}} struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *{{.StructName}}{{.TypeParamNames}} -} - -// Valid reports whether underlying value is non-nil. -func (v {{.ViewName}}{{.TypeParamNames}}) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v {{.ViewName}}{{.TypeParamNames}}) AsStruct() *{{.StructName}}{{.TypeParamNames}}{ - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v {{.ViewName}}{{.TypeParamNames}}) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *{{.ViewName}}{{.TypeParamNames}}) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x {{.StructName}}{{.TypeParamNames}} - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж=&x - return nil -} - -{{end}} -{{define "valueField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} { return v.ж.{{.FieldName}} } -{{end}} -{{define "byteSliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.ByteSlice[{{.FieldType}}] { return views.ByteSliceOf(v.ж.{{.FieldName}}) } -{{end}} -{{define "sliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Slice[{{.FieldType}}] { return views.SliceOf(v.ж.{{.FieldName}}) } -{{end}} -{{define "viewSliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.SliceView[{{.FieldType}},{{.FieldViewName}}] { return views.SliceOfViews[{{.FieldType}},{{.FieldViewName}}](v.ж.{{.FieldName}}) } -{{end}} -{{define "viewField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldViewName}} { return v.ж.{{.FieldName}}.View() } -{{end}} -{{define "makeViewField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldViewName}} { return {{.MakeViewFnName}}(&v.ж.{{.FieldName}}) } -{{end}} -{{define "valuePointerField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} { - if v.ж.{{.FieldName}} == nil { - return nil - } - x := *v.ж.{{.FieldName}} - return &x -} - -{{end}} -{{define "mapField"}} -func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})} -{{end}} -{{define "mapFnField"}} -func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} { - return {{.MapFn}} -})} -{{end}} -{{define "mapSliceField"}} -func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapSlice[{{.MapKeyType}},{{.MapValueType}}] { return views.MapSliceOf(v.ж.{{.FieldName}}) } -{{end}} -{{define "unsupportedField"}}func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")} -{{end}} -{{define "stringFunc"}}func(v {{.ViewName}}{{.TypeParamNames}}) String() string { return v.ж.String() } -{{end}} -{{define "equalFunc"}}func(v {{.ViewName}}{{.TypeParamNames}}) Equal(v2 {{.ViewName}}{{.TypeParamNames}}) bool { return v.ж.Equal(v2.ж) } -{{end}} -` - -var viewTemplate *template.Template - -func init() { - viewTemplate = template.Must(template.New("view").Parse(viewTemplateStr)) -} - -func requiresCloning(t types.Type) (shallow, deep bool, base types.Type) { - switch v := t.(type) { - case *types.Pointer: - _, deep, base = requiresCloning(v.Elem()) - return true, deep, base - case *types.Slice: - _, deep, base = requiresCloning(v.Elem()) - return true, deep, base - } - p := codegen.ContainsPointers(t) - return p, p, t -} - -func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thisPkg *types.Package) { - t, ok := typ.Underlying().(*types.Struct) - if !ok || codegen.IsViewType(t) { - return - } - it.Import("encoding/json") - it.Import("errors") - - args := struct { - StructName string - ViewName string - TypeParams string // e.g. [T constraints.Integer] - TypeParamNames string // e.g. [T] - - FieldName string - FieldType string - FieldViewName string - - MapKeyType string - MapValueType string - MapValueView string - MapFn string - - // MakeViewFnName is the name of the function that accepts a value and returns a readonly view of it. - MakeViewFnName string - }{ - StructName: typ.Obj().Name(), - ViewName: typ.Origin().Obj().Name() + "View", - } - - typeParams := typ.Origin().TypeParams() - args.TypeParams, args.TypeParamNames = codegen.FormatTypeParams(typeParams, it) - - writeTemplate := func(name string) { - if err := viewTemplate.ExecuteTemplate(buf, name, args); err != nil { - log.Fatal(err) - } - } - writeTemplate("common") - for i := range t.NumFields() { - f := t.Field(i) - fname := f.Name() - if !f.Exported() { - continue - } - args.FieldName = fname - fieldType := f.Type() - if codegen.IsInvalid(fieldType) { - continue - } - if !codegen.ContainsPointers(fieldType) || codegen.IsViewType(fieldType) || codegen.HasNoClone(t.Tag(i)) { - args.FieldType = it.QualifiedName(fieldType) - writeTemplate("valueField") - continue - } - switch underlying := fieldType.Underlying().(type) { - case *types.Slice: - slice := underlying - elem := slice.Elem() - switch elem.String() { - case "byte": - args.FieldType = it.QualifiedName(fieldType) - it.Import("tailscale.com/types/views") - writeTemplate("byteSliceField") - default: - args.FieldType = it.QualifiedName(elem) - it.Import("tailscale.com/types/views") - shallow, deep, base := requiresCloning(elem) - if deep { - switch elem.Underlying().(type) { - case *types.Pointer: - if _, isIface := base.Underlying().(*types.Interface); !isIface { - args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View") - writeTemplate("viewSliceField") - } else { - writeTemplate("unsupportedField") - } - continue - case *types.Interface: - if viewType := viewTypeForValueType(elem); viewType != nil { - args.FieldViewName = it.QualifiedName(viewType) - writeTemplate("viewSliceField") - continue - } - } - writeTemplate("unsupportedField") - continue - } else if shallow { - switch base.Underlying().(type) { - case *types.Basic, *types.Interface: - writeTemplate("unsupportedField") - default: - if _, isIface := base.Underlying().(*types.Interface); !isIface { - args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View") - writeTemplate("viewSliceField") - } else { - writeTemplate("unsupportedField") - } - } - continue - } - writeTemplate("sliceField") - } - continue - case *types.Struct: - strucT := underlying - args.FieldType = it.QualifiedName(fieldType) - if codegen.ContainsPointers(strucT) { - if viewType := viewTypeForValueType(fieldType); viewType != nil { - args.FieldViewName = it.QualifiedName(viewType) - writeTemplate("viewField") - continue - } - if viewType, makeViewFn := viewTypeForContainerType(fieldType); viewType != nil { - args.FieldViewName = it.QualifiedName(viewType) - args.MakeViewFnName = it.PackagePrefix(makeViewFn.Pkg()) + makeViewFn.Name() - writeTemplate("makeViewField") - continue - } - writeTemplate("unsupportedField") - continue - } - writeTemplate("valueField") - continue - case *types.Map: - m := underlying - args.FieldType = it.QualifiedName(fieldType) - shallow, deep, key := requiresCloning(m.Key()) - if shallow || deep { - writeTemplate("unsupportedField") - continue - } - it.Import("tailscale.com/types/views") - args.MapKeyType = it.QualifiedName(key) - mElem := m.Elem() - var template string - switch u := mElem.(type) { - case *types.Struct, *types.Named, *types.Alias: - strucT := u - args.FieldType = it.QualifiedName(fieldType) - if codegen.ContainsPointers(strucT) { - args.MapFn = "t.View()" - template = "mapFnField" - args.MapValueType = it.QualifiedName(mElem) - args.MapValueView = appendNameSuffix(args.MapValueType, "View") - } else { - template = "mapField" - args.MapValueType = it.QualifiedName(mElem) - } - case *types.Basic: - template = "mapField" - args.MapValueType = it.QualifiedName(mElem) - case *types.Slice: - slice := u - sElem := slice.Elem() - switch x := sElem.(type) { - case *types.Basic, *types.Named, *types.Alias: - sElem := it.QualifiedName(sElem) - args.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem) - args.MapValueType = sElem - template = "mapSliceField" - case *types.Pointer: - ptr := x - pElem := ptr.Elem() - template = "unsupportedField" - if _, isIface := pElem.Underlying().(*types.Interface); !isIface { - switch pElem.(type) { - case *types.Struct, *types.Named, *types.Alias: - ptrType := it.QualifiedName(ptr) - viewType := appendNameSuffix(it.QualifiedName(pElem), "View") - args.MapFn = fmt.Sprintf("views.SliceOfViews[%v,%v](t)", ptrType, viewType) - args.MapValueView = fmt.Sprintf("views.SliceView[%v,%v]", ptrType, viewType) - args.MapValueType = "[]" + ptrType - template = "mapFnField" - default: - template = "unsupportedField" - } - } else { - template = "unsupportedField" - } - default: - template = "unsupportedField" - } - case *types.Pointer: - ptr := u - pElem := ptr.Elem() - if _, isIface := pElem.Underlying().(*types.Interface); !isIface { - switch pElem.(type) { - case *types.Struct, *types.Named, *types.Alias: - args.MapValueType = it.QualifiedName(ptr) - args.MapValueView = appendNameSuffix(it.QualifiedName(pElem), "View") - args.MapFn = "t.View()" - template = "mapFnField" - default: - template = "unsupportedField" - } - } else { - template = "unsupportedField" - } - case *types.Interface, *types.TypeParam: - if viewType := viewTypeForValueType(u); viewType != nil { - args.MapValueType = it.QualifiedName(u) - args.MapValueView = it.QualifiedName(viewType) - args.MapFn = "t.View()" - template = "mapFnField" - } else if !codegen.ContainsPointers(u) { - args.MapValueType = it.QualifiedName(mElem) - template = "mapField" - } else { - template = "unsupportedField" - } - default: - template = "unsupportedField" - } - writeTemplate(template) - continue - case *types.Pointer: - ptr := underlying - _, deep, base := requiresCloning(ptr) - - if deep { - if _, isIface := base.Underlying().(*types.Interface); !isIface { - args.FieldType = it.QualifiedName(base) - args.FieldViewName = appendNameSuffix(args.FieldType, "View") - writeTemplate("viewField") - } else { - writeTemplate("unsupportedField") - } - } else { - args.FieldType = it.QualifiedName(ptr) - writeTemplate("valuePointerField") - } - continue - case *types.Interface: - // If fieldType is an interface with a "View() {ViewType}" method, it can be used to clone the field. - // This includes scenarios where fieldType is a constrained type parameter. - if viewType := viewTypeForValueType(underlying); viewType != nil { - args.FieldViewName = it.QualifiedName(viewType) - writeTemplate("viewField") - continue - } - } - writeTemplate("unsupportedField") - } - for i := range typ.NumMethods() { - f := typ.Method(i) - if !f.Exported() { - continue - } - sig, ok := f.Type().(*types.Signature) - if !ok { - continue - } - - switch f.Name() { - case "Clone", "View": - continue // "AsStruct" - case "String": - writeTemplate("stringFunc") - continue - case "Equal": - if sig.Results().Len() == 1 && sig.Results().At(0).Type().String() == "bool" { - writeTemplate("equalFunc") - continue - } - } - } - fmt.Fprintf(buf, "\n") - buf.Write(codegen.AssertStructUnchanged(t, args.StructName, typeParams, "View", it)) -} - -func appendNameSuffix(name, suffix string) string { - if idx := strings.IndexRune(name, '['); idx != -1 { - // Insert suffix after the type name, but before type parameters. - return name[:idx] + suffix + name[idx:] - } - return name + suffix -} - -func viewTypeForValueType(typ types.Type) types.Type { - if ptr, ok := typ.(*types.Pointer); ok { - return viewTypeForValueType(ptr.Elem()) - } - viewMethod := codegen.LookupMethod(typ, "View") - if viewMethod == nil { - return nil - } - sig, ok := viewMethod.Type().(*types.Signature) - if !ok || sig.Results().Len() != 1 { - return nil - } - return sig.Results().At(0).Type() -} - -func viewTypeForContainerType(typ types.Type) (*types.Named, *types.Func) { - // The container type should be an instantiated generic type, - // with its first type parameter specifying the element type. - containerType, ok := codegen.NamedTypeOf(typ) - if !ok || containerType.TypeArgs().Len() == 0 { - return nil, nil - } - - // Look up the view type for the container type. - // It must include an additional type parameter specifying the element's view type. - // For example, Container[T] => ContainerView[T, V]. - containerViewTypeName := containerType.Obj().Name() + "View" - containerViewTypeObj, ok := containerType.Obj().Pkg().Scope().Lookup(containerViewTypeName).(*types.TypeName) - if !ok { - return nil, nil - } - containerViewGenericType, ok := codegen.NamedTypeOf(containerViewTypeObj.Type()) - if !ok || containerViewGenericType.TypeParams().Len() != containerType.TypeArgs().Len()+1 { - return nil, nil - } - - // Create a list of type arguments for instantiating the container view type. - // Include all type arguments specified for the container type... - containerViewTypeArgs := make([]types.Type, containerViewGenericType.TypeParams().Len()) - for i := range containerType.TypeArgs().Len() { - containerViewTypeArgs[i] = containerType.TypeArgs().At(i) - } - // ...and add the element view type. - // For that, we need to first determine the named elem type... - elemType, ok := codegen.NamedTypeOf(baseType(containerType.TypeArgs().At(containerType.TypeArgs().Len() - 1))) - if !ok { - return nil, nil - } - // ...then infer the view type from it. - var elemViewType *types.Named - elemTypeName := elemType.Obj().Name() - elemViewTypeBaseName := elemType.Obj().Name() + "View" - if elemViewTypeName, ok := elemType.Obj().Pkg().Scope().Lookup(elemViewTypeBaseName).(*types.TypeName); ok { - // The elem's view type is already defined in the same package as the elem type. - elemViewType = elemViewTypeName.Type().(*types.Named) - } else if slices.Contains(typeNames, elemTypeName) { - // The elem's view type has not been generated yet, but we can define - // and use a blank type with the expected view type name. - elemViewTypeName = types.NewTypeName(0, elemType.Obj().Pkg(), elemViewTypeBaseName, nil) - elemViewType = types.NewNamed(elemViewTypeName, types.NewStruct(nil, nil), nil) - if elemTypeParams := elemType.TypeParams(); elemTypeParams != nil { - elemViewType.SetTypeParams(collectTypeParams(elemTypeParams)) - } - } else { - // The elem view type does not exist and won't be generated. - return nil, nil - } - // If elemType is an instantiated generic type, instantiate the elemViewType as well. - if elemTypeArgs := elemType.TypeArgs(); elemTypeArgs != nil { - elemViewType, _ = codegen.NamedTypeOf(must.Get(types.Instantiate(nil, elemViewType, collectTypes(elemTypeArgs), false))) - } - // And finally set the elemViewType as the last type argument. - containerViewTypeArgs[len(containerViewTypeArgs)-1] = elemViewType - - // Instantiate the container view type with the specified type arguments. - containerViewType := must.Get(types.Instantiate(nil, containerViewGenericType, containerViewTypeArgs, false)) - // Look up a function to create a view of a container. - // It should be in the same package as the container type, named {ViewType}Of, - // and have a signature like {ViewType}Of(c *Container[T]) ContainerView[T, V]. - makeContainerView, ok := containerType.Obj().Pkg().Scope().Lookup(containerViewTypeName + "Of").(*types.Func) - if !ok { - return nil, nil - } - return containerViewType.(*types.Named), makeContainerView -} - -func baseType(typ types.Type) types.Type { - if ptr, ok := typ.(*types.Pointer); ok { - return ptr.Elem() - } - return typ -} - -func collectTypes(list *types.TypeList) []types.Type { - // TODO(nickkhyl): use slices.Collect in Go 1.23? - if list.Len() == 0 { - return nil - } - res := make([]types.Type, list.Len()) - for i := range res { - res[i] = list.At(i) - } - return res -} - -func collectTypeParams(list *types.TypeParamList) []*types.TypeParam { - if list.Len() == 0 { - return nil - } - res := make([]*types.TypeParam, list.Len()) - for i := range res { - p := list.At(i) - res[i] = types.NewTypeParam(p.Obj(), p.Constraint()) - } - return res -} - -var ( - flagTypes = flag.String("type", "", "comma-separated list of types; required") - flagBuildTags = flag.String("tags", "", "compiler build tags to apply") - flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func") - - flagCloneOnlyTypes = flag.String("clone-only-type", "", "comma-separated list of types (a subset of --type) that should only generate a go:generate clone line and not actual views") - - typeNames []string -) - -func main() { - log.SetFlags(0) - log.SetPrefix("viewer: ") - flag.Parse() - if len(*flagTypes) == 0 { - flag.Usage() - os.Exit(2) - } - typeNames = strings.Split(*flagTypes, ",") - - var flagArgs []string - flagArgs = append(flagArgs, fmt.Sprintf("-clonefunc=%v", *flagCloneFunc)) - if *flagTypes != "" { - flagArgs = append(flagArgs, "-type="+*flagTypes) - } - if *flagBuildTags != "" { - flagArgs = append(flagArgs, "-tags="+*flagBuildTags) - } - pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".") - if err != nil { - log.Fatal(err) - } - it := codegen.NewImportTracker(pkg.Types) - - cloneOnlyType := map[string]bool{} - for _, t := range strings.Split(*flagCloneOnlyTypes, ",") { - cloneOnlyType[t] = true - } - - buf := new(bytes.Buffer) - fmt.Fprintf(buf, "//go:generate go run tailscale.com/cmd/cloner %s\n\n", strings.Join(flagArgs, " ")) - runCloner := false - for _, typeName := range typeNames { - if cloneOnlyType[typeName] { - continue - } - typ, ok := namedTypes[typeName].(*types.Named) - if !ok { - log.Fatalf("could not find type %s", typeName) - } - var hasClone bool - for i, n := 0, typ.NumMethods(); i < n; i++ { - if typ.Method(i).Name() == "Clone" { - hasClone = true - break - } - } - if !hasClone { - runCloner = true - } - genView(buf, it, typ, pkg.Types) - } - out := pkg.Name + "_view" - if *flagBuildTags == "test" { - out += "_test" - } - out += ".go" - if err := codegen.WritePackageFile("tailscale/cmd/viewer", pkg, out, it, buf); err != nil { - log.Fatal(err) - } - if runCloner { - // When a new package is added or when existing generated files have - // been deleted, we might run into a case where tailscale.com/cmd/cloner - // has not run yet. We detect this by verifying that all the structs we - // interacted with have had Clone method already generated. If they - // haven't we ask the caller to rerun generation again so that those get - // generated. - log.Printf("%v requires regeneration. Please run go generate again", pkg.Name+"_clone.go") - } -} diff --git a/cmd/viewer/viewer_test.go b/cmd/viewer/viewer_test.go deleted file mode 100644 index cd5f3d95f9c93..0000000000000 --- a/cmd/viewer/viewer_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "bytes" - "fmt" - "go/ast" - "go/parser" - "go/token" - "go/types" - "testing" - - "tailscale.com/util/codegen" -) - -func TestViewerImports(t *testing.T) { - tests := []struct { - name string - content string - typeNames []string - wantImports []string - }{ - { - name: "Map", - content: `type Test struct { Map map[string]int }`, - typeNames: []string{"Test"}, - wantImports: []string{"tailscale.com/types/views"}, - }, - { - name: "Slice", - content: `type Test struct { Slice []int }`, - typeNames: []string{"Test"}, - wantImports: []string{"tailscale.com/types/views"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, "test.go", "package test\n\n"+tt.content, 0) - if err != nil { - fmt.Println("Error parsing:", err) - return - } - - info := &types.Info{ - Types: make(map[ast.Expr]types.TypeAndValue), - } - - conf := types.Config{} - pkg, err := conf.Check("", fset, []*ast.File{f}, info) - if err != nil { - t.Fatal(err) - } - - var output bytes.Buffer - tracker := codegen.NewImportTracker(pkg) - for i := range tt.typeNames { - typeName, ok := pkg.Scope().Lookup(tt.typeNames[i]).(*types.TypeName) - if !ok { - t.Fatalf("type %q does not exist", tt.typeNames[i]) - } - namedType, ok := typeName.Type().(*types.Named) - if !ok { - t.Fatalf("%q is not a named type", tt.typeNames[i]) - } - genView(&output, tracker, namedType, pkg) - } - - for _, pkgName := range tt.wantImports { - if !tracker.Has(pkgName) { - t.Errorf("missing import %q", pkgName) - } - } - }) - } -} diff --git a/cmd/vnet/run-krazy.sh b/cmd/vnet/run-krazy.sh deleted file mode 100755 index a55da6b953a0e..0000000000000 --- a/cmd/vnet/run-krazy.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash - -echo "Type 'C-a c' to enter monitor; q to quit." - -# If the USE_V6 environment is set to 1, set the nameserver explicitly to. -EXTRA_ARG="" -if [ "$USE_V6" = "1" ]; then - EXTRA_ARG="tta.nameserver=2411::411" -fi - -set -eux -qemu-system-x86_64 -M microvm,isa-serial=off \ - -m 1G \ - -nodefaults -no-user-config -nographic \ - -kernel $HOME/src/github.com/tailscale/gokrazy-kernel/vmlinuz \ - -append "console=hvc0 root=PARTUUID=60c24cc1-f3f9-427a-8199-76baa2d60001/PARTNROFF=1 ro init=/gokrazy/init panic=10 oops=panic pci=off nousb tsc=unstable clocksource=hpet tailscale-tta=1 tailscaled.env=TS_DEBUG_RAW_DISCO=1 ${EXTRA_ARG}" \ - -drive id=blk0,file=$HOME/src/tailscale.com/gokrazy/natlabapp.img,format=raw \ - -device virtio-blk-device,drive=blk0 \ - -device virtio-rng-device \ - -netdev stream,id=net0,addr.type=unix,addr.path=/tmp/qemu.sock \ - -device virtio-serial-device \ - -device virtio-net-device,netdev=net0,mac=52:cc:cc:cc:cc:01 \ - -chardev stdio,id=virtiocon0,mux=on \ - -device virtconsole,chardev=virtiocon0 \ - -mon chardev=virtiocon0,mode=readline \ - -audio none - diff --git a/cmd/vnet/vnet-main.go b/cmd/vnet/vnet-main.go deleted file mode 100644 index 1eb4f65ef2070..0000000000000 --- a/cmd/vnet/vnet-main.go +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The vnet binary runs a virtual network stack in userspace for qemu instances -// to connect to and simulate various network conditions. -package main - -import ( - "context" - "flag" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "os" - "time" - - "tailscale.com/tstest/natlab/vnet" - "tailscale.com/types/logger" - "tailscale.com/util/must" -) - -var ( - listen = flag.String("listen", "/tmp/qemu.sock", "path to listen on") - nat = flag.String("nat", "easy", "type of NAT to use") - nat2 = flag.String("nat2", "hard", "type of NAT to use for second network") - portmap = flag.Bool("portmap", false, "enable portmapping; requires --v4") - dgram = flag.Bool("dgram", false, "enable datagram mode; for use with macOS Hypervisor.Framework and VZFileHandleNetworkDeviceAttachment") - blend = flag.Bool("blend", true, "blend reality (controlplane.tailscale.com and DERPs) into the virtual network") - pcapFile = flag.String("pcap", "", "if non-empty, filename to write pcap") - v4 = flag.Bool("v4", true, "enable IPv4") - v6 = flag.Bool("v6", true, "enable IPv6") -) - -func main() { - flag.Parse() - - if _, err := os.Stat(*listen); err == nil { - os.Remove(*listen) - } - - var srv net.Listener - var err error - var conn *net.UnixConn - if *dgram { - addr, err := net.ResolveUnixAddr("unixgram", *listen) - if err != nil { - log.Fatalf("ResolveUnixAddr: %v", err) - } - conn, err = net.ListenUnixgram("unixgram", addr) - if err != nil { - log.Fatalf("ListenUnixgram: %v", err) - } - defer conn.Close() - } else { - srv, err = net.Listen("unix", *listen) - } - if err != nil { - log.Fatal(err) - } - - var c vnet.Config - c.SetPCAPFile(*pcapFile) - c.SetBlendReality(*blend) - - var net1opt = []any{vnet.NAT(*nat)} - if *v4 { - net1opt = append(net1opt, "2.1.1.1", "192.168.1.1/24") - } - if *v6 { - net1opt = append(net1opt, "2000:52::1/64") - } - - node1 := c.AddNode(c.AddNetwork(net1opt...)) - c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", vnet.NAT(*nat2))) - if *portmap && *v4 { - node1.Network().AddService(vnet.NATPMP) - } - - s, err := vnet.New(&c) - if err != nil { - log.Fatalf("newServer: %v", err) - } - - if *blend { - if err := s.PopulateDERPMapIPs(); err != nil { - log.Printf("warning: ignoring failure to populate DERP map: %v", err) - } - } - - s.WriteStartingBanner(os.Stdout) - nc := s.NodeAgentClient(node1) - go func() { - rp := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://gokrazy"))) - d := rp.Director - rp.Director = func(r *http.Request) { - d(r) - r.Header.Set("X-TTA-GoKrazy", "1") - } - rp.Transport = nc.HTTPClient.Transport - http.ListenAndServe(":8080", rp) - }() - go func() { - var last string - getStatus := func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - st, err := nc.Status(ctx) - if err != nil { - log.Printf("NodeStatus: %v", err) - return - } - if st.BackendState != last { - last = st.BackendState - log.Printf("NodeStatus: %v", logger.AsJSON(st)) - } - } - for { - time.Sleep(5 * time.Second) - //continue - getStatus() - } - }() - - if conn != nil { - s.ServeUnixConn(conn, vnet.ProtocolUnixDGRAM) - return - } - - for { - c, err := srv.Accept() - if err != nil { - log.Printf("Accept: %v", err) - continue - } - go s.ServeUnixConn(c.(*net.UnixConn), vnet.ProtocolQEMU) - } -} diff --git a/cmd/xdpderper/xdpderper.go b/cmd/xdpderper/xdpderper.go deleted file mode 100644 index 599034ae7259c..0000000000000 --- a/cmd/xdpderper/xdpderper.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Command xdpderper runs the XDP STUN server. -package main - -import ( - "flag" - "io" - "log" - "net/http" - "os" - "os/signal" - "strings" - "syscall" - - "github.com/prometheus/client_golang/prometheus" - "tailscale.com/derp/xdp" - "tailscale.com/net/netutil" - "tailscale.com/tsweb" -) - -var ( - flagDevice = flag.String("device", "", "target device name (default: autodetect)") - flagPort = flag.Int("dst-port", 0, "destination UDP port to serve") - flagVerbose = flag.Bool("verbose", false, "verbose output including verifier errors") - flagMode = flag.String("mode", "xdp", "XDP mode; valid modes: [xdp, xdpgeneric, xdpdrv, xdpoffload]") - flagHTTP = flag.String("http", ":8230", "HTTP listen address") -) - -func main() { - flag.Parse() - var attachFlags xdp.XDPAttachFlags - switch strings.ToLower(*flagMode) { - case "xdp": - attachFlags = 0 - case "xdpgeneric": - attachFlags = xdp.XDPGenericMode - case "xdpdrv": - attachFlags = xdp.XDPDriverMode - case "xdpoffload": - attachFlags = xdp.XDPOffloadMode - default: - log.Fatal("invalid mode") - } - deviceName := *flagDevice - if deviceName == "" { - var err error - deviceName, _, err = netutil.DefaultInterfacePortable() - if err != nil || deviceName == "" { - log.Fatalf("failed to detect default route interface: %v", err) - } - } - log.Printf("binding to device: %s", deviceName) - - server, err := xdp.NewSTUNServer(&xdp.STUNServerConfig{ - DeviceName: deviceName, - DstPort: *flagPort, - AttachFlags: attachFlags, - FullVerifierErr: *flagVerbose, - }) - if err != nil { - log.Fatalf("failed to init XDP STUN server: %v", err) - } - defer server.Close() - err = prometheus.Register(server) - if err != nil { - log.Fatalf("failed to register XDP STUN server as a prometheus collector: %v", err) - } - log.Println("XDP STUN server started") - - mux := http.NewServeMux() - debug := tsweb.Debugger(mux) - debug.KVFunc("Drop STUN", func() any { - return server.GetDropSTUN() - }) - debug.Handle("drop-stun-on", "Drop STUN packets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := server.SetDropSTUN(true) - if err != nil { - http.Error(w, err.Error(), 500) - } else { - io.WriteString(w, "STUN packets are now being dropped.") - } - })) - debug.Handle("drop-stun-off", "Handle STUN packets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := server.SetDropSTUN(false) - if err != nil { - http.Error(w, err.Error(), 500) - } else { - io.WriteString(w, "STUN packets are now being handled.") - } - })) - errCh := make(chan error, 1) - go func() { - err := http.ListenAndServe(*flagHTTP, mux) - errCh <- err - }() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - select { - case err := <-errCh: - log.Printf("HTTP serve err: %v", err) - case sig := <-sigCh: - log.Printf("received signal: %s", sig) - } - -} diff --git a/control/controlbase/conn.go b/control/controlbase/conn.go index dc22212e887cb..5f6acfc8ba53a 100644 --- a/control/controlbase/conn.go +++ b/control/controlbase/conn.go @@ -16,9 +16,9 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/types/key" "golang.org/x/crypto/blake2s" chp "golang.org/x/crypto/chacha20poly1305" - "tailscale.com/types/key" ) const ( diff --git a/control/controlbase/conn_test.go b/control/controlbase/conn_test.go deleted file mode 100644 index 8a0f46967e342..0000000000000 --- a/control/controlbase/conn_test.go +++ /dev/null @@ -1,390 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlbase - -import ( - "bufio" - "bytes" - "context" - "encoding/binary" - "fmt" - "io" - "net" - "runtime" - "strings" - "sync" - "testing" - "testing/iotest" - "time" - - chp "golang.org/x/crypto/chacha20poly1305" - "golang.org/x/net/nettest" - "tailscale.com/net/memnet" - "tailscale.com/types/key" -) - -const testProtocolVersion = 1 - -func TestMessageSize(t *testing.T) { - // This test is a regression guard against someone looking at - // maxCiphertextSize, going "huh, we could be more efficient if it - // were larger, and accidentally violating the Noise spec. Do not - // change this max value, it's a deliberate limitation of the - // cryptographic protocol we use (see Section 3 "Message Format" - // of the Noise spec). - const max = 65535 - if maxCiphertextSize > max { - t.Fatalf("max ciphertext size is %d, which is larger than the maximum noise message size %d", maxCiphertextSize, max) - } -} - -func TestConnBasic(t *testing.T) { - client, server := pair(t) - - sb := sinkReads(server) - - want := "test" - if _, err := io.WriteString(client, want); err != nil { - t.Fatalf("client write failed: %v", err) - } - client.Close() - - if got := sb.String(4); got != want { - t.Fatalf("wrong content received: got %q, want %q", got, want) - } - if err := sb.Error(); err != io.EOF { - t.Fatal("client close wasn't seen by server") - } - if sb.Total() != 4 { - t.Fatalf("wrong amount of bytes received: got %d, want 4", sb.Total()) - } -} - -// bufferedWriteConn wraps a net.Conn and gives control over how -// Writes get batched out. -type bufferedWriteConn struct { - net.Conn - w *bufio.Writer - manualFlush bool -} - -func (c *bufferedWriteConn) Write(bs []byte) (int, error) { - n, err := c.w.Write(bs) - if err == nil && !c.manualFlush { - err = c.w.Flush() - } - return n, err -} - -// TestFastPath exercises the Read codepath that can receive multiple -// Noise frames at once and decode each in turn without making another -// syscall. -func TestFastPath(t *testing.T) { - s1, s2 := memnet.NewConn("noise", 128000) - b := &bufferedWriteConn{s1, bufio.NewWriterSize(s1, 10000), false} - client, server := pairWithConns(t, b, s2) - - b.manualFlush = true - - sb := sinkReads(server) - - const packets = 10 - s := "test" - for range packets { - // Many separate writes, to force separate Noise frames that - // all get buffered up and then all sent as a single slice to - // the server. - if _, err := io.WriteString(client, s); err != nil { - t.Fatalf("client write1 failed: %v", err) - } - } - if err := b.w.Flush(); err != nil { - t.Fatalf("client flush failed: %v", err) - } - client.Close() - - want := strings.Repeat(s, packets) - if got := sb.String(len(want)); got != want { - t.Fatalf("wrong content received: got %q, want %q", got, want) - } - if err := sb.Error(); err != io.EOF { - t.Fatalf("client close wasn't seen by server") - } -} - -// Writes things larger than a single Noise frame, to check the -// chunking on the encoder and decoder. -func TestBigData(t *testing.T) { - client, server := pair(t) - - serverReads := sinkReads(server) - clientReads := sinkReads(client) - - const sz = 15 * 1024 // 15KiB - clientStr := strings.Repeat("abcde", sz/5) - serverStr := strings.Repeat("fghij", sz/5*2) - - if _, err := io.WriteString(client, clientStr); err != nil { - t.Fatalf("writing client>server: %v", err) - } - if _, err := io.WriteString(server, serverStr); err != nil { - t.Fatalf("writing server>client: %v", err) - } - - if serverGot := serverReads.String(sz); serverGot != clientStr { - t.Error("server didn't receive what client sent") - } - if clientGot := clientReads.String(2 * sz); clientGot != serverStr { - t.Error("client didn't receive what server sent") - } - - getNonce := func(n [chp.NonceSize]byte) uint64 { - if binary.BigEndian.Uint32(n[:4]) != 0 { - panic("unexpected nonce") - } - return binary.BigEndian.Uint64(n[4:]) - } - - // Reach into the Conns and verify the cipher nonces advanced as - // expected. - if getNonce(client.tx.nonce) != getNonce(server.rx.nonce) { - t.Error("desynchronized client tx nonce") - } - if getNonce(server.tx.nonce) != getNonce(client.rx.nonce) { - t.Error("desynchronized server tx nonce") - } - if n := getNonce(client.tx.nonce); n != 4 { - t.Errorf("wrong client tx nonce, got %d want 4", n) - } - if n := getNonce(server.tx.nonce); n != 8 { - t.Errorf("wrong client tx nonce, got %d want 8", n) - } -} - -// readerConn wraps a net.Conn and routes its Reads through a separate -// io.Reader. -type readerConn struct { - net.Conn - r io.Reader -} - -func (c readerConn) Read(bs []byte) (int, error) { return c.r.Read(bs) } - -// Check that the receiver can handle not being able to read an entire -// frame in a single syscall. -func TestDataTrickle(t *testing.T) { - s1, s2 := memnet.NewConn("noise", 128000) - client, server := pairWithConns(t, s1, readerConn{s2, iotest.OneByteReader(s2)}) - serverReads := sinkReads(server) - - const sz = 10000 - clientStr := strings.Repeat("abcde", sz/5) - if _, err := io.WriteString(client, clientStr); err != nil { - t.Fatalf("writing client>server: %v", err) - } - - serverGot := serverReads.String(sz) - if serverGot != clientStr { - t.Error("server didn't receive what client sent") - } -} - -func TestConnStd(t *testing.T) { - // You can run this test manually, and noise.Conn should pass all - // of them except for TestConn/PastTimeout, - // TestConn/FutureTimeout, TestConn/ConcurrentMethods, because - // those tests assume that write errors are recoverable, and - // they're not on our Conn due to cipher security. - t.Skip("not all tests can pass on this Conn, see https://github.com/golang/go/issues/46977") - nettest.TestConn(t, func() (c1 net.Conn, c2 net.Conn, stop func(), err error) { - s1, s2 := memnet.NewConn("noise", 4096) - controlKey := key.NewMachine() - machineKey := key.NewMachine() - serverErr := make(chan error, 1) - go func() { - var err error - c2, err = Server(context.Background(), s2, controlKey, nil) - serverErr <- err - }() - c1, err = Client(context.Background(), s1, machineKey, controlKey.Public(), testProtocolVersion) - if err != nil { - s1.Close() - s2.Close() - return nil, nil, nil, fmt.Errorf("connecting client: %w", err) - } - if err := <-serverErr; err != nil { - c1.Close() - s1.Close() - s2.Close() - return nil, nil, nil, fmt.Errorf("connecting server: %w", err) - } - return c1, c2, func() { - c1.Close() - c2.Close() - }, nil - }) -} - -// tests that the idle memory overhead of a Conn blocked in a read is -// reasonable (under 2K). It was previously over 8KB with two 4KB -// buffers for rx/tx. This make sure we don't regress. Hopefully it -// doesn't turn into a flaky test. If so, const max can be adjusted, -// or it can be deleted or reworked. -func TestConnMemoryOverhead(t *testing.T) { - num := 1000 - if testing.Short() { - num = 100 - } - ng0 := runtime.NumGoroutine() - - runtime.GC() - var ms0 runtime.MemStats - runtime.ReadMemStats(&ms0) - - var closers []io.Closer - closeAll := func() { - for _, c := range closers { - c.Close() - } - closers = nil - } - defer closeAll() - - for range num { - client, server := pair(t) - closers = append(closers, client, server) - go func() { - var buf [1]byte - client.Read(buf[:]) - }() - } - - t0 := time.Now() - deadline := t0.Add(3 * time.Second) - var ngo int - for time.Now().Before(deadline) { - runtime.GC() - ngo = runtime.NumGoroutine() - if ngo >= num { - break - } - time.Sleep(10 * time.Millisecond) - } - if ngo < num { - t.Fatalf("only %v goroutines; expected %v+", ngo, num) - } - runtime.GC() - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - growthTotal := int64(ms.HeapAlloc) - int64(ms0.HeapAlloc) - growthEach := float64(growthTotal) / float64(num) - t.Logf("Alloced %v bytes, %.2f B/each", growthTotal, growthEach) - const max = 2000 - if growthEach > max { - t.Errorf("allocated more than expected; want max %v bytes/each", max) - } - - closeAll() - - // And make sure our goroutines go away too. - deadline = time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - ngo = runtime.NumGoroutine() - if ngo < ng0+num/10 { - break - } - time.Sleep(10 * time.Millisecond) - } - if ngo >= ng0+num/10 { - t.Errorf("goroutines didn't go back down; started at %v, now %v", ng0, ngo) - } -} - -type readSink struct { - r io.Reader - - cond *sync.Cond - sync.Mutex - bs bytes.Buffer - err error -} - -func sinkReads(r io.Reader) *readSink { - ret := &readSink{ - r: r, - } - ret.cond = sync.NewCond(&ret.Mutex) - go func() { - var buf [4096]byte - for { - n, err := r.Read(buf[:]) - ret.Lock() - ret.bs.Write(buf[:n]) - if err != nil { - ret.err = err - } - ret.cond.Broadcast() - ret.Unlock() - if err != nil { - return - } - } - }() - return ret -} - -func (s *readSink) String(total int) string { - s.Lock() - defer s.Unlock() - for s.bs.Len() < total && s.err == nil { - s.cond.Wait() - } - if s.err != nil { - total = s.bs.Len() - } - return string(s.bs.Bytes()[:total]) -} - -func (s *readSink) Error() error { - s.Lock() - defer s.Unlock() - for s.err == nil { - s.cond.Wait() - } - return s.err -} - -func (s *readSink) Total() int { - s.Lock() - defer s.Unlock() - return s.bs.Len() -} - -func pairWithConns(t *testing.T, clientConn, serverConn net.Conn) (*Conn, *Conn) { - var ( - controlKey = key.NewMachine() - machineKey = key.NewMachine() - server *Conn - serverErr = make(chan error, 1) - ) - go func() { - var err error - server, err = Server(context.Background(), serverConn, controlKey, nil) - serverErr <- err - }() - - client, err := Client(context.Background(), clientConn, machineKey, controlKey.Public(), testProtocolVersion) - if err != nil { - t.Fatalf("client connection failed: %v", err) - } - if err := <-serverErr; err != nil { - t.Fatalf("server connection failed: %v", err) - } - return client, server -} - -func pair(t *testing.T) (*Conn, *Conn) { - s1, s2 := memnet.NewConn("noise", 128000) - return pairWithConns(t, s1, s2) -} diff --git a/control/controlbase/handshake.go b/control/controlbase/handshake.go index 765a4620b876f..6af753116eec1 100644 --- a/control/controlbase/handshake.go +++ b/control/controlbase/handshake.go @@ -15,12 +15,12 @@ import ( "strconv" "time" + "github.com/sagernet/tailscale/types/key" "go4.org/mem" "golang.org/x/crypto/blake2s" chp "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/hkdf" - "tailscale.com/types/key" ) const ( diff --git a/control/controlbase/handshake_test.go b/control/controlbase/handshake_test.go deleted file mode 100644 index 242b1f4d7c658..0000000000000 --- a/control/controlbase/handshake_test.go +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlbase - -import ( - "bytes" - "context" - "io" - "strings" - "testing" - "time" - - "tailscale.com/net/memnet" - "tailscale.com/types/key" -) - -func TestHandshake(t *testing.T) { - var ( - clientConn, serverConn = memnet.NewConn("noise", 128000) - serverKey = key.NewMachine() - clientKey = key.NewMachine() - server *Conn - serverErr = make(chan error, 1) - ) - go func() { - var err error - server, err = Server(context.Background(), serverConn, serverKey, nil) - serverErr <- err - }() - - client, err := Client(context.Background(), clientConn, clientKey, serverKey.Public(), testProtocolVersion) - if err != nil { - t.Fatalf("client connection failed: %v", err) - } - if err := <-serverErr; err != nil { - t.Fatalf("server connection failed: %v", err) - } - - if client.HandshakeHash() != server.HandshakeHash() { - t.Fatal("client and server disagree on handshake hash") - } - - if client.ProtocolVersion() != int(testProtocolVersion) { - t.Fatalf("client reporting wrong protocol version %d, want %d", client.ProtocolVersion(), testProtocolVersion) - } - if client.ProtocolVersion() != server.ProtocolVersion() { - t.Fatalf("peers disagree on protocol version, client=%d server=%d", client.ProtocolVersion(), server.ProtocolVersion()) - } - if client.Peer() != serverKey.Public() { - t.Fatal("client peer key isn't serverKey") - } - if server.Peer() != clientKey.Public() { - t.Fatal("client peer key isn't serverKey") - } -} - -// Check that handshaking repeatedly with the same long-term keys -// result in different handshake hashes and wire traffic. -func TestNoReuse(t *testing.T) { - var ( - hashes = map[[32]byte]bool{} - clientHandshakes = map[[96]byte]bool{} - serverHandshakes = map[[48]byte]bool{} - packets = map[[32]byte]bool{} - ) - for range 10 { - var ( - clientRaw, serverRaw = memnet.NewConn("noise", 128000) - clientBuf, serverBuf bytes.Buffer - clientConn = &readerConn{clientRaw, io.TeeReader(clientRaw, &clientBuf)} - serverConn = &readerConn{serverRaw, io.TeeReader(serverRaw, &serverBuf)} - serverKey = key.NewMachine() - clientKey = key.NewMachine() - server *Conn - serverErr = make(chan error, 1) - ) - go func() { - var err error - server, err = Server(context.Background(), serverConn, serverKey, nil) - serverErr <- err - }() - - client, err := Client(context.Background(), clientConn, clientKey, serverKey.Public(), testProtocolVersion) - if err != nil { - t.Fatalf("client connection failed: %v", err) - } - if err := <-serverErr; err != nil { - t.Fatalf("server connection failed: %v", err) - } - - var clientHS [96]byte - copy(clientHS[:], serverBuf.Bytes()) - if clientHandshakes[clientHS] { - t.Fatal("client handshake seen twice") - } - clientHandshakes[clientHS] = true - - var serverHS [48]byte - copy(serverHS[:], clientBuf.Bytes()) - if serverHandshakes[serverHS] { - t.Fatal("server handshake seen twice") - } - serverHandshakes[serverHS] = true - - clientBuf.Reset() - serverBuf.Reset() - cb := sinkReads(client) - sb := sinkReads(server) - - if hashes[client.HandshakeHash()] { - t.Fatalf("handshake hash %v seen twice", client.HandshakeHash()) - } - hashes[client.HandshakeHash()] = true - - // Sending 14 bytes turns into 32 bytes on the wire (+16 for - // the chacha20poly1305 overhead, +2 length header) - if _, err := io.WriteString(client, strings.Repeat("a", 14)); err != nil { - t.Fatalf("client>server write failed: %v", err) - } - if _, err := io.WriteString(server, strings.Repeat("b", 14)); err != nil { - t.Fatalf("server>client write failed: %v", err) - } - - // Wait for the bytes to be read, so we know they've traveled end to end - cb.String(14) - sb.String(14) - - var clientWire, serverWire [32]byte - copy(clientWire[:], clientBuf.Bytes()) - copy(serverWire[:], serverBuf.Bytes()) - - if packets[clientWire] { - t.Fatalf("client wire traffic seen twice") - } - packets[clientWire] = true - if packets[serverWire] { - t.Fatalf("server wire traffic seen twice") - } - packets[serverWire] = true - - server.Close() - client.Close() - } -} - -// tamperReader wraps a reader and mutates the Nth byte. -type tamperReader struct { - r io.Reader - n int - total int -} - -func (r *tamperReader) Read(bs []byte) (int, error) { - n, err := r.r.Read(bs) - if off := r.n - r.total; off >= 0 && off < n { - bs[off] += 1 - } - r.total += n - return n, err -} - -func TestTampering(t *testing.T) { - // Tamper with every byte of the client initiation message. - for i := range 101 { - var ( - clientConn, serverRaw = memnet.NewConn("noise", 128000) - serverConn = &readerConn{serverRaw, &tamperReader{serverRaw, i, 0}} - serverKey = key.NewMachine() - clientKey = key.NewMachine() - serverErr = make(chan error, 1) - ) - go func() { - _, err := Server(context.Background(), serverConn, serverKey, nil) - // If the server failed, we have to close the Conn to - // unblock the client. - if err != nil { - serverConn.Close() - } - serverErr <- err - }() - - _, err := Client(context.Background(), clientConn, clientKey, serverKey.Public(), testProtocolVersion) - if err == nil { - t.Fatal("client connection succeeded despite tampering") - } - if err := <-serverErr; err == nil { - t.Fatalf("server connection succeeded despite tampering") - } - } - - // Tamper with every byte of the server response message. - for i := range 51 { - var ( - clientRaw, serverConn = memnet.NewConn("noise", 128000) - clientConn = &readerConn{clientRaw, &tamperReader{clientRaw, i, 0}} - serverKey = key.NewMachine() - clientKey = key.NewMachine() - serverErr = make(chan error, 1) - ) - go func() { - _, err := Server(context.Background(), serverConn, serverKey, nil) - serverErr <- err - }() - - _, err := Client(context.Background(), clientConn, clientKey, serverKey.Public(), testProtocolVersion) - if err == nil { - t.Fatal("client connection succeeded despite tampering") - } - // The server shouldn't fail, because the tampering took place - // in its response. - if err := <-serverErr; err != nil { - t.Fatalf("server connection failed despite no tampering: %v", err) - } - } - - // Tamper with every byte of the first server>client transport message. - for i := range 30 { - var ( - clientRaw, serverConn = memnet.NewConn("noise", 128000) - clientConn = &readerConn{clientRaw, &tamperReader{clientRaw, 51 + i, 0}} - serverKey = key.NewMachine() - clientKey = key.NewMachine() - serverErr = make(chan error, 1) - ) - go func() { - server, err := Server(context.Background(), serverConn, serverKey, nil) - serverErr <- err - _, err = io.WriteString(server, strings.Repeat("a", 14)) - serverErr <- err - }() - - client, err := Client(context.Background(), clientConn, clientKey, serverKey.Public(), testProtocolVersion) - if err != nil { - t.Fatalf("client handshake failed: %v", err) - } - // The server shouldn't fail, because the tampering took place - // in its response. - if err := <-serverErr; err != nil { - t.Fatalf("server handshake failed: %v", err) - } - - // The client needs a timeout if the tampering is hitting the length header. - if i == 1 || i == 2 { - client.SetReadDeadline(time.Now().Add(10 * time.Millisecond)) - } - - var bs [100]byte - n, err := client.Read(bs[:]) - if err == nil { - t.Fatal("read succeeded despite tampering") - } - if n != 0 { - t.Fatal("conn yielded some bytes despite tampering") - } - } - - // Tamper with every byte of the first client>server transport message. - for i := range 30 { - var ( - clientConn, serverRaw = memnet.NewConn("noise", 128000) - serverConn = &readerConn{serverRaw, &tamperReader{serverRaw, 101 + i, 0}} - serverKey = key.NewMachine() - clientKey = key.NewMachine() - serverErr = make(chan error, 1) - ) - go func() { - server, err := Server(context.Background(), serverConn, serverKey, nil) - serverErr <- err - var bs [100]byte - // The server needs a timeout if the tampering is hitting the length header. - if i == 1 || i == 2 { - server.SetReadDeadline(time.Now().Add(10 * time.Millisecond)) - } - n, err := server.Read(bs[:]) - if n != 0 { - panic("server got bytes despite tampering") - } else { - serverErr <- err - } - }() - - client, err := Client(context.Background(), clientConn, clientKey, serverKey.Public(), testProtocolVersion) - if err != nil { - t.Fatalf("client handshake failed: %v", err) - } - if err := <-serverErr; err != nil { - t.Fatalf("server handshake failed: %v", err) - } - - if _, err := io.WriteString(client, strings.Repeat("a", 14)); err != nil { - t.Fatalf("client>server write failed: %v", err) - } - if err := <-serverErr; err == nil { - t.Fatal("server successfully received bytes despite tampering") - } - } -} diff --git a/control/controlbase/interop_test.go b/control/controlbase/interop_test.go deleted file mode 100644 index c41fbf4dd4950..0000000000000 --- a/control/controlbase/interop_test.go +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlbase - -import ( - "context" - "encoding/binary" - "errors" - "io" - "net" - "testing" - - "tailscale.com/net/memnet" - "tailscale.com/types/key" -) - -// Can a reference Noise IK client talk to our server? -func TestInteropClient(t *testing.T) { - var ( - s1, s2 = memnet.NewConn("noise", 128000) - controlKey = key.NewMachine() - machineKey = key.NewMachine() - serverErr = make(chan error, 2) - serverBytes = make(chan []byte, 1) - c2s = "client>server" - s2c = "server>client" - ) - - go func() { - server, err := Server(context.Background(), s2, controlKey, nil) - serverErr <- err - if err != nil { - return - } - var buf [1024]byte - _, err = io.ReadFull(server, buf[:len(c2s)]) - serverBytes <- buf[:len(c2s)] - if err != nil { - serverErr <- err - return - } - _, err = server.Write([]byte(s2c)) - serverErr <- err - }() - - gotS2C, err := noiseExplorerClient(s1, controlKey.Public(), machineKey, []byte(c2s)) - if err != nil { - t.Fatalf("failed client interop: %v", err) - } - if string(gotS2C) != s2c { - t.Fatalf("server sent unexpected data %q, want %q", string(gotS2C), s2c) - } - - if err := <-serverErr; err != nil { - t.Fatalf("server handshake failed: %v", err) - } - if err := <-serverErr; err != nil { - t.Fatalf("server read/write failed: %v", err) - } - if got := string(<-serverBytes); got != c2s { - t.Fatalf("server received %q, want %q", got, c2s) - } -} - -// Can our client talk to a reference Noise IK server? -func TestInteropServer(t *testing.T) { - var ( - s1, s2 = memnet.NewConn("noise", 128000) - controlKey = key.NewMachine() - machineKey = key.NewMachine() - clientErr = make(chan error, 2) - clientBytes = make(chan []byte, 1) - c2s = "client>server" - s2c = "server>client" - ) - - go func() { - client, err := Client(context.Background(), s1, machineKey, controlKey.Public(), testProtocolVersion) - clientErr <- err - if err != nil { - return - } - _, err = client.Write([]byte(c2s)) - if err != nil { - clientErr <- err - return - } - var buf [1024]byte - _, err = io.ReadFull(client, buf[:len(s2c)]) - clientBytes <- buf[:len(s2c)] - clientErr <- err - }() - - gotC2S, err := noiseExplorerServer(s2, controlKey, machineKey.Public(), []byte(s2c)) - if err != nil { - t.Fatalf("failed server interop: %v", err) - } - if string(gotC2S) != c2s { - t.Fatalf("server sent unexpected data %q, want %q", string(gotC2S), c2s) - } - - if err := <-clientErr; err != nil { - t.Fatalf("client handshake failed: %v", err) - } - if err := <-clientErr; err != nil { - t.Fatalf("client read/write failed: %v", err) - } - if got := string(<-clientBytes); got != s2c { - t.Fatalf("client received %q, want %q", got, s2c) - } -} - -// noiseExplorerClient uses the Noise Explorer implementation of Noise -// IK to handshake as a Noise client on conn, transmit payload, and -// read+return a payload from the peer. -func noiseExplorerClient(conn net.Conn, controlKey key.MachinePublic, machineKey key.MachinePrivate, payload []byte) ([]byte, error) { - var mk keypair - copy(mk.private_key[:], machineKey.UntypedBytes()) - copy(mk.public_key[:], machineKey.Public().UntypedBytes()) - var peerKey [32]byte - copy(peerKey[:], controlKey.UntypedBytes()) - session := InitSession(true, protocolVersionPrologue(testProtocolVersion), mk, peerKey) - - _, msg1 := SendMessage(&session, nil) - var hdr [initiationHeaderLen]byte - binary.BigEndian.PutUint16(hdr[:2], testProtocolVersion) - hdr[2] = msgTypeInitiation - binary.BigEndian.PutUint16(hdr[3:5], 96) - if _, err := conn.Write(hdr[:]); err != nil { - return nil, err - } - if _, err := conn.Write(msg1.ne[:]); err != nil { - return nil, err - } - if _, err := conn.Write(msg1.ns); err != nil { - return nil, err - } - if _, err := conn.Write(msg1.ciphertext); err != nil { - return nil, err - } - - var buf [1024]byte - if _, err := io.ReadFull(conn, buf[:51]); err != nil { - return nil, err - } - // ignore the header for this test, we're only checking the noise - // implementation. - msg2 := messagebuffer{ - ciphertext: buf[35:51], - } - copy(msg2.ne[:], buf[3:35]) - _, p, valid := RecvMessage(&session, &msg2) - if !valid { - return nil, errors.New("handshake failed") - } - if len(p) != 0 { - return nil, errors.New("non-empty payload") - } - - _, msg3 := SendMessage(&session, payload) - hdr[0] = msgTypeRecord - binary.BigEndian.PutUint16(hdr[1:3], uint16(len(msg3.ciphertext))) - if _, err := conn.Write(hdr[:3]); err != nil { - return nil, err - } - if _, err := conn.Write(msg3.ciphertext); err != nil { - return nil, err - } - - if _, err := io.ReadFull(conn, buf[:3]); err != nil { - return nil, err - } - // Ignore all of the header except the payload length - plen := int(binary.BigEndian.Uint16(buf[1:3])) - if _, err := io.ReadFull(conn, buf[:plen]); err != nil { - return nil, err - } - - msg4 := messagebuffer{ - ciphertext: buf[:plen], - } - _, p, valid = RecvMessage(&session, &msg4) - if !valid { - return nil, errors.New("transport message decryption failed") - } - - return p, nil -} - -func noiseExplorerServer(conn net.Conn, controlKey key.MachinePrivate, wantMachineKey key.MachinePublic, payload []byte) ([]byte, error) { - var mk keypair - copy(mk.private_key[:], controlKey.UntypedBytes()) - copy(mk.public_key[:], controlKey.Public().UntypedBytes()) - session := InitSession(false, protocolVersionPrologue(testProtocolVersion), mk, [32]byte{}) - - var buf [1024]byte - if _, err := io.ReadFull(conn, buf[:101]); err != nil { - return nil, err - } - // Ignore the header, we're just checking the noise implementation. - msg1 := messagebuffer{ - ns: buf[37:85], - ciphertext: buf[85:101], - } - copy(msg1.ne[:], buf[5:37]) - _, p, valid := RecvMessage(&session, &msg1) - if !valid { - return nil, errors.New("handshake failed") - } - if len(p) != 0 { - return nil, errors.New("non-empty payload") - } - - _, msg2 := SendMessage(&session, nil) - var hdr [headerLen]byte - hdr[0] = msgTypeResponse - binary.BigEndian.PutUint16(hdr[1:3], 48) - if _, err := conn.Write(hdr[:]); err != nil { - return nil, err - } - if _, err := conn.Write(msg2.ne[:]); err != nil { - return nil, err - } - if _, err := conn.Write(msg2.ciphertext[:]); err != nil { - return nil, err - } - - if _, err := io.ReadFull(conn, buf[:3]); err != nil { - return nil, err - } - plen := int(binary.BigEndian.Uint16(buf[1:3])) - if _, err := io.ReadFull(conn, buf[:plen]); err != nil { - return nil, err - } - - msg3 := messagebuffer{ - ciphertext: buf[:plen], - } - _, p, valid = RecvMessage(&session, &msg3) - if !valid { - return nil, errors.New("transport message decryption failed") - } - - _, msg4 := SendMessage(&session, payload) - hdr[0] = msgTypeRecord - binary.BigEndian.PutUint16(hdr[1:3], uint16(len(msg4.ciphertext))) - if _, err := conn.Write(hdr[:]); err != nil { - return nil, err - } - if _, err := conn.Write(msg4.ciphertext); err != nil { - return nil, err - } - - return p, nil -} diff --git a/control/controlbase/noiseexplorer_test.go b/control/controlbase/noiseexplorer_test.go deleted file mode 100644 index 76b6f79be047b..0000000000000 --- a/control/controlbase/noiseexplorer_test.go +++ /dev/null @@ -1,443 +0,0 @@ -// This file contains the implementation of Noise IK from -// https://noiseexplorer.com/ . Unlike the rest of this repository, -// this file is licensed under the terms of the GNU GPL v3. See -// https://source.symbolic.software/noiseexplorer/noiseexplorer for -// more information. -// -// This file is used here to verify that Tailscale's implementation of -// Noise IK is interoperable with another implementation. -//lint:file-ignore SA4006 not our code. - -/* -IK: - <- s - ... - -> e, es, s, ss - <- e, ee, se - -> - <- -*/ - -// Implementation Version: 1.0.2 - -/* ---------------------------------------------------------------- * - * PARAMETERS * - * ---------------------------------------------------------------- */ - -package controlbase - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/binary" - "hash" - "io" - - "golang.org/x/crypto/blake2s" - "golang.org/x/crypto/chacha20poly1305" - "golang.org/x/crypto/curve25519" - "golang.org/x/crypto/hkdf" -) - -/* ---------------------------------------------------------------- * - * TYPES * - * ---------------------------------------------------------------- */ - -type keypair struct { - public_key [32]byte - private_key [32]byte -} - -type messagebuffer struct { - ne [32]byte - ns []byte - ciphertext []byte -} - -type cipherstate struct { - k [32]byte - n uint32 -} - -type symmetricstate struct { - cs cipherstate - ck [32]byte - h [32]byte -} - -type handshakestate struct { - ss symmetricstate - s keypair - e keypair - rs [32]byte - re [32]byte - psk [32]byte -} - -type noisesession struct { - hs handshakestate - h [32]byte - cs1 cipherstate - cs2 cipherstate - mc uint64 - i bool -} - -/* ---------------------------------------------------------------- * - * CONSTANTS * - * ---------------------------------------------------------------- */ - -var emptyKey = [32]byte{ - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, -} - -var minNonce = uint32(0) - -/* ---------------------------------------------------------------- * - * UTILITY FUNCTIONS * - * ---------------------------------------------------------------- */ - -func isEmptyKey(k [32]byte) bool { - return subtle.ConstantTimeCompare(k[:], emptyKey[:]) == 1 -} - -func validatePublicKey(k []byte) bool { - forbiddenCurveValues := [12][]byte{ - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {224, 235, 122, 124, 59, 65, 184, 174, 22, 86, 227, 250, 241, 159, 196, 106, 218, 9, 141, 235, 156, 50, 177, 253, 134, 98, 5, 22, 95, 73, 184, 0}, - {95, 156, 149, 188, 163, 80, 140, 36, 177, 208, 177, 85, 156, 131, 239, 91, 4, 68, 92, 196, 88, 28, 142, 134, 216, 34, 78, 221, 208, 159, 17, 87}, - {236, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}, - {237, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}, - {238, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}, - {205, 235, 122, 124, 59, 65, 184, 174, 22, 86, 227, 250, 241, 159, 196, 106, 218, 9, 141, 235, 156, 50, 177, 253, 134, 98, 5, 22, 95, 73, 184, 128}, - {76, 156, 149, 188, 163, 80, 140, 36, 177, 208, 177, 85, 156, 131, 239, 91, 4, 68, 92, 196, 88, 28, 142, 134, 216, 34, 78, 221, 208, 159, 17, 215}, - {217, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, - {218, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, - {219, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 25}, - } - - for _, testValue := range forbiddenCurveValues { - if subtle.ConstantTimeCompare(k[:], testValue[:]) == 1 { - panic("Invalid public key") - } - } - return true -} - -/* ---------------------------------------------------------------- * - * PRIMITIVES * - * ---------------------------------------------------------------- */ - -func incrementNonce(n uint32) uint32 { - return n + 1 -} - -func dh(private_key [32]byte, public_key [32]byte) [32]byte { - var ss [32]byte - curve25519.ScalarMult(&ss, &private_key, &public_key) - return ss -} - -func generateKeypair() keypair { - var public_key [32]byte - var private_key [32]byte - _, _ = rand.Read(private_key[:]) - curve25519.ScalarBaseMult(&public_key, &private_key) - if validatePublicKey(public_key[:]) { - return keypair{public_key, private_key} - } - return generateKeypair() -} - -func encrypt(k [32]byte, n uint32, ad []byte, plaintext []byte) []byte { - var nonce [12]byte - var ciphertext []byte - enc, _ := chacha20poly1305.New(k[:]) - binary.LittleEndian.PutUint32(nonce[4:], n) - ciphertext = enc.Seal(nil, nonce[:], plaintext, ad) - return ciphertext -} - -func decrypt(k [32]byte, n uint32, ad []byte, ciphertext []byte) (bool, []byte, []byte) { - var nonce [12]byte - var plaintext []byte - enc, err := chacha20poly1305.New(k[:]) - binary.LittleEndian.PutUint32(nonce[4:], n) - plaintext, err = enc.Open(nil, nonce[:], ciphertext, ad) - return (err == nil), ad, plaintext -} - -func getHash(a []byte, b []byte) [32]byte { - return blake2s.Sum256(append(a, b...)) -} - -func hashProtocolName(protocolName []byte) [32]byte { - var h [32]byte - if len(protocolName) <= 32 { - copy(h[:], protocolName) - } else { - h = getHash(protocolName, []byte{}) - } - return h -} - -func blake2HkdfInterface() hash.Hash { - h, _ := blake2s.New256([]byte{}) - return h -} - -func getHkdf(ck [32]byte, ikm []byte) ([32]byte, [32]byte, [32]byte) { - var k1 [32]byte - var k2 [32]byte - var k3 [32]byte - output := hkdf.New(blake2HkdfInterface, ikm[:], ck[:], []byte{}) - io.ReadFull(output, k1[:]) - io.ReadFull(output, k2[:]) - io.ReadFull(output, k3[:]) - return k1, k2, k3 -} - -/* ---------------------------------------------------------------- * - * STATE MANAGEMENT * - * ---------------------------------------------------------------- */ - -/* CipherState */ -func initializeKey(k [32]byte) cipherstate { - return cipherstate{k, minNonce} -} - -func hasKey(cs *cipherstate) bool { - return !isEmptyKey(cs.k) -} - -func setNonce(cs *cipherstate, newNonce uint32) *cipherstate { - cs.n = newNonce - return cs -} - -func encryptWithAd(cs *cipherstate, ad []byte, plaintext []byte) (*cipherstate, []byte) { - e := encrypt(cs.k, cs.n, ad, plaintext) - cs = setNonce(cs, incrementNonce(cs.n)) - return cs, e -} - -func decryptWithAd(cs *cipherstate, ad []byte, ciphertext []byte) (*cipherstate, []byte, bool) { - valid, ad, plaintext := decrypt(cs.k, cs.n, ad, ciphertext) - cs = setNonce(cs, incrementNonce(cs.n)) - return cs, plaintext, valid -} - -/* SymmetricState */ - -func initializeSymmetric(protocolName []byte) symmetricstate { - h := hashProtocolName(protocolName) - ck := h - cs := initializeKey(emptyKey) - return symmetricstate{cs, ck, h} -} - -func mixKey(ss *symmetricstate, ikm [32]byte) *symmetricstate { - ck, tempK, _ := getHkdf(ss.ck, ikm[:]) - ss.cs = initializeKey(tempK) - ss.ck = ck - return ss -} - -func mixHash(ss *symmetricstate, data []byte) *symmetricstate { - ss.h = getHash(ss.h[:], data) - return ss -} - -func encryptAndHash(ss *symmetricstate, plaintext []byte) (*symmetricstate, []byte) { - var ciphertext []byte - if hasKey(&ss.cs) { - _, ciphertext = encryptWithAd(&ss.cs, ss.h[:], plaintext) - } else { - ciphertext = plaintext - } - ss = mixHash(ss, ciphertext) - return ss, ciphertext -} - -func decryptAndHash(ss *symmetricstate, ciphertext []byte) (*symmetricstate, []byte, bool) { - var plaintext []byte - var valid bool - if hasKey(&ss.cs) { - _, plaintext, valid = decryptWithAd(&ss.cs, ss.h[:], ciphertext) - } else { - plaintext, valid = ciphertext, true - } - ss = mixHash(ss, ciphertext) - return ss, plaintext, valid -} - -func split(ss *symmetricstate) (cipherstate, cipherstate) { - tempK1, tempK2, _ := getHkdf(ss.ck, []byte{}) - cs1 := initializeKey(tempK1) - cs2 := initializeKey(tempK2) - return cs1, cs2 -} - -/* HandshakeState */ - -func initializeInitiator(prologue []byte, s keypair, rs [32]byte, psk [32]byte) handshakestate { - var ss symmetricstate - var e keypair - var re [32]byte - name := []byte("Noise_IK_25519_ChaChaPoly_BLAKE2s") - ss = initializeSymmetric(name) - mixHash(&ss, prologue) - mixHash(&ss, rs[:]) - return handshakestate{ss, s, e, rs, re, psk} -} - -func initializeResponder(prologue []byte, s keypair, rs [32]byte, psk [32]byte) handshakestate { - var ss symmetricstate - var e keypair - var re [32]byte - name := []byte("Noise_IK_25519_ChaChaPoly_BLAKE2s") - ss = initializeSymmetric(name) - mixHash(&ss, prologue) - mixHash(&ss, s.public_key[:]) - return handshakestate{ss, s, e, rs, re, psk} -} - -func writeMessageA(hs *handshakestate, payload []byte) (*handshakestate, messagebuffer) { - ne, ns, ciphertext := emptyKey, []byte{}, []byte{} - hs.e = generateKeypair() - ne = hs.e.public_key - mixHash(&hs.ss, ne[:]) - /* No PSK, so skipping mixKey */ - mixKey(&hs.ss, dh(hs.e.private_key, hs.rs)) - spk := make([]byte, len(hs.s.public_key)) - copy(spk[:], hs.s.public_key[:]) - _, ns = encryptAndHash(&hs.ss, spk) - mixKey(&hs.ss, dh(hs.s.private_key, hs.rs)) - _, ciphertext = encryptAndHash(&hs.ss, payload) - messageBuffer := messagebuffer{ne, ns, ciphertext} - return hs, messageBuffer -} - -func writeMessageB(hs *handshakestate, payload []byte) ([32]byte, messagebuffer, cipherstate, cipherstate) { - ne, ns, ciphertext := emptyKey, []byte{}, []byte{} - hs.e = generateKeypair() - ne = hs.e.public_key - mixHash(&hs.ss, ne[:]) - /* No PSK, so skipping mixKey */ - mixKey(&hs.ss, dh(hs.e.private_key, hs.re)) - mixKey(&hs.ss, dh(hs.e.private_key, hs.rs)) - _, ciphertext = encryptAndHash(&hs.ss, payload) - messageBuffer := messagebuffer{ne, ns, ciphertext} - cs1, cs2 := split(&hs.ss) - return hs.ss.h, messageBuffer, cs1, cs2 -} - -func writeMessageRegular(cs *cipherstate, payload []byte) (*cipherstate, messagebuffer) { - ne, ns, ciphertext := emptyKey, []byte{}, []byte{} - cs, ciphertext = encryptWithAd(cs, []byte{}, payload) - messageBuffer := messagebuffer{ne, ns, ciphertext} - return cs, messageBuffer -} - -func readMessageA(hs *handshakestate, message *messagebuffer) (*handshakestate, []byte, bool) { - valid1 := true - if validatePublicKey(message.ne[:]) { - hs.re = message.ne - } - mixHash(&hs.ss, hs.re[:]) - /* No PSK, so skipping mixKey */ - mixKey(&hs.ss, dh(hs.s.private_key, hs.re)) - _, ns, valid1 := decryptAndHash(&hs.ss, message.ns) - if valid1 && len(ns) == 32 && validatePublicKey(message.ns[:]) { - copy(hs.rs[:], ns) - } - mixKey(&hs.ss, dh(hs.s.private_key, hs.rs)) - _, plaintext, valid2 := decryptAndHash(&hs.ss, message.ciphertext) - return hs, plaintext, (valid1 && valid2) -} - -func readMessageB(hs *handshakestate, message *messagebuffer) ([32]byte, []byte, bool, cipherstate, cipherstate) { - valid1 := true - if validatePublicKey(message.ne[:]) { - hs.re = message.ne - } - mixHash(&hs.ss, hs.re[:]) - /* No PSK, so skipping mixKey */ - mixKey(&hs.ss, dh(hs.e.private_key, hs.re)) - mixKey(&hs.ss, dh(hs.s.private_key, hs.re)) - _, plaintext, valid2 := decryptAndHash(&hs.ss, message.ciphertext) - cs1, cs2 := split(&hs.ss) - return hs.ss.h, plaintext, (valid1 && valid2), cs1, cs2 -} - -func readMessageRegular(cs *cipherstate, message *messagebuffer) (*cipherstate, []byte, bool) { - /* No encrypted keys */ - _, plaintext, valid2 := decryptWithAd(cs, []byte{}, message.ciphertext) - return cs, plaintext, valid2 -} - -/* ---------------------------------------------------------------- * - * PROCESSES * - * ---------------------------------------------------------------- */ - -func InitSession(initiator bool, prologue []byte, s keypair, rs [32]byte) noisesession { - var session noisesession - psk := emptyKey - if initiator { - session.hs = initializeInitiator(prologue, s, rs, psk) - } else { - session.hs = initializeResponder(prologue, s, rs, psk) - } - session.i = initiator - session.mc = 0 - return session -} - -func SendMessage(session *noisesession, message []byte) (*noisesession, messagebuffer) { - var messageBuffer messagebuffer - if session.mc == 0 { - _, messageBuffer = writeMessageA(&session.hs, message) - } - if session.mc == 1 { - session.h, messageBuffer, session.cs1, session.cs2 = writeMessageB(&session.hs, message) - session.hs = handshakestate{} - } - if session.mc > 1 { - if session.i { - _, messageBuffer = writeMessageRegular(&session.cs1, message) - } else { - _, messageBuffer = writeMessageRegular(&session.cs2, message) - } - } - session.mc = session.mc + 1 - return session, messageBuffer -} - -func RecvMessage(session *noisesession, message *messagebuffer) (*noisesession, []byte, bool) { - var plaintext []byte - var valid bool - if session.mc == 0 { - _, plaintext, valid = readMessageA(&session.hs, message) - } - if session.mc == 1 { - session.h, plaintext, valid, session.cs1, session.cs2 = readMessageB(&session.hs, message) - session.hs = handshakestate{} - } - if session.mc > 1 { - if session.i { - _, plaintext, valid = readMessageRegular(&session.cs2, message) - } else { - _, plaintext, valid = readMessageRegular(&session.cs1, message) - } - } - session.mc = session.mc + 1 - return session, plaintext, valid -} diff --git a/control/controlclient/auto.go b/control/controlclient/auto.go index edd0ae29c645d..afa4d10db6be3 100644 --- a/control/controlclient/auto.go +++ b/control/controlclient/auto.go @@ -12,16 +12,16 @@ import ( "sync/atomic" "time" - "tailscale.com/logtail/backoff" - "tailscale.com/net/sockstats" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/persist" - "tailscale.com/types/structs" - "tailscale.com/util/execqueue" + "github.com/sagernet/tailscale/logtail/backoff" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/structs" + "github.com/sagernet/tailscale/util/execqueue" ) type LoginGoal struct { diff --git a/control/controlclient/client.go b/control/controlclient/client.go index 8df64f9e8139a..ebdeece7bf8ab 100644 --- a/control/controlclient/client.go +++ b/control/controlclient/client.go @@ -11,7 +11,7 @@ package controlclient import ( "context" - "tailscale.com/tailcfg" + "github.com/sagernet/tailscale/tailcfg" ) // LoginFlags is a bitmask of options to change the behavior of Client.Login diff --git a/control/controlclient/controlclient_test.go b/control/controlclient/controlclient_test.go deleted file mode 100644 index b376234511c09..0000000000000 --- a/control/controlclient/controlclient_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlclient - -import ( - "reflect" - "testing" -) - -func fieldsOf(t reflect.Type) (fields []string) { - for i := range t.NumField() { - if name := t.Field(i).Name; name != "_" { - fields = append(fields, name) - } - } - return -} - -func TestStatusEqual(t *testing.T) { - // Verify that the Equal method stays in sync with reality - equalHandles := []string{"Err", "URL", "NetMap", "Persist", "state"} - if have := fieldsOf(reflect.TypeFor[Status]()); !reflect.DeepEqual(have, equalHandles) { - t.Errorf("Status.Equal check might be out of sync\nfields: %q\nhandled: %q\n", - have, equalHandles) - } - - tests := []struct { - a, b *Status - want bool - }{ - { - &Status{}, - nil, - false, - }, - { - nil, - &Status{}, - false, - }, - { - nil, - nil, - true, - }, - { - &Status{}, - &Status{}, - true, - }, - { - &Status{}, - &Status{state: StateAuthenticated}, - false, - }, - } - for i, tt := range tests { - got := tt.a.Equal(tt.b) - if got != tt.want { - t.Errorf("%d. Equal = %v; want %v", i, got, tt.want) - } - } -} diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 9cbd0e14ead52..f108739257b11 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -27,36 +27,36 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/logtail" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/dnsfallback" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/net/tlsdial" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/net/tshttpproxy" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/util/singleflight" + "github.com/sagernet/tailscale/util/syspolicy" + "github.com/sagernet/tailscale/util/systemd" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/util/zstdframe" "go4.org/mem" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/ipn/ipnstate" - "tailscale.com/logtail" - "tailscale.com/net/dnscache" - "tailscale.com/net/dnsfallback" - "tailscale.com/net/netmon" - "tailscale.com/net/netutil" - "tailscale.com/net/tlsdial" - "tailscale.com/net/tsdial" - "tailscale.com/net/tshttpproxy" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/persist" - "tailscale.com/types/ptr" - "tailscale.com/types/tkatype" - "tailscale.com/util/clientmetric" - "tailscale.com/util/multierr" - "tailscale.com/util/singleflight" - "tailscale.com/util/syspolicy" - "tailscale.com/util/systemd" - "tailscale.com/util/testenv" - "tailscale.com/util/zstdframe" ) // Direct is the client that connects to a tailcontrol server for a node. diff --git a/control/controlclient/direct_test.go b/control/controlclient/direct_test.go deleted file mode 100644 index e2a6f9fa4b93f..0000000000000 --- a/control/controlclient/direct_test.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlclient - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/netip" - "testing" - "time" - - "tailscale.com/hostinfo" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -func TestNewDirect(t *testing.T) { - hi := hostinfo.New() - ni := tailcfg.NetInfo{LinkType: "wired"} - hi.NetInfo = &ni - - k := key.NewMachine() - opts := Options{ - ServerURL: "https://example.com", - Hostinfo: hi, - GetMachinePrivateKey: func() (key.MachinePrivate, error) { - return k, nil - }, - Dialer: tsdial.NewDialer(netmon.NewStatic()), - } - c, err := NewDirect(opts) - if err != nil { - t.Fatal(err) - } - - if c.serverURL != opts.ServerURL { - t.Errorf("c.serverURL got %v want %v", c.serverURL, opts.ServerURL) - } - - // hi is stored without its NetInfo field. - hiWithoutNi := *hi - hiWithoutNi.NetInfo = nil - if !hiWithoutNi.Equal(c.hostinfo) { - t.Errorf("c.hostinfo got %v want %v", c.hostinfo, hi) - } - - changed := c.SetNetInfo(&ni) - if changed { - t.Errorf("c.SetNetInfo(ni) want false got %v", changed) - } - ni = tailcfg.NetInfo{LinkType: "wifi"} - changed = c.SetNetInfo(&ni) - if !changed { - t.Errorf("c.SetNetInfo(ni) want true got %v", changed) - } - - changed = c.SetHostinfo(hi) - if changed { - t.Errorf("c.SetHostinfo(hi) want false got %v", changed) - } - hi = hostinfo.New() - hi.Hostname = "different host name" - changed = c.SetHostinfo(hi) - if !changed { - t.Errorf("c.SetHostinfo(hi) want true got %v", changed) - } - - endpoints := fakeEndpoints(1, 2, 3) - changed = c.newEndpoints(endpoints) - if !changed { - t.Errorf("c.newEndpoints want true got %v", changed) - } - changed = c.newEndpoints(endpoints) - if changed { - t.Errorf("c.newEndpoints want false got %v", changed) - } - endpoints = fakeEndpoints(4, 5, 6) - changed = c.newEndpoints(endpoints) - if !changed { - t.Errorf("c.newEndpoints want true got %v", changed) - } -} - -func fakeEndpoints(ports ...uint16) (ret []tailcfg.Endpoint) { - for _, port := range ports { - ret = append(ret, tailcfg.Endpoint{ - Addr: netip.AddrPortFrom(netip.Addr{}, port), - }) - } - return -} - -func TestTsmpPing(t *testing.T) { - hi := hostinfo.New() - ni := tailcfg.NetInfo{LinkType: "wired"} - hi.NetInfo = &ni - - k := key.NewMachine() - opts := Options{ - ServerURL: "https://example.com", - Hostinfo: hi, - GetMachinePrivateKey: func() (key.MachinePrivate, error) { - return k, nil - }, - Dialer: tsdial.NewDialer(netmon.NewStatic()), - } - - c, err := NewDirect(opts) - if err != nil { - t.Fatal(err) - } - - pingRes := &tailcfg.PingResponse{ - Type: "TSMP", - IP: "123.456.7890", - Err: "", - NodeName: "testnode", - } - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - body := new(ipnstate.PingResult) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - if pingRes.IP != body.IP { - t.Fatalf("PingResult did not have the correct IP : got %v, expected : %v", body.IP, pingRes.IP) - } - w.WriteHeader(200) - })) - defer ts.Close() - - now := time.Now() - - pr := &tailcfg.PingRequest{ - URL: ts.URL, - } - - err = postPingResult(now, t.Logf, c.httpc, pr, pingRes) - if err != nil { - t.Fatal(err) - } -} diff --git a/control/controlclient/map.go b/control/controlclient/map.go index 7879122229e37..210c171c2e5e0 100644 --- a/control/controlclient/map.go +++ b/control/controlclient/map.go @@ -19,19 +19,19 @@ import ( "sync" "time" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/ptr" - "tailscale.com/types/views" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/wgengine/filter" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/wgengine/filter" ) // mapSession holds the state over a long-polled "map" request to the diff --git a/control/controlclient/map_test.go b/control/controlclient/map_test.go deleted file mode 100644 index 897036a942f49..0000000000000 --- a/control/controlclient/map_test.go +++ /dev/null @@ -1,1060 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlclient - -import ( - "context" - "encoding/json" - "fmt" - "net/netip" - "reflect" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "go4.org/mem" - "tailscale.com/control/controlknobs" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/tstime" - "tailscale.com/types/dnstype" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/ptr" - "tailscale.com/util/mak" - "tailscale.com/util/must" -) - -func eps(s ...string) []netip.AddrPort { - var eps []netip.AddrPort - for _, ep := range s { - eps = append(eps, netip.MustParseAddrPort(ep)) - } - return eps -} - -func TestUpdatePeersStateFromResponse(t *testing.T) { - var curTime time.Time - - online := func(v bool) func(*tailcfg.Node) { - return func(n *tailcfg.Node) { - n.Online = &v - } - } - seenAt := func(t time.Time) func(*tailcfg.Node) { - return func(n *tailcfg.Node) { - n.LastSeen = &t - } - } - withDERP := func(d string) func(*tailcfg.Node) { - return func(n *tailcfg.Node) { - n.DERP = d - } - } - withEP := func(ep string) func(*tailcfg.Node) { - return func(n *tailcfg.Node) { - n.Endpoints = []netip.AddrPort{netip.MustParseAddrPort(ep)} - } - } - n := func(id tailcfg.NodeID, name string, mod ...func(*tailcfg.Node)) *tailcfg.Node { - n := &tailcfg.Node{ID: id, Name: name} - for _, f := range mod { - f(n) - } - return n - } - peers := func(nv ...*tailcfg.Node) []*tailcfg.Node { return nv } - tests := []struct { - name string - mapRes *tailcfg.MapResponse - curTime time.Time - prev []*tailcfg.Node - want []*tailcfg.Node - wantStats updateStats - }{ - { - name: "full_peers", - mapRes: &tailcfg.MapResponse{ - Peers: peers(n(1, "foo"), n(2, "bar")), - }, - want: peers(n(1, "foo"), n(2, "bar")), - wantStats: updateStats{ - allNew: true, - added: 2, - }, - }, - { - name: "full_peers_ignores_deltas", - mapRes: &tailcfg.MapResponse{ - Peers: peers(n(1, "foo"), n(2, "bar")), - PeersRemoved: []tailcfg.NodeID{2}, - }, - want: peers(n(1, "foo"), n(2, "bar")), - wantStats: updateStats{ - allNew: true, - added: 2, - }, - }, - { - name: "add_and_update", - prev: peers(n(1, "foo"), n(2, "bar")), - mapRes: &tailcfg.MapResponse{ - PeersChanged: peers(n(0, "zero"), n(2, "bar2"), n(3, "three")), - }, - want: peers(n(0, "zero"), n(1, "foo"), n(2, "bar2"), n(3, "three")), - wantStats: updateStats{ - added: 2, // added IDs 0 and 3 - changed: 1, // changed ID 2 - }, - }, - { - name: "remove", - prev: peers(n(1, "foo"), n(2, "bar")), - mapRes: &tailcfg.MapResponse{ - PeersRemoved: []tailcfg.NodeID{1, 3, 4}, - }, - want: peers(n(2, "bar")), - wantStats: updateStats{ - removed: 1, // ID 1 - }, - }, - { - name: "add_and_remove", - prev: peers(n(1, "foo"), n(2, "bar")), - mapRes: &tailcfg.MapResponse{ - PeersChanged: peers(n(1, "foo2")), - PeersRemoved: []tailcfg.NodeID{2}, - }, - want: peers(n(1, "foo2")), - wantStats: updateStats{ - changed: 1, - removed: 1, - }, - }, - { - name: "unchanged", - prev: peers(n(1, "foo"), n(2, "bar")), - mapRes: &tailcfg.MapResponse{}, - want: peers(n(1, "foo"), n(2, "bar")), - }, - { - name: "online_change", - prev: peers(n(1, "foo"), n(2, "bar")), - mapRes: &tailcfg.MapResponse{ - OnlineChange: map[tailcfg.NodeID]bool{ - 1: true, - 404: true, - }, - }, - want: peers( - n(1, "foo", online(true)), - n(2, "bar"), - ), - wantStats: updateStats{changed: 1}, - }, - { - name: "online_change_offline", - prev: peers(n(1, "foo"), n(2, "bar")), - mapRes: &tailcfg.MapResponse{ - OnlineChange: map[tailcfg.NodeID]bool{ - 1: false, - 2: true, - }, - }, - want: peers( - n(1, "foo", online(false)), - n(2, "bar", online(true)), - ), - wantStats: updateStats{changed: 2}, - }, - { - name: "peer_seen_at", - prev: peers(n(1, "foo", seenAt(time.Unix(111, 0))), n(2, "bar")), - curTime: time.Unix(123, 0), - mapRes: &tailcfg.MapResponse{ - PeerSeenChange: map[tailcfg.NodeID]bool{ - 1: false, - 2: true, - }, - }, - want: peers( - n(1, "foo"), - n(2, "bar", seenAt(time.Unix(123, 0))), - ), - wantStats: updateStats{changed: 2}, - }, - { - name: "ep_change_derp", - prev: peers(n(1, "foo", withDERP("127.3.3.40:3"))), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - DERPRegion: 4, - }}, - }, - want: peers(n(1, "foo", withDERP("127.3.3.40:4"))), - wantStats: updateStats{changed: 1}, - }, - { - name: "ep_change_udp", - prev: peers(n(1, "foo", withEP("1.2.3.4:111"))), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - Endpoints: eps("1.2.3.4:56"), - }}, - }, - want: peers(n(1, "foo", withEP("1.2.3.4:56"))), - wantStats: updateStats{changed: 1}, - }, - { - name: "ep_change_udp_2", - prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - Endpoints: eps("1.2.3.4:56"), - }}, - }, - want: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:56"))), - wantStats: updateStats{changed: 1}, - }, - { - name: "ep_change_both", - prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - DERPRegion: 2, - Endpoints: eps("1.2.3.4:56"), - }}, - }, - want: peers(n(1, "foo", withDERP("127.3.3.40:2"), withEP("1.2.3.4:56"))), - wantStats: updateStats{changed: 1}, - }, - { - name: "change_key", - prev: peers(n(1, "foo")), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - Key: ptr.To(key.NodePublicFromRaw32(mem.B(append(make([]byte, 31), 'A')))), - }}, - }, want: peers(&tailcfg.Node{ - ID: 1, - Name: "foo", - Key: key.NodePublicFromRaw32(mem.B(append(make([]byte, 31), 'A'))), - }), - wantStats: updateStats{changed: 1}, - }, - { - name: "change_key_signature", - prev: peers(n(1, "foo")), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - KeySignature: []byte{3, 4}, - }}, - }, - want: peers(&tailcfg.Node{ - ID: 1, - Name: "foo", - KeySignature: []byte{3, 4}, - }), - wantStats: updateStats{changed: 1}, - }, - { - name: "change_disco_key", - prev: peers(n(1, "foo")), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - DiscoKey: ptr.To(key.DiscoPublicFromRaw32(mem.B(append(make([]byte, 31), 'A')))), - }}, - }, - want: peers(&tailcfg.Node{ - ID: 1, - Name: "foo", - DiscoKey: key.DiscoPublicFromRaw32(mem.B(append(make([]byte, 31), 'A'))), - }), - wantStats: updateStats{changed: 1}, - }, - { - name: "change_online", - prev: peers(n(1, "foo")), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - Online: ptr.To(true), - }}, - }, - want: peers(&tailcfg.Node{ - ID: 1, - Name: "foo", - Online: ptr.To(true), - }), - wantStats: updateStats{changed: 1}, - }, - { - name: "change_last_seen", - prev: peers(n(1, "foo")), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - LastSeen: ptr.To(time.Unix(123, 0).UTC()), - }}, - }, - want: peers(&tailcfg.Node{ - ID: 1, - Name: "foo", - LastSeen: ptr.To(time.Unix(123, 0).UTC()), - }), - wantStats: updateStats{changed: 1}, - }, - { - name: "change_key_expiry", - prev: peers(n(1, "foo")), - mapRes: &tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - KeyExpiry: ptr.To(time.Unix(123, 0).UTC()), - }}, - }, - want: peers(&tailcfg.Node{ - ID: 1, - Name: "foo", - KeyExpiry: time.Unix(123, 0).UTC(), - }), - wantStats: updateStats{changed: 1}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !tt.curTime.IsZero() { - curTime = tt.curTime - tstest.Replace(t, &clock, tstime.Clock(tstest.NewClock(tstest.ClockOpts{Start: curTime}))) - } - ms := newTestMapSession(t, nil) - for _, n := range tt.prev { - mak.Set(&ms.peers, n.ID, ptr.To(n.View())) - } - ms.rebuildSorted() - - gotStats := ms.updatePeersStateFromResponse(tt.mapRes) - - got := make([]*tailcfg.Node, len(ms.sortedPeers)) - for i, vp := range ms.sortedPeers { - got[i] = vp.AsStruct() - } - if gotStats != tt.wantStats { - t.Errorf("got stats = %+v; want %+v", gotStats, tt.wantStats) - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("wrong results\n got: %s\nwant: %s", formatNodes(got), formatNodes(tt.want)) - } - }) - } -} - -func formatNodes(nodes []*tailcfg.Node) string { - var sb strings.Builder - for i, n := range nodes { - if i > 0 { - sb.WriteString(", ") - } - fmt.Fprintf(&sb, "(%d, %q", n.ID, n.Name) - - if n.Online != nil { - fmt.Fprintf(&sb, ", online=%v", *n.Online) - } - if n.LastSeen != nil { - fmt.Fprintf(&sb, ", lastSeen=%v", n.LastSeen.Unix()) - } - if n.Key != (key.NodePublic{}) { - fmt.Fprintf(&sb, ", key=%v", n.Key.String()) - } - if n.Expired { - fmt.Fprintf(&sb, ", expired=true") - } - sb.WriteString(")") - } - return sb.String() -} - -func newTestMapSession(t testing.TB, nu NetmapUpdater) *mapSession { - ms := newMapSession(key.NewNode(), nu, new(controlknobs.Knobs)) - t.Cleanup(ms.Close) - ms.logf = t.Logf - return ms -} - -func (ms *mapSession) netmapForResponse(res *tailcfg.MapResponse) *netmap.NetworkMap { - ms.updateStateFromResponse(res) - return ms.netmap() -} - -func TestNetmapForResponse(t *testing.T) { - t.Run("implicit_packetfilter", func(t *testing.T) { - somePacketFilter := []tailcfg.FilterRule{ - { - SrcIPs: []string{"*"}, - DstPorts: []tailcfg.NetPortRange{ - {IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}}, - }, - }, - } - ms := newTestMapSession(t, nil) - nm1 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - PacketFilter: somePacketFilter, - }) - if len(nm1.PacketFilter) == 0 { - t.Fatalf("zero length PacketFilter") - } - nm2 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - PacketFilter: nil, // testing that the server can omit this. - }) - if len(nm1.PacketFilter) == 0 { - t.Fatalf("zero length PacketFilter in 2nd netmap") - } - if !reflect.DeepEqual(nm1.PacketFilter, nm2.PacketFilter) { - t.Error("packet filters differ") - } - }) - t.Run("implicit_dnsconfig", func(t *testing.T) { - someDNSConfig := &tailcfg.DNSConfig{Domains: []string{"foo", "bar"}} - ms := newTestMapSession(t, nil) - nm1 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - DNSConfig: someDNSConfig, - }) - if !reflect.DeepEqual(nm1.DNS, *someDNSConfig) { - t.Fatalf("1st DNS wrong") - } - nm2 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - DNSConfig: nil, // implicit - }) - if !reflect.DeepEqual(nm2.DNS, *someDNSConfig) { - t.Fatalf("2nd DNS wrong") - } - }) - t.Run("collect_services", func(t *testing.T) { - ms := newTestMapSession(t, nil) - var nm *netmap.NetworkMap - wantCollect := func(v bool) { - t.Helper() - if nm.CollectServices != v { - t.Errorf("netmap.CollectServices = %v; want %v", nm.CollectServices, v) - } - } - - nm = ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - }) - wantCollect(false) - - nm = ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - CollectServices: "false", - }) - wantCollect(false) - - nm = ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - CollectServices: "true", - }) - wantCollect(true) - - nm = ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - CollectServices: "", - }) - wantCollect(true) - }) - t.Run("implicit_domain", func(t *testing.T) { - ms := newTestMapSession(t, nil) - var nm *netmap.NetworkMap - want := func(v string) { - t.Helper() - if nm.Domain != v { - t.Errorf("netmap.Domain = %q; want %q", nm.Domain, v) - } - } - nm = ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - Domain: "foo.com", - }) - want("foo.com") - - nm = ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - }) - want("foo.com") - }) - t.Run("implicit_node", func(t *testing.T) { - someNode := &tailcfg.Node{ - Name: "foo", - } - wantNode := (&tailcfg.Node{ - Name: "foo", - ComputedName: "foo", - ComputedNameWithHost: "foo", - }).View() - ms := newTestMapSession(t, nil) - mapRes := &tailcfg.MapResponse{ - Node: someNode, - } - initDisplayNames(mapRes.Node.View(), mapRes) - ms.updateStateFromResponse(mapRes) - nm1 := ms.netmap() - if !nm1.SelfNode.Valid() { - t.Fatal("nil Node in 1st netmap") - } - if !reflect.DeepEqual(nm1.SelfNode, wantNode) { - j, _ := json.Marshal(nm1.SelfNode) - t.Errorf("Node mismatch in 1st netmap; got: %s", j) - } - - ms.updateStateFromResponse(&tailcfg.MapResponse{}) - nm2 := ms.netmap() - if !nm2.SelfNode.Valid() { - t.Fatal("nil Node in 1st netmap") - } - if !reflect.DeepEqual(nm2.SelfNode, wantNode) { - j, _ := json.Marshal(nm2.SelfNode) - t.Errorf("Node mismatch in 2nd netmap; got: %s", j) - } - }) - t.Run("named_packetfilter", func(t *testing.T) { - pfA := []tailcfg.FilterRule{ - { - SrcIPs: []string{"10.0.0.1"}, - DstPorts: []tailcfg.NetPortRange{ - {IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}}, - }, - }, - } - pfB := []tailcfg.FilterRule{ - { - SrcIPs: []string{"10.0.0.2"}, - DstPorts: []tailcfg.NetPortRange{ - {IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}}, - }, - }, - } - ms := newTestMapSession(t, nil) - - // Mix of old & new style (PacketFilter and PacketFilters). - nm1 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - PacketFilter: pfA, - PacketFilters: map[string][]tailcfg.FilterRule{ - "pf-b": pfB, - }, - }) - if got, want := len(nm1.PacketFilter), 2; got != want { - t.Fatalf("PacketFilter length = %v; want %v", got, want) - } - if got, want := first(nm1.PacketFilter[0].Srcs).String(), "10.0.0.1/32"; got != want { - t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want) - } - if got, want := first(nm1.PacketFilter[1].Srcs).String(), "10.0.0.2/32"; got != want { - t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want) - } - - // No-op change. Remember the old stuff. - nm2 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - PacketFilter: nil, - PacketFilters: nil, - }) - if got, want := len(nm2.PacketFilter), 2; got != want { - t.Fatalf("PacketFilter length = %v; want %v", got, want) - } - if !reflect.DeepEqual(nm1.PacketFilter, nm2.PacketFilter) { - t.Error("packet filters differ") - } - - // New style only, with clear. - nm3 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - PacketFilter: nil, - PacketFilters: map[string][]tailcfg.FilterRule{ - "*": nil, - "pf-b": pfB, - }, - }) - if got, want := len(nm3.PacketFilter), 1; got != want { - t.Fatalf("PacketFilter length = %v; want %v", got, want) - } - if got, want := first(nm3.PacketFilter[0].Srcs).String(), "10.0.0.2/32"; got != want { - t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want) - } - - // New style only, adding pfA back, not as the legacy "base" layer:. - nm4 := ms.netmapForResponse(&tailcfg.MapResponse{ - Node: new(tailcfg.Node), - PacketFilter: nil, - PacketFilters: map[string][]tailcfg.FilterRule{ - "pf-a": pfA, - }, - }) - if got, want := len(nm4.PacketFilter), 2; got != want { - t.Fatalf("PacketFilter length = %v; want %v", got, want) - } - if got, want := first(nm4.PacketFilter[0].Srcs).String(), "10.0.0.1/32"; got != want { - t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want) - } - if got, want := first(nm4.PacketFilter[1].Srcs).String(), "10.0.0.2/32"; got != want { - t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want) - } - }) -} - -func first[T any](s []T) T { - if len(s) == 0 { - var zero T - return zero - } - return s[0] -} - -func TestDeltaDERPMap(t *testing.T) { - regions1 := map[int]*tailcfg.DERPRegion{ - 1: { - RegionID: 1, - Nodes: []*tailcfg.DERPNode{{ - Name: "derp1a", - RegionID: 1, - HostName: "derp1a" + tailcfg.DotInvalid, - IPv4: "169.254.169.254", - IPv6: "none", - }}, - }, - } - - // As above, but with a changed IPv4 addr - regions2 := map[int]*tailcfg.DERPRegion{1: regions1[1].Clone()} - regions2[1].Nodes[0].IPv4 = "127.0.0.1" - - type step struct { - got *tailcfg.DERPMap - want *tailcfg.DERPMap - } - tests := []struct { - name string - steps []step - }{ - { - name: "nothing-to-nothing", - steps: []step{ - {nil, nil}, - {nil, nil}, - }, - }, - { - name: "regions-sticky", - steps: []step{ - {&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}}, - {&tailcfg.DERPMap{}, &tailcfg.DERPMap{Regions: regions1}}, - }, - }, - { - name: "regions-change", - steps: []step{ - {&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}}, - {&tailcfg.DERPMap{Regions: regions2}, &tailcfg.DERPMap{Regions: regions2}}, - }, - }, - { - name: "home-params", - steps: []step{ - // Send a DERP map - {&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}}, - // Send home params, want to still have the same regions - { - &tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{1: 0.5}, - }}, - &tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{1: 0.5}, - }}, - }, - }, - }, - { - name: "home-params-sub-fields", - steps: []step{ - // Send a DERP map with home params - { - &tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{1: 0.5}, - }}, - &tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{1: 0.5}, - }}, - }, - // Sending a struct with a 'HomeParams' field but nil RegionScore doesn't change home params... - { - &tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: nil}}, - &tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{1: 0.5}, - }}, - }, - // ... but sending one with a non-nil and empty RegionScore field zeroes that out. - { - &tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: map[int]float64{}}}, - &tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{}, - }}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ms := newTestMapSession(t, nil) - for stepi, s := range tt.steps { - nm := ms.netmapForResponse(&tailcfg.MapResponse{DERPMap: s.got}) - if !reflect.DeepEqual(nm.DERPMap, s.want) { - t.Errorf("unexpected result at step index %v; got: %s", stepi, logger.AsJSON(nm.DERPMap)) - } - } - }) - } -} - -func TestPeerChangeDiff(t *testing.T) { - tests := []struct { - name string - a, b *tailcfg.Node - want *tailcfg.PeerChange // nil means want ok=false, unless wantEqual is set - wantEqual bool // means test wants (nil, true) - }{ - { - name: "eq", - a: &tailcfg.Node{ID: 1}, - b: &tailcfg.Node{ID: 1}, - wantEqual: true, - }, - { - name: "patch-derp", - a: &tailcfg.Node{ID: 1, DERP: "127.3.3.40:1"}, - b: &tailcfg.Node{ID: 1, DERP: "127.3.3.40:2"}, - want: &tailcfg.PeerChange{NodeID: 1, DERPRegion: 2}, - }, - { - name: "patch-endpoints", - a: &tailcfg.Node{ID: 1, Endpoints: eps("10.0.0.1:1")}, - b: &tailcfg.Node{ID: 1, Endpoints: eps("10.0.0.2:2")}, - want: &tailcfg.PeerChange{NodeID: 1, Endpoints: eps("10.0.0.2:2")}, - }, - { - name: "patch-cap", - a: &tailcfg.Node{ID: 1, Cap: 1}, - b: &tailcfg.Node{ID: 1, Cap: 2}, - want: &tailcfg.PeerChange{NodeID: 1, Cap: 2}, - }, - { - name: "patch-lastseen", - a: &tailcfg.Node{ID: 1, LastSeen: ptr.To(time.Unix(1, 0))}, - b: &tailcfg.Node{ID: 1, LastSeen: ptr.To(time.Unix(2, 0))}, - want: &tailcfg.PeerChange{NodeID: 1, LastSeen: ptr.To(time.Unix(2, 0))}, - }, - { - name: "patch-online-to-true", - a: &tailcfg.Node{ID: 1, Online: ptr.To(false)}, - b: &tailcfg.Node{ID: 1, Online: ptr.To(true)}, - want: &tailcfg.PeerChange{NodeID: 1, Online: ptr.To(true)}, - }, - { - name: "patch-online-to-false", - a: &tailcfg.Node{ID: 1, Online: ptr.To(true)}, - b: &tailcfg.Node{ID: 1, Online: ptr.To(false)}, - want: &tailcfg.PeerChange{NodeID: 1, Online: ptr.To(false)}, - }, - { - name: "mix-patchable-and-not", - a: &tailcfg.Node{ID: 1, Cap: 1}, - b: &tailcfg.Node{ID: 1, Cap: 2, StableID: "foo"}, - want: nil, - }, - { - name: "miss-change-stableid", - a: &tailcfg.Node{ID: 1}, - b: &tailcfg.Node{ID: 1, StableID: "diff"}, - want: nil, - }, - { - name: "miss-change-id", - a: &tailcfg.Node{ID: 1}, - b: &tailcfg.Node{ID: 2}, - want: nil, - }, - { - name: "miss-change-name", - a: &tailcfg.Node{ID: 1, Name: "foo"}, - b: &tailcfg.Node{ID: 1, Name: "bar"}, - want: nil, - }, - { - name: "miss-change-user", - a: &tailcfg.Node{ID: 1, User: 1}, - b: &tailcfg.Node{ID: 1, User: 2}, - want: nil, - }, - { - name: "miss-change-masq-v4", - a: &tailcfg.Node{ID: 1, SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, - b: &tailcfg.Node{ID: 1, SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.2"))}, - want: nil, - }, - { - name: "miss-change-masq-v6", - a: &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))}, - b: &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3006"))}, - want: nil, - }, - { - name: "patch-capmap-add-value-to-existing-key", - a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}}, - want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}}, - }, - { - name: "patch-capmap-add-new-key", - a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}}, - want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}}, - }, { - name: "patch-capmap-remove-key", - a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{}}, - want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}}, - }, { - name: "patch-capmap-remove-as-nil", - a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - b: &tailcfg.Node{ID: 1}, - want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}}, - }, { - name: "patch-capmap-add-key-to-empty-map", - a: &tailcfg.Node{ID: 1}, - b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - }, - { - name: "patch-capmap-no-change", - a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}}, - wantEqual: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pc, ok := peerChangeDiff(tt.a.View(), tt.b) - if tt.wantEqual { - if !ok || pc != nil { - t.Errorf("got (%p, %v); want (nil, true); pc=%v", pc, ok, logger.AsJSON(pc)) - } - return - } - if (pc != nil) != ok { - t.Fatalf("inconsistent ok=%v, pc=%p", ok, pc) - } - if !reflect.DeepEqual(pc, tt.want) { - t.Errorf("mismatch\n got: %v\nwant: %v\n", logger.AsJSON(pc), logger.AsJSON(tt.want)) - } - }) - } -} - -func TestPeerChangeDiffAllocs(t *testing.T) { - a := &tailcfg.Node{ID: 1} - b := &tailcfg.Node{ID: 1} - n := testing.AllocsPerRun(10000, func() { - diff, ok := peerChangeDiff(a.View(), b) - if !ok || diff != nil { - t.Fatalf("unexpected result: (%s, %v)", logger.AsJSON(diff), ok) - } - }) - if n != 0 { - t.Errorf("allocs = %v; want 0", int(n)) - } -} - -type countingNetmapUpdater struct { - full atomic.Int64 -} - -func (nu *countingNetmapUpdater) UpdateFullNetmap(nm *netmap.NetworkMap) { - nu.full.Add(1) -} - -// tests (*mapSession).patchifyPeersChanged; smaller tests are in TestPeerChangeDiff -func TestPatchifyPeersChanged(t *testing.T) { - hi := (&tailcfg.Hostinfo{}).View() - tests := []struct { - name string - mr0 *tailcfg.MapResponse // initial - mr1 *tailcfg.MapResponse // incremental - want *tailcfg.MapResponse // what the incremental one should've been mutated to - }{ - { - name: "change_one_endpoint", - mr0: &tailcfg.MapResponse{ - Node: &tailcfg.Node{Name: "foo.bar.ts.net."}, - Peers: []*tailcfg.Node{ - {ID: 1, Hostinfo: hi}, - }, - }, - mr1: &tailcfg.MapResponse{ - PeersChanged: []*tailcfg.Node{ - {ID: 1, Endpoints: eps("10.0.0.1:1111"), Hostinfo: hi}, - }, - }, - want: &tailcfg.MapResponse{ - PeersChanged: nil, - PeersChangedPatch: []*tailcfg.PeerChange{ - {NodeID: 1, Endpoints: eps("10.0.0.1:1111")}, - }, - }, - }, - { - name: "change_some", - mr0: &tailcfg.MapResponse{ - Node: &tailcfg.Node{Name: "foo.bar.ts.net."}, - Peers: []*tailcfg.Node{ - {ID: 1, DERP: "127.3.3.40:1", Hostinfo: hi}, - {ID: 2, DERP: "127.3.3.40:2", Hostinfo: hi}, - {ID: 3, DERP: "127.3.3.40:3", Hostinfo: hi}, - }, - }, - mr1: &tailcfg.MapResponse{ - PeersChanged: []*tailcfg.Node{ - {ID: 1, DERP: "127.3.3.40:11", Hostinfo: hi}, - {ID: 2, StableID: "other-change", Hostinfo: hi}, - {ID: 3, DERP: "127.3.3.40:33", Hostinfo: hi}, - {ID: 4, DERP: "127.3.3.40:4", Hostinfo: hi}, - }, - }, - want: &tailcfg.MapResponse{ - PeersChanged: []*tailcfg.Node{ - {ID: 2, StableID: "other-change", Hostinfo: hi}, - {ID: 4, DERP: "127.3.3.40:4", Hostinfo: hi}, - }, - PeersChangedPatch: []*tailcfg.PeerChange{ - {NodeID: 1, DERPRegion: 11}, - {NodeID: 3, DERPRegion: 33}, - }, - }, - }, - { - name: "change_exitnodednsresolvers", - mr0: &tailcfg.MapResponse{ - Node: &tailcfg.Node{Name: "foo.bar.ts.net."}, - Peers: []*tailcfg.Node{ - {ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi}, - }, - }, - mr1: &tailcfg.MapResponse{ - PeersChanged: []*tailcfg.Node{ - {ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi}, - }, - }, - want: &tailcfg.MapResponse{ - PeersChanged: []*tailcfg.Node{ - {ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi}, - }, - }, - }, - { - name: "same_exitnoderesolvers", - mr0: &tailcfg.MapResponse{ - Node: &tailcfg.Node{Name: "foo.bar.ts.net."}, - Peers: []*tailcfg.Node{ - {ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi}, - }, - }, - mr1: &tailcfg.MapResponse{ - PeersChanged: []*tailcfg.Node{ - {ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi}, - }, - }, - want: &tailcfg.MapResponse{}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - nu := &countingNetmapUpdater{} - ms := newTestMapSession(t, nu) - ms.updateStateFromResponse(tt.mr0) - mr1 := new(tailcfg.MapResponse) - must.Do(json.Unmarshal(must.Get(json.Marshal(tt.mr1)), mr1)) - ms.patchifyPeersChanged(mr1) - opts := []cmp.Option{ - cmp.Comparer(func(a, b netip.AddrPort) bool { return a == b }), - } - if diff := cmp.Diff(tt.want, mr1, opts...); diff != "" { - t.Errorf("wrong result (-want +got):\n%s", diff) - } - }) - } -} - -func BenchmarkMapSessionDelta(b *testing.B) { - for _, size := range []int{10, 100, 1_000, 10_000} { - b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) { - ctx := context.Background() - nu := &countingNetmapUpdater{} - ms := newTestMapSession(b, nu) - res := &tailcfg.MapResponse{ - Node: &tailcfg.Node{ - ID: 1, - Name: "foo.bar.ts.net.", - }, - } - for i := range size { - res.Peers = append(res.Peers, &tailcfg.Node{ - ID: tailcfg.NodeID(i + 2), - Name: fmt.Sprintf("peer%d.bar.ts.net.", i), - DERP: "127.3.3.40:10", - Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.2.3/32"), netip.MustParsePrefix("fd7a:115c:a1e0::123/128")}, - AllowedIPs: []netip.Prefix{netip.MustParsePrefix("100.100.2.3/32"), netip.MustParsePrefix("fd7a:115c:a1e0::123/128")}, - Endpoints: eps("192.168.1.2:345", "192.168.1.3:678"), - Hostinfo: (&tailcfg.Hostinfo{ - OS: "fooOS", - Hostname: "MyHostname", - Services: []tailcfg.Service{ - {Proto: "peerapi4", Port: 1234}, - {Proto: "peerapi6", Port: 1234}, - {Proto: "peerapi-dns-proxy", Port: 1}, - }, - }).View(), - LastSeen: ptr.To(time.Unix(int64(i), 0)), - }) - } - ms.HandleNonKeepAliveMapResponse(ctx, res) - - b.ResetTimer() - b.ReportAllocs() - - // Now for the core of the benchmark loop, just toggle - // a single node's online status. - for i := range b.N { - if err := ms.HandleNonKeepAliveMapResponse(ctx, &tailcfg.MapResponse{ - OnlineChange: map[tailcfg.NodeID]bool{ - 2: i%2 == 0, - }, - }); err != nil { - b.Fatal(err) - } - } - }) - } -} diff --git a/control/controlclient/noise.go b/control/controlclient/noise.go index 2e7c70fd1b162..26591fb5770c3 100644 --- a/control/controlclient/noise.go +++ b/control/controlclient/noise.go @@ -15,20 +15,20 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/control/controlhttp" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/internal/noiseconn" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/util/singleflight" "golang.org/x/net/http2" - "tailscale.com/control/controlhttp" - "tailscale.com/health" - "tailscale.com/internal/noiseconn" - "tailscale.com/net/dnscache" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/mak" - "tailscale.com/util/multierr" - "tailscale.com/util/singleflight" ) // NoiseClient provides a http.Client to connect to tailcontrol over diff --git a/control/controlclient/noise_test.go b/control/controlclient/noise_test.go deleted file mode 100644 index 69a3a6a36551d..0000000000000 --- a/control/controlclient/noise_test.go +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlclient - -import ( - "context" - "encoding/binary" - "encoding/json" - "io" - "math" - "net/http" - "net/http/httptest" - "testing" - "time" - - "golang.org/x/net/http2" - "tailscale.com/control/controlhttp/controlhttpserver" - "tailscale.com/internal/noiseconn" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logger" -) - -// maxAllowedNoiseVersion is the highest we expect the Tailscale -// capability version to ever get. It's a value close to 2^16, but -// with enough leeway that we get a very early warning that it's time -// to rework the wire protocol to allow larger versions, while still -// giving us headroom to bump this test and fix the build. -// -// Code elsewhere in the client will panic() if the tailcfg capability -// version exceeds 16 bits, so take a failure of this test seriously. -const maxAllowedNoiseVersion = math.MaxUint16 - 5000 - -func TestNoiseVersion(t *testing.T) { - if tailcfg.CurrentCapabilityVersion > maxAllowedNoiseVersion { - t.Fatalf("tailcfg.CurrentCapabilityVersion is %d, want <=%d", tailcfg.CurrentCapabilityVersion, maxAllowedNoiseVersion) - } -} - -type noiseClientTest struct { - sendEarlyPayload bool -} - -func TestNoiseClientHTTP2Upgrade(t *testing.T) { - noiseClientTest{}.run(t) -} - -func TestNoiseClientHTTP2Upgrade_earlyPayload(t *testing.T) { - noiseClientTest{ - sendEarlyPayload: true, - }.run(t) -} - -func (tt noiseClientTest) run(t *testing.T) { - serverPrivate := key.NewMachine() - clientPrivate := key.NewMachine() - chalPrivate := key.NewChallenge() - - const msg = "Hello, client" - h2 := &http2.Server{} - hs := httptest.NewServer(&Upgrader{ - h2srv: h2, - noiseKeyPriv: serverPrivate, - sendEarlyPayload: tt.sendEarlyPayload, - challenge: chalPrivate, - httpBaseConfig: &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - io.WriteString(w, msg) - }), - }, - }) - defer hs.Close() - - dialer := tsdial.NewDialer(netmon.NewStatic()) - nc, err := NewNoiseClient(NoiseOpts{ - PrivKey: clientPrivate, - ServerPubKey: serverPrivate.Public(), - ServerURL: hs.URL, - Dialer: dialer, - }) - if err != nil { - t.Fatal(err) - } - - // Get a conn and verify it read its early payload before the http/2 - // handshake. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - c, err := nc.getConn(ctx) - if err != nil { - t.Fatal(err) - } - payload, err := c.GetEarlyPayload(ctx) - if err != nil { - t.Fatal("timed out waiting for didReadHeaderCh") - } - - gotNonNil := payload != nil - if gotNonNil != tt.sendEarlyPayload { - t.Errorf("sendEarlyPayload = %v but got earlyPayload = %T", tt.sendEarlyPayload, payload) - } - if payload != nil { - if payload.NodeKeyChallenge != chalPrivate.Public() { - t.Errorf("earlyPayload.NodeKeyChallenge = %v; want %v", payload.NodeKeyChallenge, chalPrivate.Public()) - } - } - - checkRes := func(t *testing.T, res *http.Response) { - t.Helper() - defer res.Body.Close() - all, err := io.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - if string(all) != msg { - t.Errorf("got response %q; want %q", all, msg) - } - } - - // And verify we can do HTTP/2 against that conn. - res, err := (&http.Client{Transport: c}).Get("https://unused.example/") - if err != nil { - t.Fatal(err) - } - checkRes(t, res) - - // And try using the high-level nc.post API as well. - res, err = nc.post(context.Background(), "/", key.NodePublic{}, nil) - if err != nil { - t.Fatal(err) - } - checkRes(t, res) -} - -// Upgrader is an http.Handler that hijacks and upgrades POST-with-Upgrade -// request to a Tailscale 2021 connection, then hands the resulting -// controlbase.Conn off to h2srv. -type Upgrader struct { - // h2srv is that will handle requests after the - // connection has been upgraded to HTTP/2-over-noise. - h2srv *http2.Server - - // httpBaseConfig is the http1 server config that h2srv is - // associated with. - httpBaseConfig *http.Server - - logf logger.Logf - - noiseKeyPriv key.MachinePrivate - challenge key.ChallengePrivate - - sendEarlyPayload bool -} - -func (up *Upgrader) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if up == nil || up.h2srv == nil { - http.Error(w, "invalid server config", http.StatusServiceUnavailable) - return - } - if r.URL.Path != "/ts2021" { - http.Error(w, "ts2021 upgrader installed at wrong path", http.StatusBadGateway) - return - } - if up.noiseKeyPriv.IsZero() { - http.Error(w, "keys not available", http.StatusServiceUnavailable) - return - } - - earlyWriteFn := func(protocolVersion int, w io.Writer) error { - if !up.sendEarlyPayload { - return nil - } - earlyJSON, err := json.Marshal(&tailcfg.EarlyNoise{ - NodeKeyChallenge: up.challenge.Public(), - }) - if err != nil { - return err - } - // 5 bytes that won't be mistaken for an HTTP/2 frame: - // https://httpwg.org/specs/rfc7540.html#rfc.section.4.1 (Especially not - // an HTTP/2 settings frame, which isn't of type 'T') - var notH2Frame [5]byte - copy(notH2Frame[:], noiseconn.EarlyPayloadMagic) - var lenBuf [4]byte - binary.BigEndian.PutUint32(lenBuf[:], uint32(len(earlyJSON))) - // These writes are all buffered by caller, so fine to do them - // separately: - if _, err := w.Write(notH2Frame[:]); err != nil { - return err - } - if _, err := w.Write(lenBuf[:]); err != nil { - return err - } - if _, err := w.Write(earlyJSON[:]); err != nil { - return err - } - return nil - } - - cbConn, err := controlhttpserver.AcceptHTTP(r.Context(), w, r, up.noiseKeyPriv, earlyWriteFn) - if err != nil { - up.logf("controlhttp: Accept: %v", err) - return - } - defer cbConn.Close() - - up.h2srv.ServeConn(cbConn, &http2.ServeConnOpts{ - BaseConfig: up.httpBaseConfig, - }) -} diff --git a/control/controlclient/sign.go b/control/controlclient/sign.go index e3a479c283c62..901690814c2a9 100644 --- a/control/controlclient/sign.go +++ b/control/controlclient/sign.go @@ -9,8 +9,8 @@ import ( "fmt" "time" - "tailscale.com/tailcfg" - "tailscale.com/types/key" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" ) var ( diff --git a/control/controlclient/sign_supported.go b/control/controlclient/sign_supported.go index 0e3dd038e4ed7..b08636ba42c8e 100644 --- a/control/controlclient/sign_supported.go +++ b/control/controlclient/sign_supported.go @@ -16,10 +16,10 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/util/syspolicy" "github.com/tailscale/certstore" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/util/syspolicy" ) var getMachineCertificateSubjectOnce struct { diff --git a/control/controlclient/sign_supported_test.go b/control/controlclient/sign_supported_test.go deleted file mode 100644 index e20349a4e82c3..0000000000000 --- a/control/controlclient/sign_supported_test.go +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build windows && cgo - -package controlclient - -import ( - "crypto" - "crypto/x509" - "crypto/x509/pkix" - "errors" - "reflect" - "testing" - "time" - - "github.com/tailscale/certstore" -) - -const ( - testRootCommonName = "testroot" - testRootSubject = "CN=testroot" -) - -type testIdentity struct { - chain []*x509.Certificate -} - -func makeChain(rootCommonName string, notBefore, notAfter time.Time) []*x509.Certificate { - return []*x509.Certificate{ - { - NotBefore: notBefore, - NotAfter: notAfter, - PublicKeyAlgorithm: x509.RSA, - }, - { - Subject: pkix.Name{ - CommonName: rootCommonName, - }, - PublicKeyAlgorithm: x509.RSA, - }, - } -} - -func (t *testIdentity) Certificate() (*x509.Certificate, error) { - return t.chain[0], nil -} - -func (t *testIdentity) CertificateChain() ([]*x509.Certificate, error) { - return t.chain, nil -} - -func (t *testIdentity) Signer() (crypto.Signer, error) { - return nil, errors.New("not implemented") -} - -func (t *testIdentity) Delete() error { - return errors.New("not implemented") -} - -func (t *testIdentity) Close() {} - -func TestSelectIdentityFromSlice(t *testing.T) { - var times []time.Time - for _, ts := range []string{ - "2000-01-01T00:00:00Z", - "2001-01-01T00:00:00Z", - "2002-01-01T00:00:00Z", - "2003-01-01T00:00:00Z", - } { - tm, err := time.Parse(time.RFC3339, ts) - if err != nil { - t.Fatal(err) - } - times = append(times, tm) - } - - tests := []struct { - name string - subject string - ids []certstore.Identity - now time.Time - // wantIndex is an index into ids, or -1 for nil. - wantIndex int - }{ - { - name: "single unexpired identity", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[2]), - }, - }, - now: times[1], - wantIndex: 0, - }, - { - name: "single expired identity", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - }, - now: times[2], - wantIndex: -1, - }, - { - name: "unrelated ids", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain("something", times[0], times[2]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[2]), - }, - &testIdentity{ - chain: makeChain("else", times[0], times[2]), - }, - }, - now: times[1], - wantIndex: 1, - }, - { - name: "expired with unrelated ids", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain("something", times[0], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - &testIdentity{ - chain: makeChain("else", times[0], times[3]), - }, - }, - now: times[2], - wantIndex: -1, - }, - { - name: "one expired", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[1], times[3]), - }, - }, - now: times[2], - wantIndex: 1, - }, - { - name: "two certs both unexpired", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[1], times[3]), - }, - }, - now: times[2], - wantIndex: 1, - }, - { - name: "two unexpired one expired", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[1], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - }, - now: times[2], - wantIndex: 1, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotId, gotChain := selectIdentityFromSlice(tt.subject, tt.ids, tt.now) - - if gotId == nil && gotChain != nil { - t.Error("id is nil: got non-nil chain, want nil chain") - return - } - if gotId != nil && gotChain == nil { - t.Error("id is not nil: got nil chain, want non-nil chain") - return - } - if tt.wantIndex == -1 { - if gotId != nil { - t.Error("got non-nil id, want nil id") - } - return - } - if gotId == nil { - t.Error("got nil id, want non-nil id") - return - } - if gotId != tt.ids[tt.wantIndex] { - found := -1 - for i := range tt.ids { - if tt.ids[i] == gotId { - found = i - break - } - } - if found == -1 { - t.Errorf("got unknown id, want id at index %v", tt.wantIndex) - } else { - t.Errorf("got id at index %v, want id at index %v", found, tt.wantIndex) - } - } - - tid, ok := tt.ids[tt.wantIndex].(*testIdentity) - if !ok { - t.Error("got non-testIdentity, want testIdentity") - return - } - - if !reflect.DeepEqual(tid.chain, gotChain) { - t.Errorf("got unknown chain, want chain from id at index %v", tt.wantIndex) - } - }) - } -} diff --git a/control/controlclient/sign_unsupported.go b/control/controlclient/sign_unsupported.go index 5e161dcbce453..b2d19914feddb 100644 --- a/control/controlclient/sign_unsupported.go +++ b/control/controlclient/sign_unsupported.go @@ -6,8 +6,8 @@ package controlclient import ( - "tailscale.com/tailcfg" - "tailscale.com/types/key" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" ) // signRegisterRequest on non-supported platforms always returns errNoCertStore. diff --git a/control/controlclient/status.go b/control/controlclient/status.go index d0fdf80d745e3..37be366cb1122 100644 --- a/control/controlclient/status.go +++ b/control/controlclient/status.go @@ -8,9 +8,9 @@ import ( "fmt" "reflect" - "tailscale.com/types/netmap" - "tailscale.com/types/persist" - "tailscale.com/types/structs" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/structs" ) // State is the high-level state of the client. It is used only in diff --git a/control/controlhttp/client.go b/control/controlhttp/client.go index 9b1d5a1a598e7..13fa0b6d5a735 100644 --- a/control/controlhttp/client.go +++ b/control/controlhttp/client.go @@ -37,20 +37,20 @@ import ( "sync/atomic" "time" - "tailscale.com/control/controlbase" - "tailscale.com/control/controlhttp/controlhttpcommon" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/dnscache" - "tailscale.com/net/dnsfallback" - "tailscale.com/net/netutil" - "tailscale.com/net/sockstats" - "tailscale.com/net/tlsdial" - "tailscale.com/net/tshttpproxy" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/util/multierr" + "github.com/sagernet/tailscale/control/controlbase" + "github.com/sagernet/tailscale/control/controlhttp/controlhttpcommon" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/dnsfallback" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/tlsdial" + "github.com/sagernet/tailscale/net/tshttpproxy" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/util/multierr" ) var stdDialer net.Dialer diff --git a/control/controlhttp/client_common.go b/control/controlhttp/client_common.go index dd94e93cdc3cf..6f08420b7b576 100644 --- a/control/controlhttp/client_common.go +++ b/control/controlhttp/client_common.go @@ -4,7 +4,7 @@ package controlhttp import ( - "tailscale.com/control/controlbase" + "github.com/sagernet/tailscale/control/controlbase" ) // ClientConn is a Tailscale control client as returned by the Dialer. diff --git a/control/controlhttp/client_js.go b/control/controlhttp/client_js.go index cc05b5b192766..590b016ab5b27 100644 --- a/control/controlhttp/client_js.go +++ b/control/controlhttp/client_js.go @@ -11,9 +11,9 @@ import ( "net/url" "github.com/coder/websocket" - "tailscale.com/control/controlbase" - "tailscale.com/control/controlhttp/controlhttpcommon" - "tailscale.com/net/wsconn" + "github.com/sagernet/tailscale/control/controlbase" + "github.com/sagernet/tailscale/control/controlhttp/controlhttpcommon" + "github.com/sagernet/tailscale/net/wsconn" ) // Variant of Dial that tunnels the request over WebSockets, since we cannot do diff --git a/control/controlhttp/constants.go b/control/controlhttp/constants.go index 971212d63b994..73ece5428e4ac 100644 --- a/control/controlhttp/constants.go +++ b/control/controlhttp/constants.go @@ -8,13 +8,13 @@ import ( "net/url" "time" - "tailscale.com/health" - "tailscale.com/net/dnscache" - "tailscale.com/net/netmon" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" ) const ( diff --git a/control/controlhttp/controlhttpserver/controlhttpserver.go b/control/controlhttp/controlhttpserver/controlhttpserver.go index af320781069d1..400484e6e9a42 100644 --- a/control/controlhttp/controlhttpserver/controlhttpserver.go +++ b/control/controlhttp/controlhttpserver/controlhttpserver.go @@ -18,11 +18,11 @@ import ( "time" "github.com/coder/websocket" - "tailscale.com/control/controlbase" - "tailscale.com/control/controlhttp/controlhttpcommon" - "tailscale.com/net/netutil" - "tailscale.com/net/wsconn" - "tailscale.com/types/key" + "github.com/sagernet/tailscale/control/controlbase" + "github.com/sagernet/tailscale/control/controlhttp/controlhttpcommon" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/net/wsconn" + "github.com/sagernet/tailscale/types/key" ) // AcceptHTTP upgrades the HTTP request given by w and r into a Tailscale diff --git a/control/controlhttp/http_test.go b/control/controlhttp/http_test.go deleted file mode 100644 index 00cc1e6cfd80b..0000000000000 --- a/control/controlhttp/http_test.go +++ /dev/null @@ -1,832 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlhttp - -import ( - "context" - "crypto/tls" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httptest" - "net/http/httputil" - "net/netip" - "net/url" - "runtime" - "slices" - "strconv" - "sync" - "testing" - "time" - - "tailscale.com/control/controlbase" - "tailscale.com/control/controlhttp/controlhttpcommon" - "tailscale.com/control/controlhttp/controlhttpserver" - "tailscale.com/net/dnscache" - "tailscale.com/net/netmon" - "tailscale.com/net/socks5" - "tailscale.com/net/tsdial" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/tstest/deptest" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" -) - -type httpTestParam struct { - name string - proxy proxy - - // makeHTTPHangAfterUpgrade makes the HTTP response hang after sending a - // 101 switching protocols. - makeHTTPHangAfterUpgrade bool - - doEarlyWrite bool - - httpInDial bool -} - -func TestControlHTTP(t *testing.T) { - tests := []httpTestParam{ - // direct connection - { - name: "no_proxy", - proxy: nil, - }, - // direct connection but port 80 is MITM'ed and broken - { - name: "port80_broken_mitm", - proxy: nil, - makeHTTPHangAfterUpgrade: true, - }, - // SOCKS5 - { - name: "socks5", - proxy: &socksProxy{}, - }, - // HTTP->HTTP - { - name: "http_to_http", - proxy: &httpProxy{ - useTLS: false, - allowConnect: false, - allowHTTP: true, - }, - }, - // HTTP->HTTPS - { - name: "http_to_https", - proxy: &httpProxy{ - useTLS: false, - allowConnect: true, - allowHTTP: false, - }, - }, - // HTTP->any (will pick HTTP) - { - name: "http_to_any", - proxy: &httpProxy{ - useTLS: false, - allowConnect: true, - allowHTTP: true, - }, - }, - // HTTPS->HTTP - { - name: "https_to_http", - proxy: &httpProxy{ - useTLS: true, - allowConnect: false, - allowHTTP: true, - }, - }, - // HTTPS->HTTPS - { - name: "https_to_https", - proxy: &httpProxy{ - useTLS: true, - allowConnect: true, - allowHTTP: false, - }, - }, - // HTTPS->any (will pick HTTP) - { - name: "https_to_any", - proxy: &httpProxy{ - useTLS: true, - allowConnect: true, - allowHTTP: true, - }, - }, - // Early write - { - name: "early_write", - doEarlyWrite: true, - }, - // Dialer needed to make another HTTP request along the way (e.g. to - // resolve the hostname via BootstrapDNS). - { - name: "http_request_in_dial", - httpInDial: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - testControlHTTP(t, test) - }) - } -} - -func testControlHTTP(t *testing.T, param httpTestParam) { - proxy := param.proxy - client, server := key.NewMachine(), key.NewMachine() - - const testProtocolVersion = 1 - const earlyWriteMsg = "Hello, world!" - sch := make(chan serverResult, 1) - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var earlyWriteFn func(protocolVersion int, w io.Writer) error - if param.doEarlyWrite { - earlyWriteFn = func(protocolVersion int, w io.Writer) error { - if protocolVersion != testProtocolVersion { - t.Errorf("unexpected protocol version %d; want %d", protocolVersion, testProtocolVersion) - return fmt.Errorf("unexpected protocol version %d; want %d", protocolVersion, testProtocolVersion) - } - _, err := io.WriteString(w, earlyWriteMsg) - return err - } - } - conn, err := controlhttpserver.AcceptHTTP(context.Background(), w, r, server, earlyWriteFn) - if err != nil { - log.Print(err) - } - res := serverResult{ - err: err, - } - if conn != nil { - res.clientAddr = conn.RemoteAddr().String() - res.version = conn.ProtocolVersion() - res.peer = conn.Peer() - res.conn = conn - } - sch <- res - }) - - httpLn, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("HTTP listen: %v", err) - } - httpsLn, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("HTTPS listen: %v", err) - } - - var httpHandler http.Handler = handler - const fallbackDelay = 50 * time.Millisecond - clock := tstest.NewClock(tstest.ClockOpts{Step: 2 * fallbackDelay}) - // Advance once to init the clock. - clock.Now() - if param.makeHTTPHangAfterUpgrade { - httpHandler = brokenMITMHandler(clock) - } - httpServer := &http.Server{Handler: httpHandler} - go httpServer.Serve(httpLn) - defer httpServer.Close() - - httpsServer := &http.Server{ - Handler: handler, - TLSConfig: tlsConfig(t), - } - go httpsServer.ServeTLS(httpsLn, "", "") - defer httpsServer.Close() - - ctx := context.Background() - const debugTimeout = false - if debugTimeout { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - } - - netMon := netmon.NewStatic() - dialer := tsdial.NewDialer(netMon) - a := &Dialer{ - Hostname: "localhost", - HTTPPort: strconv.Itoa(httpLn.Addr().(*net.TCPAddr).Port), - HTTPSPort: strconv.Itoa(httpsLn.Addr().(*net.TCPAddr).Port), - MachineKey: client, - ControlKey: server.Public(), - NetMon: netMon, - ProtocolVersion: testProtocolVersion, - Dialer: dialer.SystemDial, - Logf: t.Logf, - omitCertErrorLogging: true, - testFallbackDelay: fallbackDelay, - Clock: clock, - } - - if param.httpInDial { - // Spin up a separate server to get a different port on localhost. - secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) - defer secondServer.Close() - - prev := a.Dialer - a.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) { - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", secondServer.URL, nil) - if err != nil { - t.Errorf("http.NewRequest: %v", err) - } - r, err := http.DefaultClient.Do(req) - if err != nil { - t.Errorf("http.Get: %v", err) - } - r.Body.Close() - - return prev(ctx, network, addr) - } - } - - if proxy != nil { - proxyEnv := proxy.Start(t) - defer proxy.Close() - proxyURL, err := url.Parse(proxyEnv) - if err != nil { - t.Fatal(err) - } - a.proxyFunc = func(*http.Request) (*url.URL, error) { - return proxyURL, nil - } - } else { - a.proxyFunc = func(*http.Request) (*url.URL, error) { - return nil, nil - } - } - - conn, err := a.dial(ctx) - if err != nil { - t.Fatalf("dialing controlhttp: %v", err) - } - defer conn.Close() - - si := <-sch - if si.conn != nil { - defer si.conn.Close() - } - if si.err != nil { - t.Fatalf("controlhttp server got error: %v", err) - } - if clientVersion := conn.ProtocolVersion(); si.version != clientVersion { - t.Fatalf("client and server don't agree on protocol version: %d vs %d", clientVersion, si.version) - } - if si.peer != client.Public() { - t.Fatalf("server got peer pubkey %s, want %s", si.peer, client.Public()) - } - if spub := conn.Peer(); spub != server.Public() { - t.Fatalf("client got peer pubkey %s, want %s", spub, server.Public()) - } - if proxy != nil && !proxy.ConnIsFromProxy(si.clientAddr) { - t.Fatalf("client connected from %s, which isn't the proxy", si.clientAddr) - } - if param.doEarlyWrite { - buf := make([]byte, len(earlyWriteMsg)) - if _, err := io.ReadFull(conn, buf); err != nil { - t.Fatalf("reading early write: %v", err) - } - if string(buf) != earlyWriteMsg { - t.Errorf("early write = %q; want %q", buf, earlyWriteMsg) - } - } - - // When no proxy is used, the RemoteAddr of the returned connection should match - // one of the listeners of the test server. - if proxy == nil { - var expectedAddrs []string - for _, ln := range []net.Listener{httpLn, httpsLn} { - expectedAddrs = append(expectedAddrs, fmt.Sprintf("127.0.0.1:%d", ln.Addr().(*net.TCPAddr).Port)) - expectedAddrs = append(expectedAddrs, fmt.Sprintf("[::1]:%d", ln.Addr().(*net.TCPAddr).Port)) - } - if !slices.Contains(expectedAddrs, conn.RemoteAddr().String()) { - t.Errorf("unexpected remote addr: %s, want %s", conn.RemoteAddr(), expectedAddrs) - } - } -} - -type serverResult struct { - err error - clientAddr string - version int - peer key.MachinePublic - conn *controlbase.Conn -} - -type proxy interface { - Start(*testing.T) string - Close() - ConnIsFromProxy(string) bool -} - -type socksProxy struct { - sync.Mutex - closed bool - proxy socks5.Server - ln net.Listener - clientConnAddrs map[string]bool // addrs of the local end of outgoing conns from proxy -} - -func (s *socksProxy) Start(t *testing.T) (url string) { - t.Helper() - s.Lock() - defer s.Unlock() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listening for SOCKS server: %v", err) - } - s.ln = ln - s.clientConnAddrs = map[string]bool{} - s.proxy.Logf = func(format string, a ...any) { - s.Lock() - defer s.Unlock() - if s.closed { - return - } - t.Logf(format, a...) - } - s.proxy.Dialer = s.dialAndRecord - go s.proxy.Serve(ln) - return fmt.Sprintf("socks5://%s", ln.Addr().String()) -} - -func (s *socksProxy) Close() { - s.Lock() - defer s.Unlock() - if s.closed { - return - } - s.closed = true - s.ln.Close() -} - -func (s *socksProxy) dialAndRecord(ctx context.Context, network, addr string) (net.Conn, error) { - var d net.Dialer - conn, err := d.DialContext(ctx, network, addr) - if err != nil { - return nil, err - } - s.Lock() - defer s.Unlock() - s.clientConnAddrs[conn.LocalAddr().String()] = true - return conn, nil -} - -func (s *socksProxy) ConnIsFromProxy(addr string) bool { - s.Lock() - defer s.Unlock() - return s.clientConnAddrs[addr] -} - -type httpProxy struct { - useTLS bool // take incoming connections over TLS - allowConnect bool // allow CONNECT for TLS - allowHTTP bool // allow plain HTTP proxying - - sync.Mutex - ln net.Listener - rp httputil.ReverseProxy - s http.Server - clientConnAddrs map[string]bool // addrs of the local end of outgoing conns from proxy -} - -func (h *httpProxy) Start(t *testing.T) (url string) { - t.Helper() - h.Lock() - defer h.Unlock() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listening for HTTP proxy: %v", err) - } - h.ln = ln - h.rp = httputil.ReverseProxy{ - Director: func(*http.Request) {}, - Transport: &http.Transport{ - DialContext: h.dialAndRecord, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, - }, - } - h.clientConnAddrs = map[string]bool{} - h.s.Handler = h - if h.useTLS { - h.s.TLSConfig = tlsConfig(t) - go h.s.ServeTLS(h.ln, "", "") - return fmt.Sprintf("https://%s", ln.Addr().String()) - } else { - go h.s.Serve(h.ln) - return fmt.Sprintf("http://%s", ln.Addr().String()) - } -} - -func (h *httpProxy) Close() { - h.Lock() - defer h.Unlock() - h.s.Close() -} - -func (h *httpProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != "CONNECT" { - if !h.allowHTTP { - http.Error(w, "http proxy not allowed", 500) - return - } - h.rp.ServeHTTP(w, r) - return - } - - if !h.allowConnect { - http.Error(w, "connect not allowed", 500) - return - } - - dst := r.RequestURI - c, err := h.dialAndRecord(context.Background(), "tcp", dst) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - defer c.Close() - - cc, ccbuf, err := w.(http.Hijacker).Hijack() - if err != nil { - http.Error(w, err.Error(), 500) - return - } - defer cc.Close() - - io.WriteString(cc, "HTTP/1.1 200 OK\r\n\r\n") - - errc := make(chan error, 1) - go func() { - _, err := io.Copy(cc, c) - errc <- err - }() - go func() { - _, err := io.Copy(c, ccbuf) - errc <- err - }() - <-errc -} - -func (h *httpProxy) dialAndRecord(ctx context.Context, network, addr string) (net.Conn, error) { - var d net.Dialer - conn, err := d.DialContext(ctx, network, addr) - if err != nil { - return nil, err - } - h.Lock() - defer h.Unlock() - h.clientConnAddrs[conn.LocalAddr().String()] = true - return conn, nil -} - -func (h *httpProxy) ConnIsFromProxy(addr string) bool { - h.Lock() - defer h.Unlock() - return h.clientConnAddrs[addr] -} - -func tlsConfig(t *testing.T) *tls.Config { - // Cert and key taken from the example code in the crypto/tls - // package. - certPem := []byte(`-----BEGIN CERTIFICATE----- -MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw -DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow -EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d -7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B -5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr -BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 -NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l -Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc -6MF9+Yw1Yy0t ------END CERTIFICATE-----`) - keyPem := []byte(`-----BEGIN EC PRIVATE KEY----- -MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49 -AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q -EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA== ------END EC PRIVATE KEY-----`) - cert, err := tls.X509KeyPair(certPem, keyPem) - if err != nil { - t.Fatal(err) - } - return &tls.Config{ - Certificates: []tls.Certificate{cert}, - } -} - -func brokenMITMHandler(clock tstime.Clock) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Upgrade", controlhttpcommon.UpgradeHeaderValue) - w.Header().Set("Connection", "upgrade") - w.WriteHeader(http.StatusSwitchingProtocols) - w.(http.Flusher).Flush() - // Advance the clock to trigger HTTPs fallback. - clock.Now() - <-r.Context().Done() - } -} - -func TestDialPlan(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("only works on Linux due to multiple localhost addresses") - } - - client, server := key.NewMachine(), key.NewMachine() - - const ( - testProtocolVersion = 1 - ) - - getRandomPort := func() string { - ln, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatalf("net.Listen: %v", err) - } - defer ln.Close() - _, port, err := net.SplitHostPort(ln.Addr().String()) - if err != nil { - t.Fatal(err) - } - return port - } - - // We need consistent ports for each address; these are chosen - // randomly and we hope that they won't conflict during this test. - httpPort := getRandomPort() - httpsPort := getRandomPort() - - makeHandler := func(t *testing.T, name string, host netip.Addr, wrap func(http.Handler) http.Handler) { - done := make(chan struct{}) - t.Cleanup(func() { - close(done) - }) - var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := controlhttpserver.AcceptHTTP(context.Background(), w, r, server, nil) - if err != nil { - log.Print(err) - } else { - defer conn.Close() - } - w.Header().Set("X-Handler-Name", name) - <-done - }) - if wrap != nil { - handler = wrap(handler) - } - - httpLn, err := net.Listen("tcp", host.String()+":"+httpPort) - if err != nil { - t.Fatalf("HTTP listen: %v", err) - } - httpsLn, err := net.Listen("tcp", host.String()+":"+httpsPort) - if err != nil { - t.Fatalf("HTTPS listen: %v", err) - } - - httpServer := &http.Server{Handler: handler} - go httpServer.Serve(httpLn) - t.Cleanup(func() { - httpServer.Close() - }) - - httpsServer := &http.Server{ - Handler: handler, - TLSConfig: tlsConfig(t), - ErrorLog: logger.StdLogger(logger.WithPrefix(t.Logf, "http.Server.ErrorLog: ")), - } - go httpsServer.ServeTLS(httpsLn, "", "") - t.Cleanup(func() { - httpsServer.Close() - }) - return - } - - fallbackAddr := netip.MustParseAddr("127.0.0.1") - goodAddr := netip.MustParseAddr("127.0.0.2") - otherAddr := netip.MustParseAddr("127.0.0.3") - other2Addr := netip.MustParseAddr("127.0.0.4") - brokenAddr := netip.MustParseAddr("127.0.0.10") - - testCases := []struct { - name string - plan *tailcfg.ControlDialPlan - wrap func(http.Handler) http.Handler - want netip.Addr - - allowFallback bool - }{ - { - name: "single", - plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{ - {IP: goodAddr, Priority: 1, DialTimeoutSec: 10}, - }}, - want: goodAddr, - }, - { - name: "broken-then-good", - plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{ - // Dials the broken one, which fails, and then - // eventually dials the good one and succeeds - {IP: brokenAddr, Priority: 2, DialTimeoutSec: 10}, - {IP: goodAddr, Priority: 1, DialTimeoutSec: 10, DialStartDelaySec: 1}, - }}, - want: goodAddr, - }, - // TODO(#8442): fix this test - // { - // name: "multiple-priority-fast-path", - // plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{ - // // Dials some good IPs and our bad one (which - // // hangs forever), which then hits the fast - // // path where we bail without waiting. - // {IP: brokenAddr, Priority: 1, DialTimeoutSec: 10}, - // {IP: goodAddr, Priority: 1, DialTimeoutSec: 10}, - // {IP: other2Addr, Priority: 1, DialTimeoutSec: 10}, - // {IP: otherAddr, Priority: 2, DialTimeoutSec: 10}, - // }}, - // want: otherAddr, - // }, - { - name: "multiple-priority-slow-path", - plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{ - // Our broken address is the highest priority, - // so we don't hit our fast path. - {IP: brokenAddr, Priority: 10, DialTimeoutSec: 10}, - {IP: otherAddr, Priority: 2, DialTimeoutSec: 10}, - {IP: goodAddr, Priority: 1, DialTimeoutSec: 10}, - }}, - want: otherAddr, - }, - { - name: "fallback", - plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{ - {IP: brokenAddr, Priority: 1, DialTimeoutSec: 1}, - }}, - want: fallbackAddr, - allowFallback: true, - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - // TODO(awly): replace this with tstest.NewClock and update the - // test to advance the clock correctly. - clock := tstime.StdClock{} - makeHandler(t, "fallback", fallbackAddr, nil) - makeHandler(t, "good", goodAddr, nil) - makeHandler(t, "other", otherAddr, nil) - makeHandler(t, "other2", other2Addr, nil) - makeHandler(t, "broken", brokenAddr, func(h http.Handler) http.Handler { - return brokenMITMHandler(clock) - }) - - dialer := closeTrackDialer{ - t: t, - inner: tsdial.NewDialer(netmon.NewStatic()).SystemDial, - conns: make(map[*closeTrackConn]bool), - } - defer dialer.Done() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // By default, we intentionally point to something that - // we know won't connect, since we want a fallback to - // DNS to be an error. - host := "example.com" - if tt.allowFallback { - host = "localhost" - } - - drained := make(chan struct{}) - a := &Dialer{ - Hostname: host, - HTTPPort: httpPort, - HTTPSPort: httpsPort, - MachineKey: client, - ControlKey: server.Public(), - ProtocolVersion: testProtocolVersion, - Dialer: dialer.Dial, - Logf: t.Logf, - DialPlan: tt.plan, - proxyFunc: func(*http.Request) (*url.URL, error) { return nil, nil }, - drainFinished: drained, - omitCertErrorLogging: true, - testFallbackDelay: 50 * time.Millisecond, - Clock: clock, - } - - conn, err := a.dial(ctx) - if err != nil { - t.Fatalf("dialing controlhttp: %v", err) - } - defer conn.Close() - - raddr := conn.RemoteAddr().(*net.TCPAddr) - - got, ok := netip.AddrFromSlice(raddr.IP) - if !ok { - t.Errorf("invalid remote IP: %v", raddr.IP) - } else if got != tt.want { - t.Errorf("got connection from %q; want %q", got, tt.want) - } else { - t.Logf("successfully connected to %q", raddr.String()) - } - - // Wait until our dialer drains so we can verify that - // all connections are closed. - <-drained - }) - } -} - -type closeTrackDialer struct { - t testing.TB - inner dnscache.DialContextFunc - mu sync.Mutex - conns map[*closeTrackConn]bool -} - -func (d *closeTrackDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) { - c, err := d.inner(ctx, network, addr) - if err != nil { - return nil, err - } - ct := &closeTrackConn{Conn: c, d: d} - - d.mu.Lock() - d.conns[ct] = true - d.mu.Unlock() - return ct, nil -} - -func (d *closeTrackDialer) Done() { - // Unfortunately, tsdial.Dialer.SystemDial closes connections - // asynchronously in a goroutine, so we can't assume that everything is - // closed by the time we get here. - // - // Sleep/wait a few times on the assumption that things will close - // "eventually". - const iters = 100 - for i := range iters { - d.mu.Lock() - if len(d.conns) == 0 { - d.mu.Unlock() - return - } - - // Only error on last iteration - if i != iters-1 { - d.mu.Unlock() - time.Sleep(100 * time.Millisecond) - continue - } - - for conn := range d.conns { - d.t.Errorf("expected close of conn %p; RemoteAddr=%q", conn, conn.RemoteAddr().String()) - } - d.mu.Unlock() - } -} - -func (d *closeTrackDialer) noteClose(c *closeTrackConn) { - d.mu.Lock() - delete(d.conns, c) // safe if already deleted - d.mu.Unlock() -} - -type closeTrackConn struct { - net.Conn - d *closeTrackDialer -} - -func (c *closeTrackConn) Close() error { - c.d.noteClose(c) - return c.Conn.Close() -} - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - GOOS: "darwin", - GOARCH: "arm64", - BadDeps: map[string]string{ - // Only the controlhttpserver needs WebSockets... - "github.com/coder/websocket": "controlhttp client shouldn't need websockets", - }, - }.Check(t) -} diff --git a/control/controlknobs/controlknobs.go b/control/controlknobs/controlknobs.go index dd76a3abdba5b..0662d3f75a737 100644 --- a/control/controlknobs/controlknobs.go +++ b/control/controlknobs/controlknobs.go @@ -8,9 +8,9 @@ package controlknobs import ( "sync/atomic" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" ) // Knobs is the set of knobs that the control plane's coordination server can diff --git a/control/controlknobs/controlknobs_test.go b/control/controlknobs/controlknobs_test.go deleted file mode 100644 index a78a486f3aaae..0000000000000 --- a/control/controlknobs/controlknobs_test.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlknobs - -import ( - "reflect" - "testing" -) - -func TestAsDebugJSON(t *testing.T) { - var nilPtr *Knobs - if got := nilPtr.AsDebugJSON(); got != nil { - t.Errorf("AsDebugJSON(nil) = %v; want nil", got) - } - k := new(Knobs) - got := k.AsDebugJSON() - if want := reflect.TypeFor[Knobs]().NumField(); len(got) != want { - t.Errorf("AsDebugJSON map has %d fields; want %v", len(got), want) - } -} diff --git a/derp/derp_client.go b/derp/derp_client.go index 7a646fa517940..3d66b4cb84525 100644 --- a/derp/derp_client.go +++ b/derp/derp_client.go @@ -14,12 +14,12 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" "go4.org/mem" "golang.org/x/time/rate" - "tailscale.com/syncs" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" ) // Client is a DERP client. diff --git a/derp/derp_server.go b/derp/derp_server.go index ab0ab0a908a07..06ce8b360e9a3 100644 --- a/derp/derp_server.go +++ b/derp/derp_server.go @@ -35,23 +35,23 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/client/tailscale" + "github.com/sagernet/tailscale/disco" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/tstime/rate" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/ctxkey" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/slicesx" + "github.com/sagernet/tailscale/version" "go4.org/mem" "golang.org/x/sync/errgroup" - "tailscale.com/client/tailscale" - "tailscale.com/disco" - "tailscale.com/envknob" - "tailscale.com/metrics" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/tstime/rate" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/ctxkey" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/util/slicesx" - "tailscale.com/version" ) // verboseDropKeys is the set of destination public keys that should diff --git a/derp/derp_server_linux.go b/derp/derp_server_linux.go index bfc2aade6588c..e326d39a80574 100644 --- a/derp/derp_server_linux.go +++ b/derp/derp_server_linux.go @@ -9,7 +9,7 @@ import ( "net" "time" - "tailscale.com/net/tcpinfo" + "github.com/sagernet/tailscale/net/tcpinfo" ) func (c *sclient) startStatsLoop(ctx context.Context) { diff --git a/derp/derp_test.go b/derp/derp_test.go deleted file mode 100644 index 9185194dd79cf..0000000000000 --- a/derp/derp_test.go +++ /dev/null @@ -1,1600 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package derp - -import ( - "bufio" - "bytes" - "context" - "crypto/x509" - "encoding/asn1" - "encoding/json" - "errors" - "expvar" - "fmt" - "io" - "log" - "net" - "os" - "reflect" - "strconv" - "sync" - "testing" - "time" - - "go4.org/mem" - "golang.org/x/time/rate" - "tailscale.com/disco" - "tailscale.com/net/memnet" - "tailscale.com/tstest" - "tailscale.com/types/key" - "tailscale.com/types/logger" -) - -func TestClientInfoUnmarshal(t *testing.T) { - for i, in := range []string{ - `{"Version":5,"MeshKey":"abc"}`, - `{"version":5,"meshKey":"abc"}`, - } { - var got clientInfo - if err := json.Unmarshal([]byte(in), &got); err != nil { - t.Fatalf("[%d]: %v", i, err) - } - want := clientInfo{Version: 5, MeshKey: "abc"} - if got != want { - t.Errorf("[%d]: got %+v; want %+v", i, got, want) - } - } -} - -func TestSendRecv(t *testing.T) { - serverPrivateKey := key.NewNode() - s := NewServer(serverPrivateKey, t.Logf) - defer s.Close() - - const numClients = 3 - var clientPrivateKeys []key.NodePrivate - var clientKeys []key.NodePublic - for range numClients { - priv := key.NewNode() - clientPrivateKeys = append(clientPrivateKeys, priv) - clientKeys = append(clientKeys, priv.Public()) - } - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - - var clients []*Client - var connsOut []Conn - var recvChs []chan []byte - errCh := make(chan error, 3) - - for i := range numClients { - t.Logf("Connecting client %d ...", i) - cout, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { - t.Fatal(err) - } - defer cout.Close() - connsOut = append(connsOut, cout) - - cin, err := ln.Accept() - if err != nil { - t.Fatal(err) - } - defer cin.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - brwServer := bufio.NewReadWriter(bufio.NewReader(cin), bufio.NewWriter(cin)) - go s.Accept(ctx, cin, brwServer, fmt.Sprintf("[abc::def]:%v", i)) - - key := clientPrivateKeys[i] - brw := bufio.NewReadWriter(bufio.NewReader(cout), bufio.NewWriter(cout)) - c, err := NewClient(key, cout, brw, t.Logf) - if err != nil { - t.Fatalf("client %d: %v", i, err) - } - waitConnect(t, c) - - clients = append(clients, c) - recvChs = append(recvChs, make(chan []byte)) - t.Logf("Connected client %d.", i) - } - - var peerGoneCountDisconnected expvar.Int - var peerGoneCountNotHere expvar.Int - - t.Logf("Starting read loops") - for i := range numClients { - go func(i int) { - for { - m, err := clients[i].Recv() - if err != nil { - errCh <- err - return - } - switch m := m.(type) { - default: - t.Errorf("unexpected message type %T", m) - continue - case PeerGoneMessage: - switch m.Reason { - case PeerGoneReasonDisconnected: - peerGoneCountDisconnected.Add(1) - case PeerGoneReasonNotHere: - peerGoneCountNotHere.Add(1) - default: - t.Errorf("unexpected PeerGone reason %v", m.Reason) - } - case ReceivedPacket: - if m.Source.IsZero() { - t.Errorf("zero Source address in ReceivedPacket") - } - recvChs[i] <- bytes.Clone(m.Data) - } - } - }(i) - } - - recv := func(i int, want string) { - t.Helper() - select { - case b := <-recvChs[i]: - if got := string(b); got != want { - t.Errorf("client1.Recv=%q, want %q", got, want) - } - case <-time.After(5 * time.Second): - t.Errorf("client%d.Recv, got nothing, want %q", i, want) - } - } - recvNothing := func(i int) { - t.Helper() - select { - case b := <-recvChs[0]: - t.Errorf("client%d.Recv=%q, want nothing", i, string(b)) - default: - } - } - - wantActive := func(total, home int64) { - t.Helper() - dl := time.Now().Add(5 * time.Second) - var gotTotal, gotHome int64 - for time.Now().Before(dl) { - gotTotal, gotHome = s.curClients.Value(), s.curHomeClients.Value() - if gotTotal == total && gotHome == home { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Errorf("total/home=%v/%v; want %v/%v", gotTotal, gotHome, total, home) - } - - wantClosedPeers := func(want int64) { - t.Helper() - var got int64 - dl := time.Now().Add(5 * time.Second) - for time.Now().Before(dl) { - if got = peerGoneCountDisconnected.Value(); got == want { - return - } - } - t.Errorf("peer gone count = %v; want %v", got, want) - } - - wantUnknownPeers := func(want int64) { - t.Helper() - var got int64 - dl := time.Now().Add(5 * time.Second) - for time.Now().Before(dl) { - if got = peerGoneCountNotHere.Value(); got == want { - return - } - } - t.Errorf("peer gone count = %v; want %v", got, want) - } - - msg1 := []byte("hello 0->1\n") - if err := clients[0].Send(clientKeys[1], msg1); err != nil { - t.Fatal(err) - } - recv(1, string(msg1)) - recvNothing(0) - recvNothing(2) - - msg2 := []byte("hello 1->2\n") - if err := clients[1].Send(clientKeys[2], msg2); err != nil { - t.Fatal(err) - } - recv(2, string(msg2)) - recvNothing(0) - recvNothing(1) - - // Send messages to a non-existent node - neKey := key.NewNode().Public() - msg4 := []byte("not a CallMeMaybe->unknown destination\n") - if err := clients[1].Send(neKey, msg4); err != nil { - t.Fatal(err) - } - wantUnknownPeers(0) - - callMe := neKey.AppendTo([]byte(disco.Magic)) - callMeHeader := make([]byte, disco.NonceLen) - callMe = append(callMe, callMeHeader...) - if err := clients[1].Send(neKey, callMe); err != nil { - t.Fatal(err) - } - wantUnknownPeers(1) - - // PeerGoneNotHere is rate-limited to 3 times a second - for range 5 { - if err := clients[1].Send(neKey, callMe); err != nil { - t.Fatal(err) - } - } - wantUnknownPeers(3) - - wantActive(3, 0) - clients[0].NotePreferred(true) - wantActive(3, 1) - clients[0].NotePreferred(true) - wantActive(3, 1) - clients[0].NotePreferred(false) - wantActive(3, 0) - clients[0].NotePreferred(false) - wantActive(3, 0) - clients[1].NotePreferred(true) - wantActive(3, 1) - connsOut[1].Close() - wantActive(2, 0) - wantClosedPeers(1) - clients[2].NotePreferred(true) - wantActive(2, 1) - clients[2].NotePreferred(false) - wantActive(2, 0) - connsOut[2].Close() - wantActive(1, 0) - wantClosedPeers(1) - - t.Logf("passed") - s.Close() - -} - -func TestSendFreeze(t *testing.T) { - serverPrivateKey := key.NewNode() - s := NewServer(serverPrivateKey, t.Logf) - defer s.Close() - s.WriteTimeout = 100 * time.Millisecond - - // We send two streams of messages: - // - // alice --> bob - // alice --> cathy - // - // Then cathy stops processing messages. - // That should not interfere with alice talking to bob. - - newClient := func(ctx context.Context, name string, k key.NodePrivate) (c *Client, clientConn memnet.Conn) { - t.Helper() - c1, c2 := memnet.NewConn(name, 1024) - go s.Accept(ctx, c1, bufio.NewReadWriter(bufio.NewReader(c1), bufio.NewWriter(c1)), name) - - brw := bufio.NewReadWriter(bufio.NewReader(c2), bufio.NewWriter(c2)) - c, err := NewClient(k, c2, brw, t.Logf) - if err != nil { - t.Fatal(err) - } - waitConnect(t, c) - return c, c2 - } - - ctx, clientCtxCancel := context.WithCancel(context.Background()) - defer clientCtxCancel() - - aliceKey := key.NewNode() - aliceClient, aliceConn := newClient(ctx, "alice", aliceKey) - - bobKey := key.NewNode() - bobClient, bobConn := newClient(ctx, "bob", bobKey) - - cathyKey := key.NewNode() - cathyClient, cathyConn := newClient(ctx, "cathy", cathyKey) - - var ( - aliceCh = make(chan struct{}, 32) - bobCh = make(chan struct{}, 32) - cathyCh = make(chan struct{}, 32) - ) - chs := func(name string) chan struct{} { - switch name { - case "alice": - return aliceCh - case "bob": - return bobCh - case "cathy": - return cathyCh - default: - panic("unknown ch: " + name) - } - } - - errCh := make(chan error, 4) - recv := func(name string, client *Client) { - ch := chs(name) - for { - m, err := client.Recv() - if err != nil { - errCh <- fmt.Errorf("%s: %w", name, err) - return - } - switch m := m.(type) { - default: - errCh <- fmt.Errorf("%s: unexpected message type %T", name, m) - return - case ReceivedPacket: - if m.Source.IsZero() { - errCh <- fmt.Errorf("%s: zero Source address in ReceivedPacket", name) - return - } - select { - case ch <- struct{}{}: - default: - } - } - } - } - go recv("alice", aliceClient) - go recv("bob", bobClient) - go recv("cathy", cathyClient) - - var cancel func() - go func() { - t := time.NewTicker(2 * time.Millisecond) - defer t.Stop() - var ctx context.Context - ctx, cancel = context.WithCancel(context.Background()) - for { - select { - case <-t.C: - case <-ctx.Done(): - errCh <- nil - return - } - - msg1 := []byte("hello alice->bob\n") - if err := aliceClient.Send(bobKey.Public(), msg1); err != nil { - errCh <- fmt.Errorf("alice send to bob: %w", err) - return - } - msg2 := []byte("hello alice->cathy\n") - - // TODO: an error is expected here. - // We ignore it, maybe we should log it somehow? - aliceClient.Send(cathyKey.Public(), msg2) - } - }() - - drainAny := func(ch chan struct{}) { - // We are draining potentially infinite sources, - // so place some reasonable upper limit. - // - // The important thing here is to make sure that - // if any tokens remain in the channel, they - // must have been generated after drainAny was - // called. - for range cap(ch) { - select { - case <-ch: - default: - return - } - } - } - drain := func(t *testing.T, name string) bool { - t.Helper() - timer := time.NewTimer(1 * time.Second) - defer timer.Stop() - - // Ensure ch has at least one element. - ch := chs(name) - select { - case <-ch: - case <-timer.C: - t.Errorf("no packet received by %s", name) - return false - } - // Drain remaining. - drainAny(ch) - return true - } - isEmpty := func(t *testing.T, name string) { - t.Helper() - select { - case <-chs(name): - t.Errorf("packet received by %s, want none", name) - default: - } - } - - t.Run("initial send", func(t *testing.T) { - drain(t, "bob") - drain(t, "cathy") - isEmpty(t, "alice") - }) - - t.Run("block cathy", func(t *testing.T) { - // Block cathy. Now the cathyConn buffer will fill up quickly, - // and the derp server will back up. - cathyConn.SetReadBlock(true) - time.Sleep(2 * s.WriteTimeout) - - drain(t, "bob") - drainAny(chs("cathy")) - isEmpty(t, "alice") - - // Now wait a little longer, and ensure packets still flow to bob - if !drain(t, "bob") { - t.Errorf("connection alice->bob frozen by alice->cathy") - } - }) - - // Cleanup, make sure we process all errors. - t.Logf("TEST COMPLETE, cancelling sender") - cancel() - t.Logf("closing connections") - // Close bob before alice. - // Starting with alice can cause a PeerGoneMessage to reach - // bob before bob is closed, causing a test flake (issue 2668). - bobConn.Close() - aliceConn.Close() - cathyConn.Close() - - for range cap(errCh) { - err := <-errCh - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { - continue - } - t.Error(err) - } - } -} - -type testServer struct { - s *Server - ln net.Listener - logf logger.Logf - - mu sync.Mutex - pubName map[key.NodePublic]string - clients map[*testClient]bool -} - -func (ts *testServer) addTestClient(c *testClient) { - ts.mu.Lock() - defer ts.mu.Unlock() - ts.clients[c] = true -} - -func (ts *testServer) addKeyName(k key.NodePublic, name string) { - ts.mu.Lock() - defer ts.mu.Unlock() - ts.pubName[k] = name - ts.logf("test adding named key %q for %x", name, k) -} - -func (ts *testServer) keyName(k key.NodePublic) string { - ts.mu.Lock() - defer ts.mu.Unlock() - if name, ok := ts.pubName[k]; ok { - return name - } - return k.ShortString() -} - -func (ts *testServer) close(t *testing.T) error { - ts.ln.Close() - ts.s.Close() - for c := range ts.clients { - c.close(t) - } - return nil -} - -func newTestServer(t *testing.T, ctx context.Context) *testServer { - t.Helper() - logf := logger.WithPrefix(t.Logf, "derp-server: ") - s := NewServer(key.NewNode(), logf) - s.SetMeshKey("mesh-key") - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - go func() { - i := 0 - for { - i++ - c, err := ln.Accept() - if err != nil { - return - } - // TODO: register c in ts so Close also closes it? - go func(i int) { - brwServer := bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c)) - go s.Accept(ctx, c, brwServer, c.RemoteAddr().String()) - }(i) - } - }() - return &testServer{ - s: s, - ln: ln, - logf: logf, - clients: map[*testClient]bool{}, - pubName: map[key.NodePublic]string{}, - } -} - -type testClient struct { - name string - c *Client - nc net.Conn - pub key.NodePublic - ts *testServer - closed bool -} - -func newTestClient(t *testing.T, ts *testServer, name string, newClient func(net.Conn, key.NodePrivate, logger.Logf) (*Client, error)) *testClient { - t.Helper() - nc, err := net.Dial("tcp", ts.ln.Addr().String()) - if err != nil { - t.Fatal(err) - } - k := key.NewNode() - ts.addKeyName(k.Public(), name) - c, err := newClient(nc, k, logger.WithPrefix(t.Logf, "client-"+name+": ")) - if err != nil { - t.Fatal(err) - } - tc := &testClient{ - name: name, - nc: nc, - c: c, - ts: ts, - pub: k.Public(), - } - ts.addTestClient(tc) - return tc -} - -func newRegularClient(t *testing.T, ts *testServer, name string) *testClient { - return newTestClient(t, ts, name, func(nc net.Conn, priv key.NodePrivate, logf logger.Logf) (*Client, error) { - brw := bufio.NewReadWriter(bufio.NewReader(nc), bufio.NewWriter(nc)) - c, err := NewClient(priv, nc, brw, logf) - if err != nil { - return nil, err - } - waitConnect(t, c) - return c, nil - - }) -} - -func newTestWatcher(t *testing.T, ts *testServer, name string) *testClient { - return newTestClient(t, ts, name, func(nc net.Conn, priv key.NodePrivate, logf logger.Logf) (*Client, error) { - brw := bufio.NewReadWriter(bufio.NewReader(nc), bufio.NewWriter(nc)) - c, err := NewClient(priv, nc, brw, logf, MeshKey("mesh-key")) - if err != nil { - return nil, err - } - waitConnect(t, c) - if err := c.WatchConnectionChanges(); err != nil { - return nil, err - } - return c, nil - }) -} - -func (tc *testClient) wantPresent(t *testing.T, peers ...key.NodePublic) { - t.Helper() - want := map[key.NodePublic]bool{} - for _, k := range peers { - want[k] = true - } - - for { - m, err := tc.c.recvTimeout(time.Second) - if err != nil { - t.Fatal(err) - } - switch m := m.(type) { - case PeerPresentMessage: - got := m.Key - if !want[got] { - t.Fatalf("got peer present for %v; want present for %v", tc.ts.keyName(got), logger.ArgWriter(func(bw *bufio.Writer) { - for _, pub := range peers { - fmt.Fprintf(bw, "%s ", tc.ts.keyName(pub)) - } - })) - } - t.Logf("got present with IP %v, flags=%v", m.IPPort, m.Flags) - switch m.Flags { - case PeerPresentIsMeshPeer, PeerPresentIsRegular: - // Okay - default: - t.Errorf("unexpected PeerPresentIsMeshPeer flags %v", m.Flags) - } - delete(want, got) - if len(want) == 0 { - return - } - default: - t.Fatalf("unexpected message type %T", m) - } - } -} - -func (tc *testClient) wantGone(t *testing.T, peer key.NodePublic) { - t.Helper() - m, err := tc.c.recvTimeout(time.Second) - if err != nil { - t.Fatal(err) - } - switch m := m.(type) { - case PeerGoneMessage: - got := key.NodePublic(m.Peer) - if peer != got { - t.Errorf("got gone message for %v; want gone for %v", tc.ts.keyName(got), tc.ts.keyName(peer)) - } - reason := m.Reason - if reason != PeerGoneReasonDisconnected { - t.Errorf("got gone message for reason %v; wanted %v", reason, PeerGoneReasonDisconnected) - } - default: - t.Fatalf("unexpected message type %T", m) - } -} - -func (c *testClient) close(t *testing.T) { - t.Helper() - if c.closed { - return - } - c.closed = true - t.Logf("closing client %q (%x)", c.name, c.pub) - c.nc.Close() -} - -// TestWatch tests the connection watcher mechanism used by regional -// DERP nodes to mesh up with each other. -func TestWatch(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ts := newTestServer(t, ctx) - defer ts.close(t) - - w1 := newTestWatcher(t, ts, "w1") - w1.wantPresent(t, w1.pub) - - c1 := newRegularClient(t, ts, "c1") - w1.wantPresent(t, c1.pub) - - c2 := newRegularClient(t, ts, "c2") - w1.wantPresent(t, c2.pub) - - w2 := newTestWatcher(t, ts, "w2") - w1.wantPresent(t, w2.pub) - w2.wantPresent(t, w1.pub, w2.pub, c1.pub, c2.pub) - - c3 := newRegularClient(t, ts, "c3") - w1.wantPresent(t, c3.pub) - w2.wantPresent(t, c3.pub) - - c2.close(t) - w1.wantGone(t, c2.pub) - w2.wantGone(t, c2.pub) - - w3 := newTestWatcher(t, ts, "w3") - w1.wantPresent(t, w3.pub) - w2.wantPresent(t, w3.pub) - w3.wantPresent(t, c1.pub, c3.pub, w1.pub, w2.pub, w3.pub) - - c1.close(t) - w1.wantGone(t, c1.pub) - w2.wantGone(t, c1.pub) - w3.wantGone(t, c1.pub) -} - -type testFwd int - -func (testFwd) ForwardPacket(key.NodePublic, key.NodePublic, []byte) error { - panic("not called in tests") -} -func (testFwd) String() string { - panic("not called in tests") -} - -func pubAll(b byte) (ret key.NodePublic) { - var bs [32]byte - for i := range bs { - bs[i] = b - } - return key.NodePublicFromRaw32(mem.B(bs[:])) -} - -func TestForwarderRegistration(t *testing.T) { - s := &Server{ - clients: make(map[key.NodePublic]*clientSet), - clientsMesh: map[key.NodePublic]PacketForwarder{}, - } - want := func(want map[key.NodePublic]PacketForwarder) { - t.Helper() - if got := s.clientsMesh; !reflect.DeepEqual(got, want) { - t.Fatalf("mismatch\n got: %v\nwant: %v\n", got, want) - } - } - wantCounter := func(c *expvar.Int, want int) { - t.Helper() - if got := c.Value(); got != int64(want) { - t.Errorf("counter = %v; want %v", got, want) - } - } - singleClient := func(c *sclient) *clientSet { - cs := &clientSet{} - cs.activeClient.Store(c) - return cs - } - - u1 := pubAll(1) - u2 := pubAll(2) - u3 := pubAll(3) - - s.AddPacketForwarder(u1, testFwd(1)) - s.AddPacketForwarder(u2, testFwd(2)) - want(map[key.NodePublic]PacketForwarder{ - u1: testFwd(1), - u2: testFwd(2), - }) - - // Verify a remove of non-registered forwarder is no-op. - s.RemovePacketForwarder(u2, testFwd(999)) - want(map[key.NodePublic]PacketForwarder{ - u1: testFwd(1), - u2: testFwd(2), - }) - - // Verify a remove of non-registered user is no-op. - s.RemovePacketForwarder(u3, testFwd(1)) - want(map[key.NodePublic]PacketForwarder{ - u1: testFwd(1), - u2: testFwd(2), - }) - - // Actual removal. - s.RemovePacketForwarder(u2, testFwd(2)) - want(map[key.NodePublic]PacketForwarder{ - u1: testFwd(1), - }) - - // Adding a dup for a user. - wantCounter(&s.multiForwarderCreated, 0) - s.AddPacketForwarder(u1, testFwd(100)) - s.AddPacketForwarder(u1, testFwd(100)) // dup to trigger dup path - want(map[key.NodePublic]PacketForwarder{ - u1: newMultiForwarder(testFwd(1), testFwd(100)), - }) - wantCounter(&s.multiForwarderCreated, 1) - - // Removing a forwarder in a multi set that doesn't exist; does nothing. - s.RemovePacketForwarder(u1, testFwd(55)) - want(map[key.NodePublic]PacketForwarder{ - u1: newMultiForwarder(testFwd(1), testFwd(100)), - }) - - // Removing a forwarder in a multi set that does exist should collapse it away - // from being a multiForwarder. - wantCounter(&s.multiForwarderDeleted, 0) - s.RemovePacketForwarder(u1, testFwd(1)) - want(map[key.NodePublic]PacketForwarder{ - u1: testFwd(100), - }) - wantCounter(&s.multiForwarderDeleted, 1) - - // Removing an entry for a client that's still connected locally should result - // in a nil forwarder. - u1c := &sclient{ - key: u1, - logf: logger.Discard, - } - s.clients[u1] = singleClient(u1c) - s.RemovePacketForwarder(u1, testFwd(100)) - want(map[key.NodePublic]PacketForwarder{ - u1: nil, - }) - - // But once that client disconnects, it should go away. - s.unregisterClient(u1c) - want(map[key.NodePublic]PacketForwarder{}) - - // But if it already has a forwarder, it's not removed. - s.AddPacketForwarder(u1, testFwd(2)) - s.unregisterClient(u1c) - want(map[key.NodePublic]PacketForwarder{ - u1: testFwd(2), - }) - - // Now pretend u1 was already connected locally (so clientsMesh[u1] is nil), and then we heard - // that they're also connected to a peer of ours. That shouldn't transition the forwarder - // from nil to the new one, not a multiForwarder. - s.clients[u1] = singleClient(u1c) - s.clientsMesh[u1] = nil - want(map[key.NodePublic]PacketForwarder{ - u1: nil, - }) - s.AddPacketForwarder(u1, testFwd(3)) - want(map[key.NodePublic]PacketForwarder{ - u1: testFwd(3), - }) -} - -type channelFwd struct { - // id is to ensure that different instances that reference the - // same channel are not equal, as they are used as keys in the - // multiForwarder map. - id int - c chan []byte -} - -func (f channelFwd) String() string { return "" } -func (f channelFwd) ForwardPacket(_ key.NodePublic, _ key.NodePublic, packet []byte) error { - f.c <- packet - return nil -} - -func TestMultiForwarder(t *testing.T) { - received := 0 - var wg sync.WaitGroup - ch := make(chan []byte) - ctx, cancel := context.WithCancel(context.Background()) - - s := &Server{ - clients: make(map[key.NodePublic]*clientSet), - clientsMesh: map[key.NodePublic]PacketForwarder{}, - } - u := pubAll(1) - s.AddPacketForwarder(u, channelFwd{1, ch}) - - wg.Add(2) - go func() { - defer wg.Done() - for { - select { - case <-ch: - received += 1 - case <-ctx.Done(): - return - } - } - }() - go func() { - defer wg.Done() - for { - s.AddPacketForwarder(u, channelFwd{2, ch}) - s.AddPacketForwarder(u, channelFwd{3, ch}) - s.RemovePacketForwarder(u, channelFwd{2, ch}) - s.RemovePacketForwarder(u, channelFwd{1, ch}) - s.AddPacketForwarder(u, channelFwd{1, ch}) - s.RemovePacketForwarder(u, channelFwd{3, ch}) - if ctx.Err() != nil { - return - } - } - }() - - // Number of messages is chosen arbitrarily, just for this loop to - // run long enough concurrently with {Add,Remove}PacketForwarder loop above. - numMsgs := 5000 - var fwd PacketForwarder - for i := range numMsgs { - s.mu.Lock() - fwd = s.clientsMesh[u] - s.mu.Unlock() - fwd.ForwardPacket(u, u, []byte(strconv.Itoa(i))) - } - - cancel() - wg.Wait() - if received != numMsgs { - t.Errorf("expected %d messages to be forwarded; got %d", numMsgs, received) - } -} -func TestMetaCert(t *testing.T) { - priv := key.NewNode() - pub := priv.Public() - s := NewServer(priv, t.Logf) - - certBytes := s.MetaCert() - cert, err := x509.ParseCertificate(certBytes) - if err != nil { - log.Fatal(err) - } - if fmt.Sprint(cert.SerialNumber) != fmt.Sprint(ProtocolVersion) { - t.Errorf("serial = %v; want %v", cert.SerialNumber, ProtocolVersion) - } - if g, w := cert.Subject.CommonName, fmt.Sprintf("derpkey%s", pub.UntypedHexString()); g != w { - t.Errorf("CommonName = %q; want %q", g, w) - } - if n := len(cert.Extensions); n != 1 { - t.Fatalf("got %d extensions; want 1", n) - } - - // oidExtensionBasicConstraints is the Basic Constraints ID copied - // from the x509 package. - oidExtensionBasicConstraints := asn1.ObjectIdentifier{2, 5, 29, 19} - - if id := cert.Extensions[0].Id; !id.Equal(oidExtensionBasicConstraints) { - t.Errorf("extension ID = %v; want %v", id, oidExtensionBasicConstraints) - } -} - -type dummyNetConn struct { - net.Conn -} - -func (dummyNetConn) SetReadDeadline(time.Time) error { return nil } - -func TestClientRecv(t *testing.T) { - tests := []struct { - name string - input []byte - want any - }{ - { - name: "ping", - input: []byte{ - byte(framePing), 0, 0, 0, 8, - 1, 2, 3, 4, 5, 6, 7, 8, - }, - want: PingMessage{1, 2, 3, 4, 5, 6, 7, 8}, - }, - { - name: "pong", - input: []byte{ - byte(framePong), 0, 0, 0, 8, - 1, 2, 3, 4, 5, 6, 7, 8, - }, - want: PongMessage{1, 2, 3, 4, 5, 6, 7, 8}, - }, - { - name: "health_bad", - input: []byte{ - byte(frameHealth), 0, 0, 0, 3, - byte('B'), byte('A'), byte('D'), - }, - want: HealthMessage{Problem: "BAD"}, - }, - { - name: "health_ok", - input: []byte{ - byte(frameHealth), 0, 0, 0, 0, - }, - want: HealthMessage{}, - }, - { - name: "server_restarting", - input: []byte{ - byte(frameRestarting), 0, 0, 0, 8, - 0, 0, 0, 1, - 0, 0, 0, 2, - }, - want: ServerRestartingMessage{ - ReconnectIn: 1 * time.Millisecond, - TryFor: 2 * time.Millisecond, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Client{ - nc: dummyNetConn{}, - br: bufio.NewReader(bytes.NewReader(tt.input)), - logf: t.Logf, - clock: &tstest.Clock{}, - } - got, err := c.Recv() - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %#v; want %#v", got, tt.want) - } - }) - } -} - -func TestClientSendPing(t *testing.T) { - var buf bytes.Buffer - c := &Client{ - bw: bufio.NewWriter(&buf), - } - if err := c.SendPing([8]byte{1, 2, 3, 4, 5, 6, 7, 8}); err != nil { - t.Fatal(err) - } - want := []byte{ - byte(framePing), 0, 0, 0, 8, - 1, 2, 3, 4, 5, 6, 7, 8, - } - if !bytes.Equal(buf.Bytes(), want) { - t.Errorf("unexpected output\nwrote: % 02x\n want: % 02x", buf.Bytes(), want) - } -} - -func TestClientSendPong(t *testing.T) { - var buf bytes.Buffer - c := &Client{ - bw: bufio.NewWriter(&buf), - } - if err := c.SendPong([8]byte{1, 2, 3, 4, 5, 6, 7, 8}); err != nil { - t.Fatal(err) - } - want := []byte{ - byte(framePong), 0, 0, 0, 8, - 1, 2, 3, 4, 5, 6, 7, 8, - } - if !bytes.Equal(buf.Bytes(), want) { - t.Errorf("unexpected output\nwrote: % 02x\n want: % 02x", buf.Bytes(), want) - } -} - -func TestServerDupClients(t *testing.T) { - serverPriv := key.NewNode() - var s *Server - - clientPriv := key.NewNode() - clientPub := clientPriv.Public() - - var c1, c2, c3 *sclient - var clientName map[*sclient]string - - // run starts a new test case and resets clients back to their zero values. - run := func(name string, dupPolicy dupPolicy, f func(t *testing.T)) { - s = NewServer(serverPriv, t.Logf) - s.dupPolicy = dupPolicy - c1 = &sclient{key: clientPub, logf: logger.WithPrefix(t.Logf, "c1: ")} - c2 = &sclient{key: clientPub, logf: logger.WithPrefix(t.Logf, "c2: ")} - c3 = &sclient{key: clientPub, logf: logger.WithPrefix(t.Logf, "c3: ")} - clientName = map[*sclient]string{ - c1: "c1", - c2: "c2", - c3: "c3", - } - t.Run(name, f) - } - runBothWays := func(name string, f func(t *testing.T)) { - run(name+"_disablefighters", disableFighters, f) - run(name+"_lastwriteractive", lastWriterIsActive, f) - } - wantSingleClient := func(t *testing.T, want *sclient) { - t.Helper() - got, ok := s.clients[want.key] - if !ok { - t.Error("no clients for key") - return - } - if got.dup != nil { - t.Errorf("unexpected dup set for single client") - } - cur := got.activeClient.Load() - if cur != want { - t.Errorf("active client = %q; want %q", clientName[cur], clientName[want]) - } - if cur != nil { - if cur.isDup.Load() { - t.Errorf("unexpected isDup on singleClient") - } - if cur.isDisabled.Load() { - t.Errorf("unexpected isDisabled on singleClient") - } - } - } - wantNoClient := func(t *testing.T) { - t.Helper() - _, ok := s.clients[clientPub] - if !ok { - // Good - return - } - t.Errorf("got client; want empty") - } - wantDupSet := func(t *testing.T) *dupClientSet { - t.Helper() - cs, ok := s.clients[clientPub] - if !ok { - t.Fatal("no set for key; want dup set") - return nil - } - if cs.dup != nil { - return cs.dup - } - t.Fatalf("no dup set for key; want dup set") - return nil - } - wantActive := func(t *testing.T, want *sclient) { - t.Helper() - set, ok := s.clients[clientPub] - if !ok { - t.Error("no set for key") - return - } - got := set.activeClient.Load() - if got != want { - t.Errorf("active client = %q; want %q", clientName[got], clientName[want]) - } - } - checkDup := func(t *testing.T, c *sclient, want bool) { - t.Helper() - if got := c.isDup.Load(); got != want { - t.Errorf("client %q isDup = %v; want %v", clientName[c], got, want) - } - } - checkDisabled := func(t *testing.T, c *sclient, want bool) { - t.Helper() - if got := c.isDisabled.Load(); got != want { - t.Errorf("client %q isDisabled = %v; want %v", clientName[c], got, want) - } - } - wantDupConns := func(t *testing.T, want int) { - t.Helper() - if got := s.dupClientConns.Value(); got != int64(want) { - t.Errorf("dupClientConns = %v; want %v", got, want) - } - } - wantDupKeys := func(t *testing.T, want int) { - t.Helper() - if got := s.dupClientKeys.Value(); got != int64(want) { - t.Errorf("dupClientKeys = %v; want %v", got, want) - } - } - - // Common case: a single client comes and goes, with no dups. - runBothWays("one_comes_and_goes", func(t *testing.T) { - wantNoClient(t) - s.registerClient(c1) - wantSingleClient(t, c1) - s.unregisterClient(c1) - wantNoClient(t) - }) - - // A still somewhat common case: a single client was - // connected and then their wifi dies or laptop closes - // or they switch networks and connect from a - // different network. They have two connections but - // it's not very bad. Only their new one is - // active. The last one, being dead, doesn't send and - // thus the new one doesn't get disabled. - runBothWays("small_overlap_replacement", func(t *testing.T) { - wantNoClient(t) - s.registerClient(c1) - wantSingleClient(t, c1) - wantActive(t, c1) - wantDupKeys(t, 0) - wantDupKeys(t, 0) - - s.registerClient(c2) // wifi dies; c2 replacement connects - wantDupSet(t) - wantDupConns(t, 2) - wantDupKeys(t, 1) - checkDup(t, c1, true) - checkDup(t, c2, true) - checkDisabled(t, c1, false) - checkDisabled(t, c2, false) - wantActive(t, c2) // sends go to the replacement - - s.unregisterClient(c1) // c1 finally times out - wantSingleClient(t, c2) - checkDup(t, c2, false) // c2 is longer a dup - wantActive(t, c2) - wantDupConns(t, 0) - wantDupKeys(t, 0) - }) - - // Key cloning situation with concurrent clients, both trying - // to write. - run("concurrent_dups_get_disabled", disableFighters, func(t *testing.T) { - wantNoClient(t) - s.registerClient(c1) - wantSingleClient(t, c1) - wantActive(t, c1) - s.registerClient(c2) - wantDupSet(t) - wantDupKeys(t, 1) - wantDupConns(t, 2) - wantActive(t, c2) - checkDup(t, c1, true) - checkDup(t, c2, true) - checkDisabled(t, c1, false) - checkDisabled(t, c2, false) - - s.noteClientActivity(c2) - checkDisabled(t, c1, false) - checkDisabled(t, c2, false) - s.noteClientActivity(c1) - checkDisabled(t, c1, true) - checkDisabled(t, c2, true) - wantActive(t, nil) - - s.registerClient(c3) - wantActive(t, c3) - checkDisabled(t, c3, false) - wantDupKeys(t, 1) - wantDupConns(t, 3) - - s.unregisterClient(c3) - wantActive(t, nil) - wantDupKeys(t, 1) - wantDupConns(t, 2) - - s.unregisterClient(c2) - wantSingleClient(t, c1) - wantDupKeys(t, 0) - wantDupConns(t, 0) - }) - - // Key cloning with an A->B->C->A series instead. - run("concurrent_dups_three_parties", disableFighters, func(t *testing.T) { - wantNoClient(t) - s.registerClient(c1) - s.registerClient(c2) - s.registerClient(c3) - s.noteClientActivity(c1) - checkDisabled(t, c1, true) - checkDisabled(t, c2, true) - checkDisabled(t, c3, true) - wantActive(t, nil) - }) - - run("activity_promotes_primary_when_nil", disableFighters, func(t *testing.T) { - wantNoClient(t) - - // Last registered client is the active one... - s.registerClient(c1) - wantActive(t, c1) - s.registerClient(c2) - wantActive(t, c2) - s.registerClient(c3) - s.noteClientActivity(c2) - wantActive(t, c3) - - // But if the last one goes away, the one with the - // most recent activity wins. - s.unregisterClient(c3) - wantActive(t, c2) - }) - - run("concurrent_dups_three_parties_last_writer", lastWriterIsActive, func(t *testing.T) { - wantNoClient(t) - - s.registerClient(c1) - wantActive(t, c1) - s.registerClient(c2) - wantActive(t, c2) - - s.noteClientActivity(c1) - checkDisabled(t, c1, false) - checkDisabled(t, c2, false) - wantActive(t, c1) - - s.noteClientActivity(c2) - checkDisabled(t, c1, false) - checkDisabled(t, c2, false) - wantActive(t, c2) - - s.unregisterClient(c2) - checkDisabled(t, c1, false) - wantActive(t, c1) - }) -} - -func TestLimiter(t *testing.T) { - rl := rate.NewLimiter(rate.Every(time.Minute), 100) - for i := range 200 { - r := rl.Reserve() - d := r.Delay() - t.Logf("i=%d, allow=%v, d=%v", i, r.OK(), d) - } -} - -// BenchmarkConcurrentStreams exercises mutex contention on a -// single Server instance with multiple concurrent client flows. -func BenchmarkConcurrentStreams(b *testing.B) { - serverPrivateKey := key.NewNode() - s := NewServer(serverPrivateKey, logger.Discard) - defer s.Close() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - b.Fatal(err) - } - defer ln.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go func() { - for ctx.Err() == nil { - connIn, err := ln.Accept() - if err != nil { - if ctx.Err() != nil { - return - } - b.Error(err) - return - } - - brwServer := bufio.NewReadWriter(bufio.NewReader(connIn), bufio.NewWriter(connIn)) - go s.Accept(ctx, connIn, brwServer, "test-client") - } - }() - - newClient := func(t testing.TB) *Client { - t.Helper() - connOut, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { - b.Fatal(err) - } - t.Cleanup(func() { connOut.Close() }) - - k := key.NewNode() - - brw := bufio.NewReadWriter(bufio.NewReader(connOut), bufio.NewWriter(connOut)) - client, err := NewClient(k, connOut, brw, logger.Discard) - if err != nil { - b.Fatalf("client: %v", err) - } - return client - } - - b.RunParallel(func(pb *testing.PB) { - c1, c2 := newClient(b), newClient(b) - const packetSize = 100 - msg := make([]byte, packetSize) - for pb.Next() { - if err := c1.Send(c2.PublicKey(), msg); err != nil { - b.Fatal(err) - } - _, err := c2.Recv() - if err != nil { - return - } - } - }) -} - -func BenchmarkSendRecv(b *testing.B) { - for _, size := range []int{10, 100, 1000, 10000} { - b.Run(fmt.Sprintf("msgsize=%d", size), func(b *testing.B) { benchmarkSendRecvSize(b, size) }) - } -} - -func benchmarkSendRecvSize(b *testing.B, packetSize int) { - serverPrivateKey := key.NewNode() - s := NewServer(serverPrivateKey, logger.Discard) - defer s.Close() - - k := key.NewNode() - clientKey := k.Public() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - b.Fatal(err) - } - defer ln.Close() - - connOut, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { - b.Fatal(err) - } - defer connOut.Close() - - connIn, err := ln.Accept() - if err != nil { - b.Fatal(err) - } - defer connIn.Close() - - brwServer := bufio.NewReadWriter(bufio.NewReader(connIn), bufio.NewWriter(connIn)) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go s.Accept(ctx, connIn, brwServer, "test-client") - - brw := bufio.NewReadWriter(bufio.NewReader(connOut), bufio.NewWriter(connOut)) - client, err := NewClient(k, connOut, brw, logger.Discard) - if err != nil { - b.Fatalf("client: %v", err) - } - - go func() { - for { - _, err := client.Recv() - if err != nil { - return - } - } - }() - - msg := make([]byte, packetSize) - b.SetBytes(int64(len(msg))) - b.ReportAllocs() - b.ResetTimer() - for range b.N { - if err := client.Send(clientKey, msg); err != nil { - b.Fatal(err) - } - } -} - -func BenchmarkWriteUint32(b *testing.B) { - w := bufio.NewWriter(io.Discard) - b.ReportAllocs() - b.ResetTimer() - for range b.N { - writeUint32(w, 0x0ba3a) - } -} - -type nopRead struct{} - -func (r nopRead) Read(p []byte) (int, error) { - return len(p), nil -} - -var sinkU32 uint32 - -func BenchmarkReadUint32(b *testing.B) { - r := bufio.NewReader(nopRead{}) - var err error - b.ReportAllocs() - b.ResetTimer() - for range b.N { - sinkU32, err = readUint32(r) - if err != nil { - b.Fatal(err) - } - } -} - -func waitConnect(t testing.TB, c *Client) { - t.Helper() - if m, err := c.Recv(); err != nil { - t.Fatalf("client first Recv: %v", err) - } else if v, ok := m.(ServerInfoMessage); !ok { - t.Fatalf("client first Recv was unexpected type %T", v) - } -} - -func TestParseSSOutput(t *testing.T) { - contents, err := os.ReadFile("testdata/example_ss.txt") - if err != nil { - t.Errorf("os.ReadFile(example_ss.txt) failed: %v", err) - } - seen := parseSSOutput(string(contents)) - if len(seen) == 0 { - t.Errorf("parseSSOutput expected non-empty map") - } -} - -type countWriter struct { - mu sync.Mutex - writes int - bytes int64 -} - -func (w *countWriter) Write(p []byte) (n int, err error) { - w.mu.Lock() - defer w.mu.Unlock() - w.writes++ - w.bytes += int64(len(p)) - return len(p), nil -} - -func (w *countWriter) Stats() (writes int, bytes int64) { - w.mu.Lock() - defer w.mu.Unlock() - return w.writes, w.bytes -} - -func (w *countWriter) ResetStats() { - w.mu.Lock() - defer w.mu.Unlock() - w.writes, w.bytes = 0, 0 -} - -func TestClientSendRateLimiting(t *testing.T) { - cw := new(countWriter) - c := &Client{ - bw: bufio.NewWriter(cw), - clock: &tstest.Clock{}, - } - c.setSendRateLimiter(ServerInfoMessage{}) - - pkt := make([]byte, 1000) - if err := c.send(key.NodePublic{}, pkt); err != nil { - t.Fatal(err) - } - writes1, bytes1 := cw.Stats() - if writes1 != 1 { - t.Errorf("writes = %v, want 1", writes1) - } - - // Flood should all succeed. - cw.ResetStats() - for range 1000 { - if err := c.send(key.NodePublic{}, pkt); err != nil { - t.Fatal(err) - } - } - writes1K, bytes1K := cw.Stats() - if writes1K != 1000 { - t.Logf("writes = %v; want 1000", writes1K) - } - if got, want := bytes1K, bytes1*1000; got != want { - t.Logf("bytes = %v; want %v", got, want) - } - - // Set a rate limiter - cw.ResetStats() - c.setSendRateLimiter(ServerInfoMessage{ - TokenBucketBytesPerSecond: 1, - TokenBucketBytesBurst: int(bytes1 * 2), - }) - for range 1000 { - if err := c.send(key.NodePublic{}, pkt); err != nil { - t.Fatal(err) - } - } - writesLimited, bytesLimited := cw.Stats() - if writesLimited == 0 || writesLimited == writes1K { - t.Errorf("limited conn's write count = %v; want non-zero, less than 1k", writesLimited) - } - if bytesLimited < bytes1*2 || bytesLimited >= bytes1K { - t.Errorf("limited conn's bytes count = %v; want >=%v, <%v", bytesLimited, bytes1K*2, bytes1K) - } -} - -func TestServerRepliesToPing(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ts := newTestServer(t, ctx) - defer ts.close(t) - - tc := newRegularClient(t, ts, "alice") - - data := [8]byte{1, 2, 3, 4, 5, 6, 7, 42} - - if err := tc.c.SendPing(data); err != nil { - t.Fatal(err) - } - - for { - m, err := tc.c.recvTimeout(time.Second) - if err != nil { - t.Fatal(err) - } - switch m := m.(type) { - case PongMessage: - if ([8]byte(m)) != data { - t.Fatalf("got pong %2x; want %2x", [8]byte(m), data) - } - return - } - } -} diff --git a/derp/derphttp/derphttp_client.go b/derp/derphttp/derphttp_client.go index c95d072b1a572..c1eb9e9dfcd74 100644 --- a/derp/derphttp/derphttp_client.go +++ b/derp/derphttp/derphttp_client.go @@ -28,21 +28,21 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/derp" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/tlsdial" + "github.com/sagernet/tailscale/net/tshttpproxy" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" "go4.org/mem" - "tailscale.com/derp" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/dnscache" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/sockstats" - "tailscale.com/net/tlsdial" - "tailscale.com/net/tshttpproxy" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" ) // Client is a DERP-over-HTTP client. diff --git a/derp/derphttp/derphttp_server.go b/derp/derphttp/derphttp_server.go index ed7d3d7073866..af63b7f12fe85 100644 --- a/derp/derphttp/derphttp_server.go +++ b/derp/derphttp/derphttp_server.go @@ -9,7 +9,7 @@ import ( "net/http" "strings" - "tailscale.com/derp" + "github.com/sagernet/tailscale/derp" ) // fastStartHeader is the header (with value "1") that signals to the HTTP diff --git a/derp/derphttp/derphttp_test.go b/derp/derphttp/derphttp_test.go deleted file mode 100644 index cf6032a5e6d43..0000000000000 --- a/derp/derphttp/derphttp_test.go +++ /dev/null @@ -1,509 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package derphttp - -import ( - "bytes" - "context" - "crypto/tls" - "fmt" - "net" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "tailscale.com/derp" - "tailscale.com/net/netmon" - "tailscale.com/tstest/deptest" - "tailscale.com/types/key" - "tailscale.com/util/set" -) - -func TestSendRecv(t *testing.T) { - serverPrivateKey := key.NewNode() - - netMon := netmon.NewStatic() - - const numClients = 3 - var clientPrivateKeys []key.NodePrivate - var clientKeys []key.NodePublic - for range numClients { - priv := key.NewNode() - clientPrivateKeys = append(clientPrivateKeys, priv) - clientKeys = append(clientKeys, priv.Public()) - } - - s := derp.NewServer(serverPrivateKey, t.Logf) - defer s.Close() - - httpsrv := &http.Server{ - TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), - Handler: Handler(s), - } - - ln, err := net.Listen("tcp4", "localhost:0") - if err != nil { - t.Fatal(err) - } - serverURL := "http://" + ln.Addr().String() - t.Logf("server URL: %s", serverURL) - - go func() { - if err := httpsrv.Serve(ln); err != nil { - if err == http.ErrServerClosed { - return - } - panic(err) - } - }() - - var clients []*Client - var recvChs []chan []byte - done := make(chan struct{}) - var wg sync.WaitGroup - defer func() { - close(done) - for _, c := range clients { - c.Close() - } - wg.Wait() - }() - for i := range numClients { - key := clientPrivateKeys[i] - c, err := NewClient(key, serverURL, t.Logf, netMon) - if err != nil { - t.Fatalf("client %d: %v", i, err) - } - if err := c.Connect(context.Background()); err != nil { - t.Fatalf("client %d Connect: %v", i, err) - } - waitConnect(t, c) - clients = append(clients, c) - recvChs = append(recvChs, make(chan []byte)) - - wg.Add(1) - go func(i int) { - defer wg.Done() - for { - select { - case <-done: - return - default: - } - m, err := c.Recv() - if err != nil { - select { - case <-done: - return - default: - } - t.Logf("client%d: %v", i, err) - break - } - switch m := m.(type) { - default: - t.Errorf("unexpected message type %T", m) - continue - case derp.PeerGoneMessage: - // Ignore. - case derp.ReceivedPacket: - recvChs[i] <- bytes.Clone(m.Data) - } - } - }(i) - } - - recv := func(i int, want string) { - t.Helper() - select { - case b := <-recvChs[i]: - if got := string(b); got != want { - t.Errorf("client1.Recv=%q, want %q", got, want) - } - case <-time.After(5 * time.Second): - t.Errorf("client%d.Recv, got nothing, want %q", i, want) - } - } - recvNothing := func(i int) { - t.Helper() - select { - case b := <-recvChs[0]: - t.Errorf("client%d.Recv=%q, want nothing", i, string(b)) - default: - } - } - - msg1 := []byte("hello 0->1\n") - if err := clients[0].Send(clientKeys[1], msg1); err != nil { - t.Fatal(err) - } - recv(1, string(msg1)) - recvNothing(0) - recvNothing(2) - - msg2 := []byte("hello 1->2\n") - if err := clients[1].Send(clientKeys[2], msg2); err != nil { - t.Fatal(err) - } - recv(2, string(msg2)) - recvNothing(0) - recvNothing(1) -} - -func waitConnect(t testing.TB, c *Client) { - t.Helper() - if m, err := c.Recv(); err != nil { - t.Fatalf("client first Recv: %v", err) - } else if v, ok := m.(derp.ServerInfoMessage); !ok { - t.Fatalf("client first Recv was unexpected type %T", v) - } -} - -func TestPing(t *testing.T) { - serverPrivateKey := key.NewNode() - s := derp.NewServer(serverPrivateKey, t.Logf) - defer s.Close() - - httpsrv := &http.Server{ - TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), - Handler: Handler(s), - } - - ln, err := net.Listen("tcp4", "localhost:0") - if err != nil { - t.Fatal(err) - } - serverURL := "http://" + ln.Addr().String() - t.Logf("server URL: %s", serverURL) - - go func() { - if err := httpsrv.Serve(ln); err != nil { - if err == http.ErrServerClosed { - return - } - panic(err) - } - }() - - c, err := NewClient(key.NewNode(), serverURL, t.Logf, netmon.NewStatic()) - if err != nil { - t.Fatalf("NewClient: %v", err) - } - defer c.Close() - if err := c.Connect(context.Background()); err != nil { - t.Fatalf("client Connect: %v", err) - } - - errc := make(chan error, 1) - go func() { - for { - m, err := c.Recv() - if err != nil { - errc <- err - return - } - t.Logf("Recv: %T", m) - } - }() - err = c.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping: %v", err) - } -} - -func newTestServer(t *testing.T, k key.NodePrivate) (serverURL string, s *derp.Server) { - s = derp.NewServer(k, t.Logf) - httpsrv := &http.Server{ - TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), - Handler: Handler(s), - } - - ln, err := net.Listen("tcp4", "localhost:0") - if err != nil { - t.Fatal(err) - } - serverURL = "http://" + ln.Addr().String() - s.SetMeshKey("1234") - - go func() { - if err := httpsrv.Serve(ln); err != nil { - if err == http.ErrServerClosed { - t.Logf("server closed") - return - } - panic(err) - } - }() - return -} - -func newWatcherClient(t *testing.T, watcherPrivateKey key.NodePrivate, serverToWatchURL string) (c *Client) { - c, err := NewClient(watcherPrivateKey, serverToWatchURL, t.Logf, netmon.NewStatic()) - if err != nil { - t.Fatal(err) - } - c.MeshKey = "1234" - return -} - -// breakConnection breaks the connection, which should trigger a reconnect. -func (c *Client) breakConnection(brokenClient *derp.Client) { - c.mu.Lock() - defer c.mu.Unlock() - if c.client != brokenClient { - return - } - if c.netConn != nil { - c.netConn.Close() - c.netConn = nil - } - c.client = nil -} - -// Test that a watcher connection successfully reconnects and processes peer -// updates after a different thread breaks and reconnects the connection, while -// the watcher is waiting on recv(). -func TestBreakWatcherConnRecv(t *testing.T) { - // Set the wait time before a retry after connection failure to be much lower. - // This needs to be early in the test, for defer to run right at the end after - // the DERP client has finished. - origRetryInterval := retryInterval - retryInterval = 50 * time.Millisecond - defer func() { retryInterval = origRetryInterval }() - - var wg sync.WaitGroup - defer wg.Wait() - // Make the watcher server - serverPrivateKey1 := key.NewNode() - _, s1 := newTestServer(t, serverPrivateKey1) - defer s1.Close() - - // Make the watched server - serverPrivateKey2 := key.NewNode() - serverURL2, s2 := newTestServer(t, serverPrivateKey2) - defer s2.Close() - - // Make the watcher (but it is not connected yet) - watcher1 := newWatcherClient(t, serverPrivateKey1, serverURL2) - defer watcher1.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - watcherChan := make(chan int, 1) - - // Start the watcher thread (which connects to the watched server) - wg.Add(1) // To avoid using t.Logf after the test ends. See https://golang.org/issue/40343 - go func() { - defer wg.Done() - var peers int - add := func(m derp.PeerPresentMessage) { - t.Logf("add: %v", m.Key.ShortString()) - peers++ - // Signal that the watcher has run - watcherChan <- peers - } - remove := func(m derp.PeerGoneMessage) { t.Logf("remove: %v", m.Peer.ShortString()); peers-- } - - watcher1.RunWatchConnectionLoop(ctx, serverPrivateKey1.Public(), t.Logf, add, remove) - }() - - timer := time.NewTimer(5 * time.Second) - defer timer.Stop() - - // Wait for the watcher to run, then break the connection and check if it - // reconnected and received peer updates. - for range 10 { - select { - case peers := <-watcherChan: - if peers != 1 { - t.Fatal("wrong number of peers added during watcher connection") - } - case <-timer.C: - t.Fatalf("watcher did not process the peer update") - } - watcher1.breakConnection(watcher1.client) - // re-establish connection by sending a packet - watcher1.ForwardPacket(key.NodePublic{}, key.NodePublic{}, []byte("bogus")) - - timer.Reset(5 * time.Second) - } -} - -// Test that a watcher connection successfully reconnects and processes peer -// updates after a different thread breaks and reconnects the connection, while -// the watcher is not waiting on recv(). -func TestBreakWatcherConn(t *testing.T) { - // Set the wait time before a retry after connection failure to be much lower. - // This needs to be early in the test, for defer to run right at the end after - // the DERP client has finished. - origRetryInterval := retryInterval - retryInterval = 50 * time.Millisecond - defer func() { retryInterval = origRetryInterval }() - - var wg sync.WaitGroup - defer wg.Wait() - // Make the watcher server - serverPrivateKey1 := key.NewNode() - _, s1 := newTestServer(t, serverPrivateKey1) - defer s1.Close() - - // Make the watched server - serverPrivateKey2 := key.NewNode() - serverURL2, s2 := newTestServer(t, serverPrivateKey2) - defer s2.Close() - - // Make the watcher (but it is not connected yet) - watcher1 := newWatcherClient(t, serverPrivateKey1, serverURL2) - defer watcher1.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - watcherChan := make(chan int, 1) - breakerChan := make(chan bool, 1) - - // Start the watcher thread (which connects to the watched server) - wg.Add(1) // To avoid using t.Logf after the test ends. See https://golang.org/issue/40343 - go func() { - defer wg.Done() - var peers int - add := func(m derp.PeerPresentMessage) { - t.Logf("add: %v", m.Key.ShortString()) - peers++ - // Signal that the watcher has run - watcherChan <- peers - // Wait for breaker to run - <-breakerChan - } - remove := func(m derp.PeerGoneMessage) { t.Logf("remove: %v", m.Peer.ShortString()); peers-- } - - watcher1.RunWatchConnectionLoop(ctx, serverPrivateKey1.Public(), t.Logf, add, remove) - }() - - timer := time.NewTimer(5 * time.Second) - defer timer.Stop() - - // Wait for the watcher to run, then break the connection and check if it - // reconnected and received peer updates. - for range 10 { - select { - case peers := <-watcherChan: - if peers != 1 { - t.Fatal("wrong number of peers added during watcher connection") - } - case <-timer.C: - t.Fatalf("watcher did not process the peer update") - } - watcher1.breakConnection(watcher1.client) - // re-establish connection by sending a packet - watcher1.ForwardPacket(key.NodePublic{}, key.NodePublic{}, []byte("bogus")) - // signal that the breaker is done - breakerChan <- true - - timer.Reset(5 * time.Second) - } -} - -func noopAdd(derp.PeerPresentMessage) {} -func noopRemove(derp.PeerGoneMessage) {} - -func TestRunWatchConnectionLoopServeConnect(t *testing.T) { - defer func() { testHookWatchLookConnectResult = nil }() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - priv := key.NewNode() - serverURL, s := newTestServer(t, priv) - defer s.Close() - - pub := priv.Public() - - watcher := newWatcherClient(t, priv, serverURL) - defer watcher.Close() - - // Test connecting to ourselves, and that we get hung up on. - testHookWatchLookConnectResult = func(err error, wasSelfConnect bool) bool { - t.Helper() - if err != nil { - t.Fatalf("error connecting to server: %v", err) - } - if !wasSelfConnect { - t.Error("wanted self-connect; wasn't") - } - return false - } - watcher.RunWatchConnectionLoop(ctx, pub, t.Logf, noopAdd, noopRemove) - - // Test connecting to the server with a zero value for ignoreServerKey, - // so we should always connect. - testHookWatchLookConnectResult = func(err error, wasSelfConnect bool) bool { - t.Helper() - if err != nil { - t.Fatalf("error connecting to server: %v", err) - } - if wasSelfConnect { - t.Error("wanted normal connect; got self connect") - } - return false - } - watcher.RunWatchConnectionLoop(ctx, key.NodePublic{}, t.Logf, noopAdd, noopRemove) -} - -// verify that the LocalAddr method doesn't acquire the mutex. -// See https://github.com/tailscale/tailscale/issues/11519 -func TestLocalAddrNoMutex(t *testing.T) { - var c Client - c.mu.Lock() - defer c.mu.Unlock() // not needed in test but for symmetry - - _, err := c.LocalAddr() - if got, want := fmt.Sprint(err), "client not connected"; got != want { - t.Errorf("got error %q; want %q", got, want) - } -} - -func TestProbe(t *testing.T) { - h := Handler(nil) - - tests := []struct { - path string - want int - }{ - {"/derp/probe", 200}, - {"/derp/latency-check", 200}, - {"/derp/sdf", http.StatusUpgradeRequired}, - } - - for _, tt := range tests { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest("GET", tt.path, nil)) - if got := rec.Result().StatusCode; got != tt.want { - t.Errorf("for path %q got HTTP status %v; want %v", tt.path, got, tt.want) - } - } -} - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - GOOS: "darwin", - GOARCH: "arm64", - BadDeps: map[string]string{ - "github.com/coder/websocket": "shouldn't link websockets except on js/wasm", - }, - }.Check(t) - - deptest.DepChecker{ - GOOS: "darwin", - GOARCH: "arm64", - Tags: "ts_debug_websockets", - WantDeps: set.Of( - "github.com/coder/websocket", - ), - }.Check(t) - -} diff --git a/derp/derphttp/mesh_client.go b/derp/derphttp/mesh_client.go index 66b8c166eeb37..0b8e2be7c90ab 100644 --- a/derp/derphttp/mesh_client.go +++ b/derp/derphttp/mesh_client.go @@ -8,9 +8,9 @@ import ( "sync" "time" - "tailscale.com/derp" - "tailscale.com/types/key" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/derp" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" ) var retryInterval = 5 * time.Second diff --git a/derp/derphttp/websocket.go b/derp/derphttp/websocket.go index 9dd640ee37083..17b028a80fdfc 100644 --- a/derp/derphttp/websocket.go +++ b/derp/derphttp/websocket.go @@ -11,7 +11,7 @@ import ( "net" "github.com/coder/websocket" - "tailscale.com/net/wsconn" + "github.com/sagernet/tailscale/net/wsconn" ) const canWebsockets = true diff --git a/derp/xdp/xdp_linux.go b/derp/xdp/xdp_linux.go index 3ebe0a0520efc..3d614c36a5c0c 100644 --- a/derp/xdp/xdp_linux.go +++ b/derp/xdp/xdp_linux.go @@ -14,7 +14,7 @@ import ( "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "github.com/prometheus/client_golang/prometheus" - "tailscale.com/util/multierr" + "github.com/sagernet/tailscale/util/multierr" ) //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type config -type counters_key -type counter_key_af -type counter_key_packets_bytes_action -type counter_key_prog_end bpf xdp.c -- -I headers diff --git a/derp/xdp/xdp_linux_test.go b/derp/xdp/xdp_linux_test.go deleted file mode 100644 index 07f11eff65b09..0000000000000 --- a/derp/xdp/xdp_linux_test.go +++ /dev/null @@ -1,1066 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package xdp - -import ( - "bytes" - "errors" - "fmt" - "net/netip" - "testing" - - "github.com/cilium/ebpf" - "golang.org/x/sys/unix" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/checksum" - "gvisor.dev/gvisor/pkg/tcpip/header" - "tailscale.com/net/stun" -) - -type xdpAction uint32 - -func (x xdpAction) String() string { - switch x { - case xdpActionAborted: - return "XDP_ABORTED" - case xdpActionDrop: - return "XDP_DROP" - case xdpActionPass: - return "XDP_PASS" - case xdpActionTX: - return "XDP_TX" - case xdpActionRedirect: - return "XDP_REDIRECT" - default: - return fmt.Sprintf("unknown(%d)", x) - } -} - -const ( - xdpActionAborted xdpAction = iota - xdpActionDrop - xdpActionPass - xdpActionTX - xdpActionRedirect -) - -const ( - ethHLen = 14 - udpHLen = 8 - ipv4HLen = 20 - ipv6HLen = 40 -) - -const ( - defaultSTUNPort = 3478 - defaultTTL = 64 - reqSrcPort = uint16(1025) -) - -var ( - reqEthSrc = tcpip.LinkAddress([]byte{0x00, 0x00, 0x5e, 0x00, 0x53, 0x01}) - reqEthDst = tcpip.LinkAddress([]byte{0x00, 0x00, 0x5e, 0x00, 0x53, 0x02}) - reqIPv4Src = netip.MustParseAddr("192.0.2.1") - reqIPv4Dst = netip.MustParseAddr("192.0.2.2") - reqIPv6Src = netip.MustParseAddr("2001:db8::1") - reqIPv6Dst = netip.MustParseAddr("2001:db8::2") -) - -var testTXID = stun.TxID([12]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}) - -type ipv4Mutations struct { - ipHeaderFn func(header.IPv4) - udpHeaderFn func(header.UDP) - stunReqFn func([]byte) -} - -func getIPv4STUNBindingReq(mutations *ipv4Mutations) []byte { - req := stun.Request(testTXID) - if mutations != nil && mutations.stunReqFn != nil { - mutations.stunReqFn(req) - } - payloadLen := len(req) - totalLen := ipv4HLen + udpHLen + payloadLen - b := make([]byte, ethHLen+totalLen) - ipv4H := header.IPv4(b[ethHLen:]) - ethH := header.Ethernet(b) - ethFields := header.EthernetFields{ - SrcAddr: reqEthSrc, - DstAddr: reqEthDst, - Type: unix.ETH_P_IP, - } - ethH.Encode(ðFields) - ipFields := header.IPv4Fields{ - SrcAddr: tcpip.AddrFrom4(reqIPv4Src.As4()), - DstAddr: tcpip.AddrFrom4(reqIPv4Dst.As4()), - Protocol: unix.IPPROTO_UDP, - TTL: defaultTTL, - TotalLength: uint16(totalLen), - } - ipv4H.Encode(&ipFields) - ipv4H.SetChecksum(^ipv4H.CalculateChecksum()) - if mutations != nil && mutations.ipHeaderFn != nil { - mutations.ipHeaderFn(ipv4H) - } - udpH := header.UDP(b[ethHLen+ipv4HLen:]) - udpFields := header.UDPFields{ - SrcPort: reqSrcPort, - DstPort: defaultSTUNPort, - Length: uint16(udpHLen + payloadLen), - Checksum: 0, - } - udpH.Encode(&udpFields) - copy(b[ethHLen+ipv4HLen+udpHLen:], req) - cs := header.PseudoHeaderChecksum( - unix.IPPROTO_UDP, - ipv4H.SourceAddress(), - ipv4H.DestinationAddress(), - uint16(udpHLen+payloadLen), - ) - cs = checksum.Checksum(req, cs) - udpH.SetChecksum(^udpH.CalculateChecksum(cs)) - if mutations != nil && mutations.udpHeaderFn != nil { - mutations.udpHeaderFn(udpH) - } - return b -} - -type ipv6Mutations struct { - ipHeaderFn func(header.IPv6) - udpHeaderFn func(header.UDP) - stunReqFn func([]byte) -} - -func getIPv6STUNBindingReq(mutations *ipv6Mutations) []byte { - req := stun.Request(testTXID) - if mutations != nil && mutations.stunReqFn != nil { - mutations.stunReqFn(req) - } - payloadLen := len(req) - src := netip.MustParseAddr("2001:db8::1") - dst := netip.MustParseAddr("2001:db8::2") - b := make([]byte, ethHLen+ipv6HLen+udpHLen+payloadLen) - ipv6H := header.IPv6(b[ethHLen:]) - ethH := header.Ethernet(b) - ethFields := header.EthernetFields{ - SrcAddr: tcpip.LinkAddress([]byte{0x00, 0x00, 0x5e, 0x00, 0x53, 0x01}), - DstAddr: tcpip.LinkAddress([]byte{0x00, 0x00, 0x5e, 0x00, 0x53, 0x02}), - Type: unix.ETH_P_IPV6, - } - ethH.Encode(ðFields) - ipFields := header.IPv6Fields{ - SrcAddr: tcpip.AddrFrom16(src.As16()), - DstAddr: tcpip.AddrFrom16(dst.As16()), - TransportProtocol: unix.IPPROTO_UDP, - HopLimit: 64, - PayloadLength: uint16(udpHLen + payloadLen), - } - ipv6H.Encode(&ipFields) - if mutations != nil && mutations.ipHeaderFn != nil { - mutations.ipHeaderFn(ipv6H) - } - udpH := header.UDP(b[ethHLen+ipv6HLen:]) - udpFields := header.UDPFields{ - SrcPort: 1025, - DstPort: defaultSTUNPort, - Length: uint16(udpHLen + payloadLen), - Checksum: 0, - } - udpH.Encode(&udpFields) - copy(b[ethHLen+ipv6HLen+udpHLen:], req) - cs := header.PseudoHeaderChecksum( - unix.IPPROTO_UDP, - ipv6H.SourceAddress(), - ipv6H.DestinationAddress(), - uint16(udpHLen+payloadLen), - ) - cs = checksum.Checksum(req, cs) - udpH.SetChecksum(^udpH.CalculateChecksum(cs)) - if mutations != nil && mutations.udpHeaderFn != nil { - mutations.udpHeaderFn(udpH) - } - return b -} - -func getIPv4STUNBindingResp() []byte { - addrPort := netip.AddrPortFrom(reqIPv4Src, reqSrcPort) - resp := stun.Response(testTXID, addrPort) - payloadLen := len(resp) - totalLen := ipv4HLen + udpHLen + payloadLen - b := make([]byte, ethHLen+totalLen) - ipv4H := header.IPv4(b[ethHLen:]) - ethH := header.Ethernet(b) - ethFields := header.EthernetFields{ - SrcAddr: reqEthDst, - DstAddr: reqEthSrc, - Type: unix.ETH_P_IP, - } - ethH.Encode(ðFields) - ipFields := header.IPv4Fields{ - SrcAddr: tcpip.AddrFrom4(reqIPv4Dst.As4()), - DstAddr: tcpip.AddrFrom4(reqIPv4Src.As4()), - Protocol: unix.IPPROTO_UDP, - TTL: defaultTTL, - TotalLength: uint16(totalLen), - } - ipv4H.Encode(&ipFields) - ipv4H.SetChecksum(^ipv4H.CalculateChecksum()) - udpH := header.UDP(b[ethHLen+ipv4HLen:]) - udpFields := header.UDPFields{ - SrcPort: defaultSTUNPort, - DstPort: reqSrcPort, - Length: uint16(udpHLen + payloadLen), - Checksum: 0, - } - udpH.Encode(&udpFields) - copy(b[ethHLen+ipv4HLen+udpHLen:], resp) - cs := header.PseudoHeaderChecksum( - unix.IPPROTO_UDP, - ipv4H.SourceAddress(), - ipv4H.DestinationAddress(), - uint16(udpHLen+payloadLen), - ) - cs = checksum.Checksum(resp, cs) - udpH.SetChecksum(^udpH.CalculateChecksum(cs)) - return b -} - -func getIPv6STUNBindingResp() []byte { - addrPort := netip.AddrPortFrom(reqIPv6Src, reqSrcPort) - resp := stun.Response(testTXID, addrPort) - payloadLen := len(resp) - totalLen := ipv6HLen + udpHLen + payloadLen - b := make([]byte, ethHLen+totalLen) - ipv6H := header.IPv6(b[ethHLen:]) - ethH := header.Ethernet(b) - ethFields := header.EthernetFields{ - SrcAddr: reqEthDst, - DstAddr: reqEthSrc, - Type: unix.ETH_P_IPV6, - } - ethH.Encode(ðFields) - ipFields := header.IPv6Fields{ - SrcAddr: tcpip.AddrFrom16(reqIPv6Dst.As16()), - DstAddr: tcpip.AddrFrom16(reqIPv6Src.As16()), - TransportProtocol: unix.IPPROTO_UDP, - HopLimit: defaultTTL, - PayloadLength: uint16(udpHLen + payloadLen), - } - ipv6H.Encode(&ipFields) - udpH := header.UDP(b[ethHLen+ipv6HLen:]) - udpFields := header.UDPFields{ - SrcPort: defaultSTUNPort, - DstPort: reqSrcPort, - Length: uint16(udpHLen + payloadLen), - Checksum: 0, - } - udpH.Encode(&udpFields) - copy(b[ethHLen+ipv6HLen+udpHLen:], resp) - cs := header.PseudoHeaderChecksum( - unix.IPPROTO_UDP, - ipv6H.SourceAddress(), - ipv6H.DestinationAddress(), - uint16(udpHLen+payloadLen), - ) - cs = checksum.Checksum(resp, cs) - udpH.SetChecksum(^udpH.CalculateChecksum(cs)) - return b -} - -func TestXDP(t *testing.T) { - ipv4STUNBindingReqTX := getIPv4STUNBindingReq(nil) - ipv6STUNBindingReqTX := getIPv6STUNBindingReq(nil) - - ipv4STUNBindingReqIPCsumPass := getIPv4STUNBindingReq(&ipv4Mutations{ - ipHeaderFn: func(ipv4H header.IPv4) { - oldCS := ipv4H.Checksum() - newCS := oldCS - for newCS == 0 || newCS == oldCS { - newCS++ - } - ipv4H.SetChecksum(newCS) - }, - }) - - ipv4STUNBindingReqIHLPass := getIPv4STUNBindingReq(&ipv4Mutations{ - ipHeaderFn: func(ipv4H header.IPv4) { - ipv4H[0] &= 0xF0 - }, - }) - - ipv4STUNBindingReqIPVerPass := getIPv4STUNBindingReq(&ipv4Mutations{ - ipHeaderFn: func(ipv4H header.IPv4) { - ipv4H[0] &= 0x0F - }, - }) - - ipv4STUNBindingReqIPProtoPass := getIPv4STUNBindingReq(&ipv4Mutations{ - ipHeaderFn: func(ipv4H header.IPv4) { - ipv4H[9] = unix.IPPROTO_TCP - }, - }) - - ipv4STUNBindingReqFragOffsetPass := getIPv4STUNBindingReq(&ipv4Mutations{ - ipHeaderFn: func(ipv4H header.IPv4) { - ipv4H.SetFlagsFragmentOffset(ipv4H.Flags(), 8) - }, - }) - - ipv4STUNBindingReqFlagsMFPass := getIPv4STUNBindingReq(&ipv4Mutations{ - ipHeaderFn: func(ipv4H header.IPv4) { - ipv4H.SetFlagsFragmentOffset(header.IPv4FlagMoreFragments, 0) - }, - }) - - ipv4STUNBindingReqTotLenPass := getIPv4STUNBindingReq(&ipv4Mutations{ - ipHeaderFn: func(ipv4H header.IPv4) { - ipv4H.SetTotalLength(ipv4H.TotalLength() + 1) - ipv4H.SetChecksum(0) - ipv4H.SetChecksum(^ipv4H.CalculateChecksum()) - }, - }) - - ipv6STUNBindingReqIPVerPass := getIPv6STUNBindingReq(&ipv6Mutations{ - ipHeaderFn: func(ipv6H header.IPv6) { - ipv6H[0] &= 0x0F - }, - udpHeaderFn: func(udp header.UDP) {}, - }) - - ipv6STUNBindingReqNextHdrPass := getIPv6STUNBindingReq(&ipv6Mutations{ - ipHeaderFn: func(ipv6H header.IPv6) { - ipv6H.SetNextHeader(unix.IPPROTO_TCP) - }, - udpHeaderFn: func(udp header.UDP) {}, - }) - - ipv6STUNBindingReqPayloadLenPass := getIPv6STUNBindingReq(&ipv6Mutations{ - ipHeaderFn: func(ipv6H header.IPv6) { - ipv6H.SetPayloadLength(ipv6H.PayloadLength() + 1) - }, - udpHeaderFn: func(udp header.UDP) {}, - }) - - ipv4STUNBindingReqUDPCsumPass := getIPv4STUNBindingReq(&ipv4Mutations{ - udpHeaderFn: func(udpH header.UDP) { - oldCS := udpH.Checksum() - newCS := oldCS - for newCS == 0 || newCS == oldCS { - newCS++ - } - udpH.SetChecksum(newCS) - }, - }) - - ipv6STUNBindingReqUDPCsumPass := getIPv6STUNBindingReq(&ipv6Mutations{ - udpHeaderFn: func(udpH header.UDP) { - oldCS := udpH.Checksum() - newCS := oldCS - for newCS == 0 || newCS == oldCS { - newCS++ - } - udpH.SetChecksum(newCS) - }, - }) - - ipv4STUNBindingReqSTUNTypePass := getIPv4STUNBindingReq(&ipv4Mutations{ - stunReqFn: func(req []byte) { - req[1] = ^req[1] - }, - }) - - ipv6STUNBindingReqSTUNTypePass := getIPv6STUNBindingReq(&ipv6Mutations{ - stunReqFn: func(req []byte) { - req[1] = ^req[1] - }, - }) - - ipv4STUNBindingReqSTUNMagicPass := getIPv4STUNBindingReq(&ipv4Mutations{ - stunReqFn: func(req []byte) { - req[4] = ^req[4] - }, - }) - - ipv6STUNBindingReqSTUNMagicPass := getIPv6STUNBindingReq(&ipv6Mutations{ - stunReqFn: func(req []byte) { - req[4] = ^req[4] - }, - }) - - ipv4STUNBindingReqSTUNAttrsLenPass := getIPv4STUNBindingReq(&ipv4Mutations{ - stunReqFn: func(req []byte) { - req[2] = ^req[2] - }, - }) - - ipv6STUNBindingReqSTUNAttrsLenPass := getIPv6STUNBindingReq(&ipv6Mutations{ - stunReqFn: func(req []byte) { - req[2] = ^req[2] - }, - }) - - ipv4STUNBindingReqSTUNSWValPass := getIPv4STUNBindingReq(&ipv4Mutations{ - stunReqFn: func(req []byte) { - req[24] = ^req[24] - }, - }) - - ipv6STUNBindingReqSTUNSWValPass := getIPv6STUNBindingReq(&ipv6Mutations{ - stunReqFn: func(req []byte) { - req[24] = ^req[24] - }, - }) - - ipv4STUNBindingReqSTUNFirstAttrPass := getIPv4STUNBindingReq(&ipv4Mutations{ - stunReqFn: func(req []byte) { - req[21] = ^req[21] - }, - }) - - ipv6STUNBindingReqSTUNFirstAttrPass := getIPv6STUNBindingReq(&ipv6Mutations{ - stunReqFn: func(req []byte) { - req[21] = ^req[21] - }, - }) - - ipv4STUNBindingReqUDPZeroCsumTx := getIPv4STUNBindingReq(&ipv4Mutations{ - udpHeaderFn: func(udpH header.UDP) { - udpH.SetChecksum(0) - }, - }) - - ipv6STUNBindingReqUDPZeroCsumPass := getIPv6STUNBindingReq(&ipv6Mutations{ - udpHeaderFn: func(udpH header.UDP) { - udpH.SetChecksum(0) - }, - }) - - cases := []struct { - name string - dropSTUN bool - packetIn []byte - wantCode xdpAction - wantPacketOut []byte - wantMetrics map[bpfCountersKey]uint64 - }{ - { - name: "ipv4 STUN Binding Request Drop STUN", - dropSTUN: true, - packetIn: ipv4STUNBindingReqTX, - wantCode: xdpActionDrop, - wantPacketOut: ipv4STUNBindingReqTX, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_DROP_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_DROP_STUN), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_DROP_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_DROP_STUN), - }: uint64(len(ipv4STUNBindingReqTX)), - }, - }, - { - name: "ipv6 STUN Binding Request Drop STUN", - dropSTUN: true, - packetIn: ipv6STUNBindingReqTX, - wantCode: xdpActionDrop, - wantPacketOut: ipv6STUNBindingReqTX, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_DROP_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_DROP_STUN), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_DROP_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_DROP_STUN), - }: uint64(len(ipv6STUNBindingReqTX)), - }, - }, - { - name: "ipv4 STUN Binding Request TX", - packetIn: ipv4STUNBindingReqTX, - wantCode: xdpActionTX, - wantPacketOut: getIPv4STUNBindingResp(), - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_TX_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_TX_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(getIPv4STUNBindingResp())), - }, - }, - { - name: "ipv6 STUN Binding Request TX", - packetIn: ipv6STUNBindingReqTX, - wantCode: xdpActionTX, - wantPacketOut: getIPv6STUNBindingResp(), - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_TX_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_TX_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(getIPv6STUNBindingResp())), - }, - }, - { - name: "ipv4 STUN Binding Request invalid ip csum PASS", - packetIn: ipv4STUNBindingReqIPCsumPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqIPCsumPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_IP_CSUM), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_IP_CSUM), - }: uint64(len(ipv4STUNBindingReqIPCsumPass)), - }, - }, - { - name: "ipv4 STUN Binding Request ihl PASS", - packetIn: ipv4STUNBindingReqIHLPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqIHLPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqIHLPass)), - }, - }, - { - name: "ipv4 STUN Binding Request ip version PASS", - packetIn: ipv4STUNBindingReqIPVerPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqIPVerPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqIPVerPass)), - }, - }, - { - name: "ipv4 STUN Binding Request ip proto PASS", - packetIn: ipv4STUNBindingReqIPProtoPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqIPProtoPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqIPProtoPass)), - }, - }, - { - name: "ipv4 STUN Binding Request frag offset PASS", - packetIn: ipv4STUNBindingReqFragOffsetPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqFragOffsetPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqFragOffsetPass)), - }, - }, - { - name: "ipv4 STUN Binding Request flags mf PASS", - packetIn: ipv4STUNBindingReqFlagsMFPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqFlagsMFPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqFlagsMFPass)), - }, - }, - { - name: "ipv4 STUN Binding Request tot len PASS", - packetIn: ipv4STUNBindingReqTotLenPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqTotLenPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqTotLenPass)), - }, - }, - { - name: "ipv6 STUN Binding Request ip version PASS", - packetIn: ipv6STUNBindingReqIPVerPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqIPVerPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv6STUNBindingReqIPVerPass)), - }, - }, - { - name: "ipv6 STUN Binding Request next hdr PASS", - packetIn: ipv6STUNBindingReqNextHdrPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqNextHdrPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv6STUNBindingReqNextHdrPass)), - }, - }, - { - name: "ipv6 STUN Binding Request payload len PASS", - packetIn: ipv6STUNBindingReqPayloadLenPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqPayloadLenPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv6STUNBindingReqPayloadLenPass)), - }, - }, - { - name: "ipv4 STUN Binding Request UDP csum PASS", - packetIn: ipv4STUNBindingReqUDPCsumPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqUDPCsumPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_UDP_CSUM), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_UDP_CSUM), - }: uint64(len(ipv4STUNBindingReqUDPCsumPass)), - }, - }, - { - name: "ipv6 STUN Binding Request UDP csum PASS", - packetIn: ipv6STUNBindingReqUDPCsumPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqUDPCsumPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_UDP_CSUM), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_UDP_CSUM), - }: uint64(len(ipv6STUNBindingReqUDPCsumPass)), - }, - }, - { - name: "ipv4 STUN Binding Request STUN type PASS", - packetIn: ipv4STUNBindingReqSTUNTypePass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqSTUNTypePass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqSTUNTypePass)), - }, - }, - { - name: "ipv6 STUN Binding Request STUN type PASS", - packetIn: ipv6STUNBindingReqSTUNTypePass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqSTUNTypePass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv6STUNBindingReqSTUNTypePass)), - }, - }, - { - name: "ipv4 STUN Binding Request STUN magic PASS", - packetIn: ipv4STUNBindingReqSTUNMagicPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqSTUNMagicPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqSTUNMagicPass)), - }, - }, - { - name: "ipv6 STUN Binding Request STUN magic PASS", - packetIn: ipv6STUNBindingReqSTUNMagicPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqSTUNMagicPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv6STUNBindingReqSTUNMagicPass)), - }, - }, - { - name: "ipv4 STUN Binding Request STUN attrs len PASS", - packetIn: ipv4STUNBindingReqSTUNAttrsLenPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqSTUNAttrsLenPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv4STUNBindingReqSTUNAttrsLenPass)), - }, - }, - { - name: "ipv6 STUN Binding Request STUN attrs len PASS", - packetIn: ipv6STUNBindingReqSTUNAttrsLenPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqSTUNAttrsLenPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(ipv6STUNBindingReqSTUNAttrsLenPass)), - }, - }, - { - name: "ipv4 STUN Binding Request STUN SW val PASS", - packetIn: ipv4STUNBindingReqSTUNSWValPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqSTUNSWValPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_SW_ATTR_VAL), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_SW_ATTR_VAL), - }: uint64(len(ipv4STUNBindingReqSTUNSWValPass)), - }, - }, - { - name: "ipv6 STUN Binding Request STUN SW val PASS", - packetIn: ipv6STUNBindingReqSTUNSWValPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqSTUNSWValPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_SW_ATTR_VAL), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_SW_ATTR_VAL), - }: uint64(len(ipv6STUNBindingReqSTUNSWValPass)), - }, - }, - { - name: "ipv4 STUN Binding Request STUN first attr PASS", - packetIn: ipv4STUNBindingReqSTUNFirstAttrPass, - wantCode: xdpActionPass, - wantPacketOut: ipv4STUNBindingReqSTUNFirstAttrPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNEXPECTED_FIRST_STUN_ATTR), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNEXPECTED_FIRST_STUN_ATTR), - }: uint64(len(ipv4STUNBindingReqSTUNFirstAttrPass)), - }, - }, - { - name: "ipv6 STUN Binding Request STUN first attr PASS", - packetIn: ipv6STUNBindingReqSTUNFirstAttrPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqSTUNFirstAttrPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNEXPECTED_FIRST_STUN_ATTR), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNEXPECTED_FIRST_STUN_ATTR), - }: uint64(len(ipv6STUNBindingReqSTUNFirstAttrPass)), - }, - }, - { - name: "ipv4 UDP zero csum TX", - packetIn: ipv4STUNBindingReqUDPZeroCsumTx, - wantCode: xdpActionTX, - wantPacketOut: getIPv4STUNBindingResp(), - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_TX_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV4), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_TX_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_UNSPECIFIED), - }: uint64(len(getIPv4STUNBindingResp())), - }, - }, - { - name: "ipv6 UDP zero csum PASS", - packetIn: ipv6STUNBindingReqUDPZeroCsumPass, - wantCode: xdpActionPass, - wantPacketOut: ipv6STUNBindingReqUDPZeroCsumPass, - wantMetrics: map[bpfCountersKey]uint64{ - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_UDP_CSUM), - }: 1, - { - Af: uint8(bpfCounterKeyAfCOUNTER_KEY_AF_IPV6), - Pba: uint8(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_BYTES_PASS_TOTAL), - ProgEnd: uint8(bpfCounterKeyProgEndCOUNTER_KEY_END_INVALID_UDP_CSUM), - }: uint64(len(ipv6STUNBindingReqUDPZeroCsumPass)), - }, - }, - } - - server, err := NewSTUNServer(&STUNServerConfig{DeviceName: "fake", DstPort: defaultSTUNPort}, - &noAttachOption{}) - if err != nil { - if errors.Is(err, unix.EPERM) { - // TODO(jwhited): get this running - t.Skip("skipping due to EPERM error; test requires elevated privileges") - } - t.Fatalf("error constructing STUN server: %v", err) - } - defer server.Close() - - clearCounters := func() error { - server.metrics.last = make(map[bpfCountersKey]uint64) - var cur, next bpfCountersKey - keys := make([]bpfCountersKey, 0) - for err = server.objs.CountersMap.NextKey(nil, &next); ; err = server.objs.CountersMap.NextKey(cur, &next) { - if err != nil { - if errors.Is(err, ebpf.ErrKeyNotExist) { - break - } - return err - } - keys = append(keys, next) - cur = next - } - for _, key := range keys { - err = server.objs.CountersMap.Delete(&key) - if err != nil { - return err - } - } - err = server.objs.CountersMap.NextKey(nil, &next) - if !errors.Is(err, ebpf.ErrKeyNotExist) { - return errors.New("counters map is not empty") - } - return nil - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - err = clearCounters() - if err != nil { - t.Fatalf("error clearing counters: %v", err) - } - opts := ebpf.RunOptions{ - Data: c.packetIn, - DataOut: make([]byte, 1514), - } - err = server.SetDropSTUN(c.dropSTUN) - if err != nil { - t.Fatalf("error setting drop STUN: %v", err) - } - got, err := server.objs.XdpProgFunc.Run(&opts) - if err != nil { - t.Fatalf("error running program: %v", err) - } - if xdpAction(got) != c.wantCode { - t.Fatalf("got code: %s != %s", xdpAction(got), c.wantCode) - } - if !bytes.Equal(opts.DataOut, c.wantPacketOut) { - t.Fatal("packets not equal") - } - err = server.updateMetrics() - if err != nil { - t.Fatalf("error updating metrics: %v", err) - } - if c.wantMetrics != nil { - for k, v := range c.wantMetrics { - gotCounter, ok := server.metrics.last[k] - if !ok { - t.Errorf("expected counter at key %+v not found", k) - } - if gotCounter != v { - t.Errorf("key: %+v gotCounter: %d != %d", k, gotCounter, v) - } - } - for k := range server.metrics.last { - _, ok := c.wantMetrics[k] - if !ok { - t.Errorf("counter at key: %+v incremented unexpectedly", k) - } - } - } - }) - } -} - -func TestCountersMapKey(t *testing.T) { - if bpfCounterKeyAfCOUNTER_KEY_AF_LEN > 256 { - t.Error("COUNTER_KEY_AF_LEN no longer fits within uint8") - } - if bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_BYTES_ACTION_LEN > 256 { - t.Error("COUNTER_KEY_PACKETS_BYTES_ACTION no longer fits within uint8") - } - if bpfCounterKeyProgEndCOUNTER_KEY_END_LEN > 256 { - t.Error("COUNTER_KEY_END_LEN no longer fits within uint8") - } - if len(pbaToOutcomeLV) != int(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_BYTES_ACTION_LEN) { - t.Error("pbaToOutcomeLV is not in sync with xdp.c") - } - if len(progEndLV) != int(bpfCounterKeyProgEndCOUNTER_KEY_END_LEN) { - t.Error("progEndLV is not in sync with xdp.c") - } - if len(packetCounterKeys)+len(bytesCounterKeys) != int(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_BYTES_ACTION_LEN) { - t.Error("packetCounterKeys and/or bytesCounterKeys is not in sync with xdp.c") - } - if len(pbaToOutcomeLV) != int(bpfCounterKeyPacketsBytesActionCOUNTER_KEY_PACKETS_BYTES_ACTION_LEN) { - t.Error("pbaToOutcomeLV is not in sync with xdp.c") - } -} diff --git a/disco/disco.go b/disco/disco.go index b9a90029d9ca8..ba7fb30f812e0 100644 --- a/disco/disco.go +++ b/disco/disco.go @@ -26,8 +26,8 @@ import ( "net" "net/netip" + "github.com/sagernet/tailscale/types/key" "go4.org/mem" - "tailscale.com/types/key" ) // Magic is the 6 byte header of all discovery messages. diff --git a/disco/disco_test.go b/disco/disco_test.go deleted file mode 100644 index 1a56324a5a423..0000000000000 --- a/disco/disco_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package disco - -import ( - "fmt" - "net/netip" - "reflect" - "strings" - "testing" - - "go4.org/mem" - "tailscale.com/types/key" -) - -func TestMarshalAndParse(t *testing.T) { - tests := []struct { - name string - want string - m Message - }{ - { - name: "ping", - m: &Ping{ - TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, - }, - want: "01 00 01 02 03 04 05 06 07 08 09 0a 0b 0c", - }, - { - name: "ping_with_nodekey_src", - m: &Ping{ - TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, - NodeKey: key.NodePublicFromRaw32(mem.B([]byte{1: 1, 2: 2, 30: 30, 31: 31})), - }, - want: "01 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 00 01 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1e 1f", - }, - { - name: "ping_with_padding", - m: &Ping{ - TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, - Padding: 3, - }, - want: "01 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 00 00 00", - }, - { - name: "ping_with_padding_and_nodekey_src", - m: &Ping{ - TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, - NodeKey: key.NodePublicFromRaw32(mem.B([]byte{1: 1, 2: 2, 30: 30, 31: 31})), - Padding: 3, - }, - want: "01 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 00 01 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1e 1f 00 00 00", - }, - { - name: "pong", - m: &Pong{ - TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, - Src: mustIPPort("2.3.4.5:1234"), - }, - want: "02 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 00 00 00 00 00 00 00 00 00 00 ff ff 02 03 04 05 04 d2", - }, - { - name: "pongv6", - m: &Pong{ - TxID: [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}, - Src: mustIPPort("[fed0::12]:6666"), - }, - want: "02 00 01 02 03 04 05 06 07 08 09 0a 0b 0c fe d0 00 00 00 00 00 00 00 00 00 00 00 00 00 12 1a 0a", - }, - { - name: "call_me_maybe", - m: &CallMeMaybe{}, - want: "03 00", - }, - { - name: "call_me_maybe_endpoints", - m: &CallMeMaybe{ - MyNumber: []netip.AddrPort{ - netip.MustParseAddrPort("1.2.3.4:567"), - netip.MustParseAddrPort("[2001::3456]:789"), - }, - }, - want: "03 00 00 00 00 00 00 00 00 00 00 00 ff ff 01 02 03 04 02 37 20 01 00 00 00 00 00 00 00 00 00 00 00 00 34 56 03 15", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - foo := []byte("foo") - got := string(tt.m.AppendMarshal(foo)) - got, ok := strings.CutPrefix(got, "foo") - if !ok { - t.Fatalf("didn't start with foo: got %q", got) - } - - gotHex := fmt.Sprintf("% x", got) - if gotHex != tt.want { - t.Fatalf("wrong marshal\n got: %s\nwant: %s\n", gotHex, tt.want) - } - - back, err := Parse([]byte(got)) - if err != nil { - t.Fatalf("parse back: %v", err) - } - if !reflect.DeepEqual(back, tt.m) { - t.Errorf("message in %+v doesn't match Parse back result %+v", tt.m, back) - } - }) - } -} - -func mustIPPort(s string) netip.AddrPort { - ipp, err := netip.ParseAddrPort(s) - if err != nil { - panic(err) - } - return ipp -} diff --git a/disco/pcap.go b/disco/pcap.go index 71035424868e8..4fc594338c880 100644 --- a/disco/pcap.go +++ b/disco/pcap.go @@ -8,8 +8,8 @@ import ( "encoding/binary" "net/netip" - "tailscale.com/tailcfg" - "tailscale.com/types/key" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" ) // ToPCAPFrame marshals the bytes for a pcap record that describe a disco frame. diff --git a/doctor/doctor.go b/doctor/doctor.go index 7c3047e12b62d..b64e929ea4e04 100644 --- a/doctor/doctor.go +++ b/doctor/doctor.go @@ -9,7 +9,7 @@ import ( "context" "sync" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // Check is the interface defining a singular check. diff --git a/doctor/doctor_test.go b/doctor/doctor_test.go deleted file mode 100644 index 87250f10ed00a..0000000000000 --- a/doctor/doctor_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package doctor - -import ( - "context" - "fmt" - "sync" - "testing" - - qt "github.com/frankban/quicktest" - "tailscale.com/types/logger" -) - -func TestRunChecks(t *testing.T) { - c := qt.New(t) - var ( - mu sync.Mutex - lines []string - ) - logf := func(format string, args ...any) { - mu.Lock() - defer mu.Unlock() - lines = append(lines, fmt.Sprintf(format, args...)) - } - - ctx := context.Background() - RunChecks(ctx, logf, - testCheck1{}, - CheckFunc("testcheck2", func(_ context.Context, log logger.Logf) error { - log("check 2") - return nil - }), - ) - - mu.Lock() - defer mu.Unlock() - c.Assert(lines, qt.Contains, "testcheck1: check 1") - c.Assert(lines, qt.Contains, "testcheck2: check 2") -} - -type testCheck1 struct{} - -func (t testCheck1) Name() string { return "testcheck1" } -func (t testCheck1) Run(_ context.Context, log logger.Logf) error { - log("check 1") - return nil -} diff --git a/doctor/ethtool/ethtool.go b/doctor/ethtool/ethtool.go index f80b00a51ff65..9172b06eb94bb 100644 --- a/doctor/ethtool/ethtool.go +++ b/doctor/ethtool/ethtool.go @@ -8,7 +8,7 @@ package ethtool import ( "context" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // Check implements the doctor.Check interface. diff --git a/doctor/ethtool/ethtool_linux.go b/doctor/ethtool/ethtool_linux.go index b8cc0800240de..e8c2bb10d8c70 100644 --- a/doctor/ethtool/ethtool_linux.go +++ b/doctor/ethtool/ethtool_linux.go @@ -8,9 +8,9 @@ import ( "sort" "github.com/safchain/ethtool" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/set" ) func ethtoolImpl(logf logger.Logf) error { diff --git a/doctor/ethtool/ethtool_other.go b/doctor/ethtool/ethtool_other.go index 9aaa9dda8ba5f..2ed7db02b4611 100644 --- a/doctor/ethtool/ethtool_other.go +++ b/doctor/ethtool/ethtool_other.go @@ -8,7 +8,7 @@ package ethtool import ( "runtime" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) func ethtoolImpl(logf logger.Logf) error { diff --git a/doctor/permissions/permissions.go b/doctor/permissions/permissions.go index 77fe526262f0c..17194841b719a 100644 --- a/doctor/permissions/permissions.go +++ b/doctor/permissions/permissions.go @@ -11,8 +11,8 @@ import ( "os/user" "strings" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/exp/constraints" - "tailscale.com/types/logger" ) // Check implements the doctor.Check interface. diff --git a/doctor/permissions/permissions_bsd.go b/doctor/permissions/permissions_bsd.go index 8b034cfff1af3..377fa690ab5fa 100644 --- a/doctor/permissions/permissions_bsd.go +++ b/doctor/permissions/permissions_bsd.go @@ -6,8 +6,8 @@ package permissions import ( + "github.com/sagernet/tailscale/types/logger" "golang.org/x/sys/unix" - "tailscale.com/types/logger" ) func permissionsImpl(logf logger.Logf) error { diff --git a/doctor/permissions/permissions_linux.go b/doctor/permissions/permissions_linux.go index 12bb393d53383..bf846226f6187 100644 --- a/doctor/permissions/permissions_linux.go +++ b/doctor/permissions/permissions_linux.go @@ -10,8 +10,8 @@ import ( "strings" "unsafe" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/sys/unix" - "tailscale.com/types/logger" ) func permissionsImpl(logf logger.Logf) error { diff --git a/doctor/permissions/permissions_other.go b/doctor/permissions/permissions_other.go index 7e6912b4928cf..fa01f1826d3e7 100644 --- a/doctor/permissions/permissions_other.go +++ b/doctor/permissions/permissions_other.go @@ -8,7 +8,7 @@ package permissions import ( "runtime" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) func permissionsImpl(logf logger.Logf) error { diff --git a/doctor/permissions/permissions_test.go b/doctor/permissions/permissions_test.go deleted file mode 100644 index 941d406ef8318..0000000000000 --- a/doctor/permissions/permissions_test.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package permissions - -import "testing" - -func TestPermissionsImpl(t *testing.T) { - if err := permissionsImpl(t.Logf); err != nil { - t.Error(err) - } -} diff --git a/doctor/routetable/routetable.go b/doctor/routetable/routetable.go index 76e4ef949b9af..f0d63f44b53a5 100644 --- a/doctor/routetable/routetable.go +++ b/doctor/routetable/routetable.go @@ -8,8 +8,8 @@ package routetable import ( "context" - "tailscale.com/net/routetable" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/routetable" + "github.com/sagernet/tailscale/types/logger" ) // MaxRoutes is the maximum number of routes that will be displayed. diff --git a/drive/drive_view.go b/drive/drive_view.go index a6adfbc705378..a4cb434d13e43 100644 --- a/drive/drive_view.go +++ b/drive/drive_view.go @@ -9,7 +9,7 @@ import ( "encoding/json" "errors" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/types/views" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=Share diff --git a/drive/driveimpl/birthtiming_test.go b/drive/driveimpl/birthtiming_test.go deleted file mode 100644 index a43ffa33db92e..0000000000000 --- a/drive/driveimpl/birthtiming_test.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// BirthTime is not supported on Linux, so only run the test on windows and Mac. - -//go:build windows || darwin - -package driveimpl - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/tailscale/xnet/webdav" -) - -func TestBirthTiming(t *testing.T) { - ctx := context.Background() - - dir := t.TempDir() - fs := &birthTimingFS{webdav.Dir(dir)} - - // create a file - filename := "thefile" - fullPath := filepath.Join(dir, filename) - err := os.WriteFile(fullPath, []byte("hello beautiful world"), 0644) - if err != nil { - t.Fatalf("writing file failed: %s", err) - } - - // wait a little bit - time.Sleep(1 * time.Second) - - // append to the file to change its mtime - file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - t.Fatalf("opening file failed: %s", err) - } - _, err = file.Write([]byte("lookin' good!")) - if err != nil { - t.Fatalf("appending to file failed: %s", err) - } - err = file.Close() - if err != nil { - t.Fatalf("closing file failed: %s", err) - } - - checkFileInfo := func(fi os.FileInfo) { - if fi.ModTime().IsZero() { - t.Fatal("FileInfo should have a non-zero ModTime") - } - bt, ok := fi.(webdav.BirthTimer) - if !ok { - t.Fatal("FileInfo should be a BirthTimer") - } - birthTime, err := bt.BirthTime(ctx) - if err != nil { - t.Fatalf("BirthTime() failed: %s", err) - } - if birthTime.IsZero() { - t.Fatal("BirthTime() should return a non-zero time") - } - if !fi.ModTime().After(birthTime) { - t.Fatal("ModTime() should be after BirthTime()") - } - } - - fi, err := fs.Stat(ctx, filename) - if err != nil { - t.Fatalf("statting file failed: %s", err) - } - checkFileInfo(fi) - - wfile, err := fs.OpenFile(ctx, filename, os.O_RDONLY, 0) - if err != nil { - t.Fatalf("opening file failed: %s", err) - } - defer wfile.Close() - fi, err = wfile.Stat() - if err != nil { - t.Fatalf("statting file failed: %s", err) - } - if fi == nil { - t.Fatal("statting file returned nil FileInfo") - } - checkFileInfo(fi) - - dfile, err := fs.OpenFile(ctx, ".", os.O_RDONLY, 0) - if err != nil { - t.Fatalf("opening directory failed: %s", err) - } - defer dfile.Close() - fis, err := dfile.Readdir(0) - if err != nil { - t.Fatalf("readdir failed: %s", err) - } - if len(fis) != 1 { - t.Fatalf("readdir should have returned 1 file info, but returned %d", 1) - } - checkFileInfo(fis[0]) -} diff --git a/drive/driveimpl/compositedav/compositedav.go b/drive/driveimpl/compositedav/compositedav.go index 7c035912b946d..c55409b569faf 100644 --- a/drive/driveimpl/compositedav/compositedav.go +++ b/drive/driveimpl/compositedav/compositedav.go @@ -16,11 +16,11 @@ import ( "strings" "sync" + "github.com/sagernet/tailscale/drive/driveimpl/dirfs" + "github.com/sagernet/tailscale/drive/driveimpl/shared" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/logger" "github.com/tailscale/xnet/webdav" - "tailscale.com/drive/driveimpl/dirfs" - "tailscale.com/drive/driveimpl/shared" - "tailscale.com/tstime" - "tailscale.com/types/logger" ) // Child is a child folder of this compositedav. diff --git a/drive/driveimpl/compositedav/rewriting.go b/drive/driveimpl/compositedav/rewriting.go index 704be93d1bf76..df7171bf10334 100644 --- a/drive/driveimpl/compositedav/rewriting.go +++ b/drive/driveimpl/compositedav/rewriting.go @@ -11,7 +11,7 @@ import ( "regexp" "strings" - "tailscale.com/drive/driveimpl/shared" + "github.com/sagernet/tailscale/drive/driveimpl/shared" ) var ( diff --git a/drive/driveimpl/compositedav/stat_cache.go b/drive/driveimpl/compositedav/stat_cache.go index fc57ff0648300..b2014cca6b72e 100644 --- a/drive/driveimpl/compositedav/stat_cache.go +++ b/drive/driveimpl/compositedav/stat_cache.go @@ -12,7 +12,7 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - "tailscale.com/drive/driveimpl/shared" + "github.com/sagernet/tailscale/drive/driveimpl/shared" ) var ( diff --git a/drive/driveimpl/compositedav/stat_cache_test.go b/drive/driveimpl/compositedav/stat_cache_test.go deleted file mode 100644 index fa63457a256d3..0000000000000 --- a/drive/driveimpl/compositedav/stat_cache_test.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package compositedav - -import ( - "fmt" - "log" - "net/http" - "path" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "tailscale.com/tstest" -) - -var parentPath = "/parent" - -var childPath = "/parent/child.txt" - -var parentResponse = ` -/parent/ - - -Mon, 29 Apr 2024 19:52:23 GMT -Fri, 19 Apr 2024 04:13:34 GMT - - - - -HTTP/1.1 200 OK - -` - -var childResponse = ` - -/parent/child.txt - - -Mon, 29 Apr 2024 19:52:23 GMT -Fri, 19 Apr 2024 04:13:34 GMT - - - - -HTTP/1.1 200 OK - -` - -var fullParent = []byte( - strings.ReplaceAll( - fmt.Sprintf(`%s%s`, parentResponse, childResponse), - "\n", "")) - -var partialParent = []byte( - strings.ReplaceAll( - fmt.Sprintf(`%s`, parentResponse), - "\n", "")) - -var fullChild = []byte( - strings.ReplaceAll( - fmt.Sprintf(`%s`, childResponse), - "\n", "")) - -func TestStatCacheNoTimeout(t *testing.T) { - // Make sure we don't leak goroutines - tstest.ResourceCheck(t) - - c := &StatCache{TTL: 5 * time.Second} - defer c.stop() - - // check get before set - fetched := c.get(childPath, 0) - if fetched != nil { - t.Errorf("got %v, want nil", fetched) - } - - // set new stat - ce := newCacheEntry(http.StatusMultiStatus, fullChild) - c.set(childPath, 0, ce) - fetched = c.get(childPath, 0) - if diff := cmp.Diff(fetched, ce); diff != "" { - t.Errorf("should have gotten cached value; (-got+want):%v", diff) - } - - // fetch stat again, should still be cached - fetched = c.get(childPath, 0) - if diff := cmp.Diff(fetched, ce); diff != "" { - t.Errorf("should still have gotten cached value; (-got+want):%v", diff) - } -} - -func TestStatCacheTimeout(t *testing.T) { - // Make sure we don't leak goroutines - tstest.ResourceCheck(t) - - c := &StatCache{TTL: 250 * time.Millisecond} - defer c.stop() - - // set new stat - ce := newCacheEntry(http.StatusMultiStatus, fullChild) - c.set(childPath, 0, ce) - fetched := c.get(childPath, 0) - if diff := cmp.Diff(fetched, ce); diff != "" { - t.Errorf("should have gotten cached value; (-got+want):%v", diff) - } - - // wait for cache to expire and refetch stat, should be empty now - time.Sleep(c.TTL * 2) - - fetched = c.get(childPath, 0) - if fetched != nil { - t.Errorf("cached value should have expired") - } - - c.set(childPath, 0, ce) - // invalidate the cache and make sure nothing is returned - c.invalidate() - fetched = c.get(childPath, 0) - if fetched != nil { - t.Errorf("invalidate should have cleared cached value") - } -} - -func TestParentChildRelationship(t *testing.T) { - // Make sure we don't leak goroutines - tstest.ResourceCheck(t) - - c := &StatCache{TTL: 24 * time.Hour} // don't expire - defer c.stop() - - missingParentPath := "/missingparent" - unparseableParentPath := "/unparseable" - - c.set(parentPath, 1, newCacheEntry(http.StatusMultiStatus, fullParent)) - c.set(missingParentPath, 1, newCacheEntry(http.StatusNotFound, nil)) - c.set(unparseableParentPath, 1, newCacheEntry(http.StatusMultiStatus, []byte("1` - remote2 = `_rem ote$%<>2` - share11 = `sha re$%<>11` - share12 = `_sha re$%<>12` - file112 = `file112.txt` -) - -var ( - file111 = `fi le$%<>111.txt` -) - -func init() { - if runtime.GOOS == "windows" { - // file with less than and greater than doesn't work on Windows - file111 = `fi le$%111.txt` - } -} - -var ( - lockRootRegex = regexp.MustCompile(`/?([^<]*)/?`) - lockTokenRegex = regexp.MustCompile(`([0-9]+)/?`) -) - -func init() { - // set AllowShareAs() to false so that we don't try to use sub-processes - // for access files on disk. - drive.DisallowShareAs = true -} - -// The tests in this file simulate real-life Taildrive scenarios, but without -// going over the Tailscale network stack. -func TestDirectoryListing(t *testing.T) { - s := newSystem(t) - - s.addRemote(remote1) - s.checkDirList("root directory should contain the one and only domain once a remote has been set", "/", domain) - s.checkDirList("domain should contain its only remote", shared.Join(domain), remote1) - s.checkDirList("remote with no shares should be empty", shared.Join(domain, remote1)) - - s.addShare(remote1, share11, drive.PermissionReadWrite) - s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11) - s.addShare(remote1, share12, drive.PermissionReadOnly) - s.checkDirList("remote with two shares should contain both in lexicographical order", shared.Join(domain, remote1), share12, share11) - s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true) - s.checkDirList("remote share should contain file", shared.Join(domain, remote1, share11), file111) - - s.addRemote(remote2) - s.checkDirList("domain with two remotes should contain both in lexicographical order", shared.Join(domain), remote2, remote1) - - s.freezeRemote(remote1) - s.checkDirList("domain with two remotes should contain both in lexicographical order even if one is unreachable", shared.Join(domain), remote2, remote1) - _, err := s.client.ReadDir(shared.Join(domain, remote1)) - if err == nil { - t.Error("directory listing for offline remote should fail") - } - s.unfreezeRemote(remote1) - - s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11) -} - -func TestFileManipulation(t *testing.T) { - s := newSystem(t) - - s.addRemote(remote1) - s.addShare(remote1, share11, drive.PermissionReadWrite) - s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true) - s.checkFileStatus(remote1, share11, file111) - s.checkFileContents(remote1, share11, file111) - - s.renameFile("renaming file across shares should fail", remote1, share11, file111, share12, file112, false) - - s.renameFile("renaming file in same share should succeed", remote1, share11, file111, share11, file112, true) - s.checkFileContents(remote1, share11, file112) - - s.addShare(remote1, share12, drive.PermissionReadOnly) - s.writeFile("writing file to non-existent remote should fail", "non-existent", share11, file111, "hello world", false) - s.writeFile("writing file to non-existent share should fail", remote1, "non-existent", file111, "hello world", false) -} - -func TestPermissions(t *testing.T) { - s := newSystem(t) - - s.addRemote(remote1) - s.addShare(remote1, share12, drive.PermissionReadOnly) - - s.writeFile("writing file to read-only remote should fail", remote1, share12, file111, "hello world", false) - if err := s.client.Mkdir(path.Join(remote1, share12), 0644); err == nil { - t.Error("making directory on read-only remote should fail") - } - - // Now, write file directly to file system so that we can test permissions - // on other operations. - s.write(remote1, share12, file111, "hello world") - if err := s.client.Remove(pathTo(remote1, share12, file111)); err == nil { - t.Error("deleting file from read-only remote should fail") - } - if err := s.client.Rename(pathTo(remote1, share12, file111), pathTo(remote1, share12, file112), true); err == nil { - t.Error("moving file on read-only remote should fail") - } -} - -// TestSecretTokenAuth verifies that the fileserver running at localhost cannot -// be accessed directly without the correct secret token. This matters because -// if a victim can be induced to visit the localhost URL and access a malicious -// file on their own share, it could allow a Mark-of-the-Web bypass attack. -func TestSecretTokenAuth(t *testing.T) { - s := newSystem(t) - - fileserverAddr := s.addRemote(remote1) - s.addShare(remote1, share11, drive.PermissionReadWrite) - s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true) - - client := &http.Client{ - Transport: &http.Transport{DisableKeepAlives: true}, - } - addr := strings.Split(fileserverAddr, "|")[1] - wrongSecret, err := generateSecretToken() - if err != nil { - t.Fatal(err) - } - u := fmt.Sprintf("http://%s/%s/%s", addr, wrongSecret, url.PathEscape(file111)) - resp, err := client.Get(u) - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != http.StatusForbidden { - t.Errorf("expected %d for incorrect secret token, but got %d", http.StatusForbidden, resp.StatusCode) - } -} - -func TestLOCK(t *testing.T) { - s := newSystem(t) - - s.addRemote(remote1) - s.addShare(remote1, share11, drive.PermissionReadWrite) - s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true) - - client := &http.Client{ - Transport: &http.Transport{DisableKeepAlives: true}, - } - - u := fmt.Sprintf("http://%s/%s/%s/%s/%s", - s.local.l.Addr(), - url.PathEscape(domain), - url.PathEscape(remote1), - url.PathEscape(share11), - url.PathEscape(file111)) - - // First acquire a lock with a short timeout - req, err := http.NewRequest("LOCK", u, strings.NewReader(lockBody)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Depth", "infinity") - req.Header.Set("Timeout", "Second-1") - resp, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("expected LOCK to succeed, but got status %d", resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatal(err) - } - - submatches := lockRootRegex.FindStringSubmatch(string(body)) - if len(submatches) != 2 { - t.Fatal("failed to find lockroot") - } - want := shared.EscapeForXML(pathTo(remote1, share11, file111)) - got := submatches[1] - if got != want { - t.Fatalf("want lockroot %q, got %q", want, got) - } - - submatches = lockTokenRegex.FindStringSubmatch(string(body)) - if len(submatches) != 2 { - t.Fatal("failed to find locktoken") - } - lockToken := submatches[1] - ifHeader := fmt.Sprintf("<%s> (<%s>)", u, lockToken) - - // Then refresh the lock with a longer timeout - req, err = http.NewRequest("LOCK", u, nil) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Depth", "infinity") - req.Header.Set("Timeout", "Second-600") - req.Header.Set("If", ifHeader) - resp, err = client.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("expected LOCK refresh to succeed, but got status %d", resp.StatusCode) - } - body, err = io.ReadAll(resp.Body) - if err != nil { - t.Fatal(err) - } - - submatches = lockRootRegex.FindStringSubmatch(string(body)) - if len(submatches) != 2 { - t.Fatal("failed to find lockroot after refresh") - } - want = shared.EscapeForXML(pathTo(remote1, share11, file111)) - got = submatches[1] - if got != want { - t.Fatalf("want lockroot after refresh %q, got %q", want, got) - } - - submatches = lockTokenRegex.FindStringSubmatch(string(body)) - if len(submatches) != 2 { - t.Fatal("failed to find locktoken after refresh") - } - if submatches[1] != lockToken { - t.Fatalf("on refresh, lock token changed from %q to %q", lockToken, submatches[1]) - } - - // Then wait past the original timeout, then try to delete without the lock - // (should fail) - time.Sleep(1 * time.Second) - req, err = http.NewRequest("DELETE", u, nil) - if err != nil { - log.Fatal(err) - } - resp, err = client.Do(req) - if err != nil { - log.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 423 { - t.Fatalf("deleting without lock token should fail with 423, but got %d", resp.StatusCode) - } - - // Then delete with the lock (should succeed) - req, err = http.NewRequest("DELETE", u, nil) - if err != nil { - log.Fatal(err) - } - req.Header.Set("If", ifHeader) - resp, err = client.Do(req) - if err != nil { - log.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 204 { - t.Fatalf("deleting with lock token should have succeeded with 204, but got %d", resp.StatusCode) - } -} - -func TestUNLOCK(t *testing.T) { - s := newSystem(t) - - s.addRemote(remote1) - s.addShare(remote1, share11, drive.PermissionReadWrite) - s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true) - - client := &http.Client{ - Transport: &http.Transport{DisableKeepAlives: true}, - } - - u := fmt.Sprintf("http://%s/%s/%s/%s/%s", - s.local.l.Addr(), - url.PathEscape(domain), - url.PathEscape(remote1), - url.PathEscape(share11), - url.PathEscape(file111)) - - // Acquire a lock - req, err := http.NewRequest("LOCK", u, strings.NewReader(lockBody)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Depth", "infinity") - req.Header.Set("Timeout", "Second-600") - resp, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("expected LOCK to succeed, but got status %d", resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatal(err) - } - - submatches := lockTokenRegex.FindStringSubmatch(string(body)) - if len(submatches) != 2 { - t.Fatal("failed to find locktoken") - } - lockToken := submatches[1] - - // Release the lock - req, err = http.NewRequest("UNLOCK", u, nil) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Lock-Token", fmt.Sprintf("<%s>", lockToken)) - resp, err = client.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 204 { - t.Fatalf("expected UNLOCK to succeed with a 204, but got status %d", resp.StatusCode) - } - - // Then delete without the lock (should succeed) - req, err = http.NewRequest("DELETE", u, nil) - if err != nil { - log.Fatal(err) - } - resp, err = client.Do(req) - if err != nil { - log.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 204 { - t.Fatalf("deleting without lock should have succeeded with 204, but got %d", resp.StatusCode) - } -} - -type local struct { - l net.Listener - fs *FileSystemForLocal -} - -type remote struct { - l net.Listener - fs *FileSystemForRemote - fileServer *FileServer - shares map[string]string - permissions map[string]drive.Permission - mu sync.RWMutex -} - -func (r *remote) freeze() { - r.mu.Lock() -} - -func (r *remote) unfreeze() { - r.mu.Unlock() -} - -func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.mu.RLock() - defer r.mu.RUnlock() - r.fs.ServeHTTPWithPerms(r.permissions, w, req) -} - -type system struct { - t *testing.T - local *local - client *gowebdav.Client - remotes map[string]*remote -} - -func newSystem(t *testing.T) *system { - // Make sure we don't leak goroutines - tstest.ResourceCheck(t) - - fs := newFileSystemForLocal(log.Printf, nil) - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to Listen: %s", err) - } - t.Logf("FileSystemForLocal listening at %s", l.Addr()) - go func() { - for { - conn, err := l.Accept() - if err != nil { - t.Logf("Accept: %v", err) - return - } - go fs.HandleConn(conn, conn.RemoteAddr()) - } - }() - - client := gowebdav.NewAuthClient(fmt.Sprintf("http://%s", l.Addr()), &noopAuthorizer{}) - client.SetTransport(&http.Transport{DisableKeepAlives: true}) - s := &system{ - t: t, - local: &local{l: l, fs: fs}, - client: client, - remotes: make(map[string]*remote), - } - t.Cleanup(s.stop) - return s -} - -func (s *system) addRemote(name string) string { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - s.t.Fatalf("failed to Listen: %s", err) - } - s.t.Logf("Remote for %v listening at %s", name, l.Addr()) - - fileServer, err := NewFileServer() - if err != nil { - s.t.Fatalf("failed to call NewFileServer: %s", err) - } - go fileServer.Serve() - s.t.Logf("FileServer for %v listening at %s", name, fileServer.Addr()) - - r := &remote{ - l: l, - fileServer: fileServer, - fs: NewFileSystemForRemote(log.Printf), - shares: make(map[string]string), - permissions: make(map[string]drive.Permission), - } - r.fs.SetFileServerAddr(fileServer.Addr()) - go http.Serve(l, r) - s.remotes[name] = r - - remotes := make([]*drive.Remote, 0, len(s.remotes)) - for name, r := range s.remotes { - remotes = append(remotes, &drive.Remote{ - Name: name, - URL: fmt.Sprintf("http://%s", r.l.Addr()), - }) - } - s.local.fs.SetRemotes( - domain, - remotes, - &http.Transport{ - DisableKeepAlives: true, - ResponseHeaderTimeout: 5 * time.Second, - }) - - return fileServer.Addr() -} - -func (s *system) addShare(remoteName, shareName string, permission drive.Permission) { - r, ok := s.remotes[remoteName] - if !ok { - s.t.Fatalf("unknown remote %q", remoteName) - } - - f := s.t.TempDir() - r.shares[shareName] = f - r.permissions[shareName] = permission - - shares := make([]*drive.Share, 0, len(r.shares)) - for shareName, folder := range r.shares { - shares = append(shares, &drive.Share{ - Name: shareName, - Path: folder, - }) - } - slices.SortFunc(shares, drive.CompareShares) - r.fs.SetShares(shares) - r.fileServer.SetShares(r.shares) -} - -func (s *system) freezeRemote(remoteName string) { - r, ok := s.remotes[remoteName] - if !ok { - s.t.Fatalf("unknown remote %q", remoteName) - } - r.freeze() -} - -func (s *system) unfreezeRemote(remoteName string) { - r, ok := s.remotes[remoteName] - if !ok { - s.t.Fatalf("unknown remote %q", remoteName) - } - r.unfreeze() -} - -func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) { - path := pathTo(remoteName, shareName, name) - err := s.client.Write(path, []byte(contents), 0644) - if expectSuccess && err != nil { - s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err) - } else if !expectSuccess && err == nil { - s.t.Fatalf("%v: expected error writing file %q, but got no error", label, path) - } -} - -func (s *system) renameFile(label, remoteName, fromShare, fromFile, toShare, toFile string, expectSuccess bool) { - fromPath := pathTo(remoteName, fromShare, fromFile) - toPath := pathTo(remoteName, toShare, toFile) - err := s.client.Rename(fromPath, toPath, true) - if expectSuccess && err != nil { - s.t.Fatalf("%v: expected success moving file %q to %q, but got error %v", label, fromPath, toPath, err) - } else if !expectSuccess && err == nil { - s.t.Fatalf("%v: expected error moving file %q to %q, but got no error", label, fromPath, toPath) - } -} - -func (s *system) checkFileStatus(remoteName, shareName, name string) { - expectedFI := s.stat(remoteName, shareName, name) - actualFI := s.statViaWebDAV(remoteName, shareName, name) - s.checkFileInfosEqual(expectedFI, actualFI, fmt.Sprintf("%s/%s/%s should show same FileInfo via WebDAV stat as local stat", remoteName, shareName, name)) -} - -func (s *system) checkFileContents(remoteName, shareName, name string) { - expected := s.read(remoteName, shareName, name) - actual := s.readViaWebDAV(remoteName, shareName, name) - if expected != actual { - s.t.Errorf("%s/%s/%s should show same contents via WebDAV read as local read\nwant: %q\nhave: %q", remoteName, shareName, name, expected, actual) - } -} - -func (s *system) checkDirList(label string, path string, want ...string) { - got, err := s.client.ReadDir(path) - if err != nil { - s.t.Fatalf("failed to Readdir: %s", err) - } - - if len(want) == 0 && len(got) == 0 { - return - } - - gotNames := make([]string, 0, len(got)) - for _, fi := range got { - gotNames = append(gotNames, fi.Name()) - } - if diff := cmp.Diff(want, gotNames); diff != "" { - s.t.Errorf("%v: (-got, +want):\n%s", label, diff) - } -} - -func (s *system) stat(remoteName, shareName, name string) os.FileInfo { - filename := filepath.Join(s.remotes[remoteName].shares[shareName], name) - fi, err := os.Stat(filename) - if err != nil { - s.t.Fatalf("failed to Stat: %s", err) - } - - return fi -} - -func (s *system) statViaWebDAV(remoteName, shareName, name string) os.FileInfo { - path := pathTo(remoteName, shareName, name) - fi, err := s.client.Stat(path) - if err != nil { - s.t.Fatalf("failed to Stat: %s", err) - } - - return fi -} - -func (s *system) read(remoteName, shareName, name string) string { - filename := filepath.Join(s.remotes[remoteName].shares[shareName], name) - b, err := os.ReadFile(filename) - if err != nil { - s.t.Fatalf("failed to ReadFile: %s", err) - } - - return string(b) -} - -func (s *system) write(remoteName, shareName, name, contents string) { - filename := filepath.Join(s.remotes[remoteName].shares[shareName], name) - err := os.WriteFile(filename, []byte(contents), 0644) - if err != nil { - s.t.Fatalf("failed to WriteFile: %s", err) - } -} - -func (s *system) readViaWebDAV(remoteName, shareName, name string) string { - path := pathTo(remoteName, shareName, name) - b, err := s.client.Read(path) - if err != nil { - s.t.Fatalf("failed to OpenFile: %s", err) - } - return string(b) -} - -func (s *system) stop() { - err := s.local.fs.Close() - if err != nil { - s.t.Fatalf("failed to Close fs: %s", err) - } - - err = s.local.l.Close() - if err != nil { - s.t.Fatalf("failed to Close listener: %s", err) - } - - for _, r := range s.remotes { - err = r.fs.Close() - if err != nil { - s.t.Fatalf("failed to Close remote fs: %s", err) - } - - err = r.l.Close() - if err != nil { - s.t.Fatalf("failed to Close remote listener: %s", err) - } - - err = r.fileServer.Close() - if err != nil { - s.t.Fatalf("failed to Close remote fileserver: %s", err) - } - } -} - -func (s *system) checkFileInfosEqual(expected, actual fs.FileInfo, label string) { - if expected == nil && actual == nil { - return - } - diff := cmp.Diff(fileInfoToStatic(expected, true), fileInfoToStatic(actual, false)) - if diff != "" { - s.t.Errorf("%v (-got, +want):\n%s", label, diff) - } -} - -func fileInfoToStatic(fi fs.FileInfo, fixupMode bool) fs.FileInfo { - mode := fi.Mode() - if fixupMode { - // WebDAV doesn't transmit file modes, so we just mimic the defaults that - // our WebDAV client uses. - mode = os.FileMode(0664) - if fi.IsDir() { - mode = 0775 | os.ModeDir - } - } - return &shared.StaticFileInfo{ - Named: fi.Name(), - Sized: fi.Size(), - Moded: mode, - ModdedTime: fi.ModTime().Truncate(1 * time.Second).UTC(), - Dir: fi.IsDir(), - } -} - -func pathTo(remote, share, name string) string { - return path.Join(domain, remote, share, name) -} - -// noopAuthorizer implements gowebdav.Authorizer. It does no actual -// authorizing. We use it in place of gowebdav's built-in authorizer in order -// to avoid a race condition in that authorizer. -type noopAuthorizer struct{} - -func (a *noopAuthorizer) NewAuthenticator(body io.Reader) (gowebdav.Authenticator, io.Reader) { - return &noopAuthenticator{}, nil -} - -func (a *noopAuthorizer) AddAuthenticator(key string, fn gowebdav.AuthFactory) { -} - -type noopAuthenticator struct{} - -func (a *noopAuthenticator) Authorize(c *http.Client, rq *http.Request, path string) error { - return nil -} - -func (a *noopAuthenticator) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) { - return false, nil -} - -func (a *noopAuthenticator) Clone() gowebdav.Authenticator { - return &noopAuthenticator{} -} - -func (a *noopAuthenticator) Close() error { - return nil -} - -const lockBody = ` - - - -` diff --git a/drive/driveimpl/fileserver.go b/drive/driveimpl/fileserver.go index 0067c1cc7db63..7175779dbbf82 100644 --- a/drive/driveimpl/fileserver.go +++ b/drive/driveimpl/fileserver.go @@ -12,8 +12,8 @@ import ( "net/http" "sync" + "github.com/sagernet/tailscale/drive/driveimpl/shared" "github.com/tailscale/xnet/webdav" - "tailscale.com/drive/driveimpl/shared" ) // FileServer is a standalone WebDAV server that dynamically serves up shares. diff --git a/drive/driveimpl/local_impl.go b/drive/driveimpl/local_impl.go index 8cdf60179aa0b..bc5bdba36d627 100644 --- a/drive/driveimpl/local_impl.go +++ b/drive/driveimpl/local_impl.go @@ -10,10 +10,10 @@ import ( "net/http" "time" - "tailscale.com/drive" - "tailscale.com/drive/driveimpl/compositedav" - "tailscale.com/drive/driveimpl/dirfs" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/drive/driveimpl/compositedav" + "github.com/sagernet/tailscale/drive/driveimpl/dirfs" + "github.com/sagernet/tailscale/types/logger" ) const ( diff --git a/drive/driveimpl/remote_impl.go b/drive/driveimpl/remote_impl.go index 7fd5d3325beb0..112341ac9b020 100644 --- a/drive/driveimpl/remote_impl.go +++ b/drive/driveimpl/remote_impl.go @@ -22,13 +22,13 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/drive/driveimpl/compositedav" + "github.com/sagernet/tailscale/drive/driveimpl/dirfs" + "github.com/sagernet/tailscale/drive/driveimpl/shared" + "github.com/sagernet/tailscale/safesocket" + "github.com/sagernet/tailscale/types/logger" "github.com/tailscale/xnet/webdav" - "tailscale.com/drive" - "tailscale.com/drive/driveimpl/compositedav" - "tailscale.com/drive/driveimpl/dirfs" - "tailscale.com/drive/driveimpl/shared" - "tailscale.com/safesocket" - "tailscale.com/types/logger" ) func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote { diff --git a/drive/driveimpl/shared/pathutil_test.go b/drive/driveimpl/shared/pathutil_test.go deleted file mode 100644 index 662adbd8b0b48..0000000000000 --- a/drive/driveimpl/shared/pathutil_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package shared - -import ( - "reflect" - "testing" -) - -func TestCleanAndSplit(t *testing.T) { - tests := []struct { - path string - want []string - }{ - {"", []string{""}}, - {"/", []string{""}}, - {"//", []string{""}}, - {"a", []string{"a"}}, - {"/a", []string{"a"}}, - {"a/", []string{"a"}}, - {"/a/", []string{"a"}}, - {"a/b", []string{"a", "b"}}, - {"/a/b", []string{"a", "b"}}, - {"a/b/", []string{"a", "b"}}, - {"/a/b/", []string{"a", "b"}}, - {"/a/../b", []string{"b"}}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := CleanAndSplit(tt.path); !reflect.DeepEqual(tt.want, got) { - t.Errorf("CleanAndSplit(%q) = %v; want %v", tt.path, got, tt.want) - } - }) - } -} - -func TestJoin(t *testing.T) { - tests := []struct { - parts []string - want string - }{ - {[]string{""}, "/"}, - {[]string{"a"}, "/a"}, - {[]string{"/a"}, "/a"}, - {[]string{"/a/"}, "/a"}, - {[]string{"/a/", "/b/"}, "/a/b"}, - {[]string{"/a/../b", "c"}, "/b/c"}, - } - for _, tt := range tests { - t.Run(Join(tt.parts...), func(t *testing.T) { - if got := Join(tt.parts...); !reflect.DeepEqual(tt.want, got) { - t.Errorf("Join(%v) = %q; want %q", tt.parts, got, tt.want) - } - }) - } -} diff --git a/drive/remote_permissions_test.go b/drive/remote_permissions_test.go deleted file mode 100644 index ff039c80020c8..0000000000000 --- a/drive/remote_permissions_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package drive - -import ( - "encoding/json" - "testing" -) - -func TestPermissions(t *testing.T) { - tests := []struct { - perms []grant - share string - want Permission - }{ - {[]grant{ - {Shares: []string{"*"}, Access: "ro"}, - {Shares: []string{"a"}, Access: "rw"}, - }, - "a", - PermissionReadWrite, - }, - {[]grant{ - {Shares: []string{"*"}, Access: "ro"}, - {Shares: []string{"a"}, Access: "rw"}, - }, - "b", - PermissionReadOnly, - }, - {[]grant{ - {Shares: []string{"a"}, Access: "rw"}, - }, - "c", - PermissionNone, - }, - } - - for _, tt := range tests { - t.Run(tt.share, func(t *testing.T) { - var rawPerms [][]byte - for _, perm := range tt.perms { - b, err := json.Marshal(perm) - if err != nil { - t.Fatal(err) - } - rawPerms = append(rawPerms, b) - } - - p, err := ParsePermissions(rawPerms) - if err != nil { - t.Fatal(err) - } - - got := p.For(tt.share) - if got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} diff --git a/drive/remote_test.go b/drive/remote_test.go deleted file mode 100644 index e05b23839bade..0000000000000 --- a/drive/remote_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package drive - -import ( - "fmt" - "testing" -) - -func TestNormalizeShareName(t *testing.T) { - tests := []struct { - name string - want string - err error - }{ - { - name: " (_this is A 5 nAme )_ ", - want: "(_this is a 5 name )_", - }, - { - name: "", - err: ErrInvalidShareName, - }, - { - name: "generally good except for .", - err: ErrInvalidShareName, - }, - } - for _, tt := range tests { - t.Run(fmt.Sprintf("name %q", tt.name), func(t *testing.T) { - got, err := NormalizeShareName(tt.name) - if tt.err != nil && err != tt.err { - t.Errorf("wanted error %v, got %v", tt.err, err) - } else if got != tt.want { - t.Errorf("wanted %q, got %q", tt.want, got) - } - }) - } -} diff --git a/drive/remote_unix.go b/drive/remote_unix.go index 0e41524dbd304..4811f8ab375b6 100644 --- a/drive/remote_unix.go +++ b/drive/remote_unix.go @@ -5,7 +5,7 @@ package drive -import "tailscale.com/version" +import "github.com/sagernet/tailscale/version" func doAllowShareAs() bool { // All UNIX platforms use user servers (sub-processes) to access the OS diff --git a/envknob/envknob.go b/envknob/envknob.go index e74bfea71bdb3..757c16b0c2a00 100644 --- a/envknob/envknob.go +++ b/envknob/envknob.go @@ -32,10 +32,10 @@ import ( "sync/atomic" "time" - "tailscale.com/kube/kubetypes" - "tailscale.com/types/opt" - "tailscale.com/version" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/kube/kubetypes" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" ) var ( diff --git a/envknob/featureknob/featureknob.go b/envknob/featureknob/featureknob.go index d7af80d239782..84b7b81704988 100644 --- a/envknob/featureknob/featureknob.go +++ b/envknob/featureknob/featureknob.go @@ -9,10 +9,10 @@ import ( "errors" "runtime" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/version" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" ) // CanRunTailscaleSSH reports whether serving a Tailscale SSH server is diff --git a/envknob/logknob/logknob.go b/envknob/logknob/logknob.go index 350384b8626e3..7f8ec6dbccf47 100644 --- a/envknob/logknob/logknob.go +++ b/envknob/logknob/logknob.go @@ -8,10 +8,10 @@ package logknob import ( "sync/atomic" - "tailscale.com/envknob" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/views" ) // TODO(andrew-d): should we have a package-global registry of logknobs? It diff --git a/envknob/logknob/logknob_test.go b/envknob/logknob/logknob_test.go deleted file mode 100644 index b2a376a25b371..0000000000000 --- a/envknob/logknob/logknob_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package logknob - -import ( - "bytes" - "fmt" - "testing" - - "tailscale.com/envknob" - "tailscale.com/tailcfg" - "tailscale.com/types/netmap" -) - -var testKnob = NewLogKnob( - "TS_TEST_LOGKNOB", - "https://tailscale.com/cap/testing", -) - -// Static type assertion for our interface type. -var _ NetMap = &netmap.NetworkMap{} - -func TestLogKnob(t *testing.T) { - t.Run("Default", func(t *testing.T) { - if testKnob.shouldLog() { - t.Errorf("expected default shouldLog()=false") - } - assertNoLogs(t) - }) - t.Run("Manual", func(t *testing.T) { - t.Cleanup(func() { testKnob.Set(false) }) - - assertNoLogs(t) - testKnob.Set(true) - if !testKnob.shouldLog() { - t.Errorf("expected shouldLog()=true") - } - assertLogs(t) - }) - t.Run("Env", func(t *testing.T) { - t.Cleanup(func() { - envknob.Setenv("TS_TEST_LOGKNOB", "") - }) - - assertNoLogs(t) - if testKnob.shouldLog() { - t.Errorf("expected default shouldLog()=false") - } - - envknob.Setenv("TS_TEST_LOGKNOB", "true") - if !testKnob.shouldLog() { - t.Errorf("expected shouldLog()=true") - } - assertLogs(t) - }) - t.Run("NetMap", func(t *testing.T) { - t.Cleanup(func() { testKnob.cap.Store(false) }) - - assertNoLogs(t) - if testKnob.shouldLog() { - t.Errorf("expected default shouldLog()=false") - } - - testKnob.UpdateFromNetMap(&netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Capabilities: []tailcfg.NodeCapability{ - "https://tailscale.com/cap/testing", - }, - }).View(), - }) - if !testKnob.shouldLog() { - t.Errorf("expected shouldLog()=true") - } - assertLogs(t) - }) -} - -func assertLogs(t *testing.T) { - var buf bytes.Buffer - logf := func(format string, args ...any) { - fmt.Fprintf(&buf, format, args...) - } - - testKnob.Do(logf, "hello %s", "world") - const want = "hello world" - if got := buf.String(); got != want { - t.Errorf("got %q, want %q", got, want) - } -} - -func assertNoLogs(t *testing.T) { - var buf bytes.Buffer - logf := func(format string, args ...any) { - fmt.Fprintf(&buf, format, args...) - } - - testKnob.Do(logf, "hello %s", "world") - if got := buf.String(); got != "" { - t.Errorf("expected no logs, but got: %q", got) - } -} diff --git a/go.mod b/go.mod index 92ba6b9c7b54d..752c6c293abd4 100644 --- a/go.mod +++ b/go.mod @@ -1,131 +1,76 @@ -module tailscale.com +module github.com/sagernet/tailscale go 1.23.1 require ( - filippo.io/mkcert v1.4.4 - fyne.io/systray v1.11.0 github.com/akutz/memconn v0.1.0 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa github.com/andybalholm/brotli v1.1.0 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be - github.com/atotto/clipboard v0.1.4 - github.com/aws/aws-sdk-go-v2 v1.24.1 - github.com/aws/aws-sdk-go-v2/config v1.26.5 - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64 - github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0 - github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 - github.com/bramvdbogaerde/go-scp v1.4.0 github.com/cilium/ebpf v0.15.0 github.com/coder/websocket v1.8.12 github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 - github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf - github.com/creack/pty v1.1.23 - github.com/dave/courtney v0.4.0 - github.com/dave/patsy v0.0.0-20210517141501-957256f50cba github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e - github.com/distribution/reference v0.6.0 github.com/djherbis/times v1.6.0 - github.com/dsnet/try v0.0.3 - github.com/elastic/crd-ref-docs v0.0.12 - github.com/evanw/esbuild v0.19.11 - github.com/fogleman/gg v1.3.0 - github.com/frankban/quicktest v1.14.6 github.com/fxamacker/cbor/v2 v2.6.0 github.com/gaissmai/bart v0.11.1 github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 - github.com/go-logr/zapr v1.3.0 github.com/go-ole/go-ole v1.3.0 github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da - github.com/golang/snappy v0.0.4 github.com/golangci/golangci-lint v1.57.1 - github.com/google/go-cmp v0.6.0 - github.com/google/go-containerregistry v0.20.2 - github.com/google/gopacket v1.1.19 github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 github.com/google/uuid v1.6.0 - github.com/goreleaser/nfpm/v2 v2.33.1 github.com/hdevalence/ed25519consensus v0.2.0 github.com/illarion/gonotify/v2 v2.0.3 - github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 github.com/jellydator/ttlcache/v3 v3.1.0 github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 github.com/jsimonetti/rtnetlink v1.4.0 - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/klauspost/compress v1.17.11 github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a - github.com/mattn/go-colorable v0.1.13 - github.com/mattn/go-isatty v0.0.20 github.com/mdlayher/genetlink v1.3.2 github.com/mdlayher/netlink v1.7.2 github.com/mdlayher/sdnotify v1.0.0 github.com/miekg/dns v1.1.58 github.com/mitchellh/go-ps v1.0.0 - github.com/peterbourgon/ff/v3 v3.4.0 github.com/pkg/errors v0.9.1 - github.com/pkg/sftp v1.13.6 github.com/prometheus-community/pro-bing v0.4.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/common v0.48.0 - github.com/prometheus/prometheus v0.49.2-0.20240125131847-c3b8ef1694ff github.com/safchain/ethtool v0.3.0 - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/studio-b12/gowebdav v0.9.0 + github.com/sagernet/gvisor v0.0.0-20241021032506-a4324256e4a3 + github.com/sagernet/wireguard-go v0.0.1-beta.4 github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 - github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a - github.com/tailscale/mkctr v0.0.0-20241111153353-1a38f6676f10 github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 - github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e - github.com/tc-hib/winres v0.2.1 github.com/tcnksm/go-httpstat v0.2.0 - github.com/toqueteos/webbrowser v1.2.0 - github.com/u-root/u-root v0.12.0 - github.com/vishvananda/netns v0.0.4 - go.uber.org/zap v1.27.0 go4.org/mem v0.0.0-20220726221520-4f986261bf13 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba golang.org/x/crypto v0.25.0 golang.org/x/exp v0.0.0-20240119083558-1b970713d09a golang.org/x/mod v0.19.0 golang.org/x/net v0.27.0 - golang.org/x/oauth2 v0.16.0 golang.org/x/sync v0.9.0 golang.org/x/sys v0.27.0 golang.org/x/term v0.22.0 - golang.org/x/time v0.5.0 + golang.org/x/time v0.7.0 golang.org/x/tools v0.23.0 - golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 golang.zx2c4.com/wireguard/windows v0.5.3 - gopkg.in/square/go-jose.v2 v2.6.0 - gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 - honnef.co/go/tools v0.5.1 - k8s.io/api v0.30.3 - k8s.io/apimachinery v0.30.3 - k8s.io/apiserver v0.30.3 - k8s.io/client-go v0.30.3 - sigs.k8s.io/controller-runtime v0.18.4 - sigs.k8s.io/controller-tools v0.15.1-0.20240618033008-7824932b0cab - sigs.k8s.io/yaml v1.4.0 - software.sslmate.com/src/go-pkcs12 v0.4.0 ) require ( github.com/4meepo/tagalign v1.3.3 // indirect github.com/Antonboom/testifylint v1.2.0 // indirect github.com/GaijinEntertainment/go-exhaustruct/v3 v3.2.0 // indirect - github.com/Masterminds/sprig v2.22.0+incompatible // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect github.com/alecthomas/go-check-sumtype v0.1.4 // indirect github.com/alexkohler/nakedret/v2 v2.0.4 // indirect @@ -135,113 +80,67 @@ require ( github.com/catenacyber/perfsprint v0.7.1 // indirect github.com/ccojocar/zxcvbn-go v1.0.2 // indirect github.com/ckaznocha/intrange v0.1.0 // indirect - github.com/cyphar/filepath-securejoin v0.2.4 // indirect - github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 // indirect - github.com/dave/brenda v1.1.0 // indirect - github.com/docker/go-connections v0.5.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/ghostiam/protogetter v0.3.5 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect - github.com/gobuffalo/flect v1.0.2 // indirect - github.com/goccy/go-yaml v1.12.0 // indirect - github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golangci/plugin-module-register v0.1.1 // indirect - github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect - github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/jjti/go-spancheck v0.5.3 // indirect github.com/karamaru-alpha/copyloopvar v1.0.8 // indirect github.com/macabu/inamedparam v0.1.3 // indirect - github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/onsi/ginkgo/v2 v2.17.2 // indirect + github.com/onsi/gomega v1.33.1 // indirect + github.com/sagernet/sing v0.5.1 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + github.com/vishvananda/netns v0.0.4 // indirect github.com/xen0n/gosmopolitan v1.2.2 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect go-simpler.org/musttag v0.9.0 // indirect go-simpler.org/sloglint v0.5.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect - go.opentelemetry.io/otel v1.32.0 // indirect - go.opentelemetry.io/otel/metric v1.32.0 // indirect - go.opentelemetry.io/otel/trace v1.32.0 // indirect go.uber.org/automaxprocs v1.5.3 // indirect - golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + honnef.co/go/tools v0.5.1 // indirect + software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect ) require ( 4d63.com/gocheckcompilerdirectives v1.2.1 // indirect 4d63.com/gochecknoglobals v0.2.1 // indirect - dario.cat/mergo v1.0.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Abirdcfly/dupword v0.0.14 // indirect - github.com/AlekSi/pointer v1.2.0 github.com/Antonboom/errname v0.1.12 // indirect github.com/Antonboom/nilnil v0.1.7 // indirect github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect github.com/Djarvur/go-err113 v0.1.0 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect - github.com/Masterminds/sprig/v3 v3.2.3 // indirect - github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/alexkohler/prealloc v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/ashanbrown/forbidigo v1.6.0 // indirect github.com/ashanbrown/makezero v1.1.1 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect - github.com/aws/smithy-go v1.19.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.1 // indirect - github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/breml/bidichk v0.2.7 // indirect github.com/breml/errchkjson v0.3.6 // indirect github.com/butuzov/ireturn v0.3.0 // indirect - github.com/cavaliergopher/cpio v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/charithe/durationcheck v0.0.10 // indirect github.com/chavacava/garif v0.1.0 // indirect - github.com/cloudflare/circl v1.3.7 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect github.com/curioswitch/go-reassign v0.2.0 // indirect github.com/daixiang0/gci v0.12.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/docker/cli v27.3.1+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker v27.3.1+incompatible // indirect - github.com/docker/docker-credential-helpers v0.8.2 // indirect - github.com/emicklei/go-restful/v3 v3.11.2 // indirect - github.com/emirpasic/gods v1.18.1 // indirect github.com/ettle/strcase v0.2.0 // indirect - github.com/evanphx/json-patch v5.6.0+incompatible // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/fatih/color v1.17.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/go-critic/go-critic v0.11.2 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.5.0 // indirect - github.com/go-git/go-git/v5 v5.11.0 // indirect github.com/go-logr/logr v1.4.2 // indirect - github.com/go-openapi/jsonpointer v0.20.2 // indirect - github.com/go-openapi/jsonreference v0.20.4 // indirect - github.com/go-openapi/swag v0.22.7 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -252,20 +151,13 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.8.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect github.com/golangci/gofmt v0.0.0-20231018234816-f50ced29576e // indirect github.com/golangci/misspell v0.4.1 // indirect github.com/golangci/revgrep v0.5.2 // indirect github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect - github.com/google/btree v1.1.2 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 // indirect - github.com/google/rpmpack v0.5.0 // indirect + github.com/google/btree v1.1.3 // indirect github.com/gordonklaus/ineffassign v0.1.0 // indirect - github.com/goreleaser/chglog v0.5.0 // indirect - github.com/goreleaser/fileglob v1.3.0 // indirect github.com/gorilla/csrf v1.7.2 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.4.2 // indirect @@ -274,24 +166,13 @@ require ( github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/huandu/xstrings v1.5.0 // indirect - github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jgautheron/goconst v1.7.0 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect github.com/julz/importas v0.1.0 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kisielk/errcheck v1.7.0 // indirect github.com/kkHAIKE/contextcheck v1.1.4 // indirect - github.com/klauspost/pgzip v1.2.6 // indirect - github.com/kr/fs v0.1.0 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/kr/text v0.2.0 // indirect github.com/kulti/thelper v0.6.3 // indirect github.com/kunwardeep/paralleltest v1.0.10 // indirect github.com/kyoh86/exportloopref v0.1.11 // indirect @@ -300,56 +181,42 @@ require ( github.com/leonklingele/grouper v1.1.1 // indirect github.com/lufeee/execinquery v1.2.1 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/maratori/testableexamples v1.0.0 // indirect github.com/maratori/testpackage v1.1.1 // indirect github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mdlayher/socket v0.5.0 github.com/mgechev/revive v1.3.7 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect github.com/moricho/tparallel v0.3.1 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nakabonne/nestif v0.3.1 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.16.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.4.8 // indirect - github.com/prometheus/client_model v0.5.0 + github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/quasilyte/go-ruleguard v0.4.2 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/ryancurrah/gomodguard v1.3.1 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect github.com/sanposhiho/wastedassign/v2 v2.0.7 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/usestdlibvars v1.25.0 // indirect github.com/securego/gosec/v2 v2.19.0 // indirect - github.com/sergi/go-diff v1.3.1 // indirect github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect - github.com/shopspring/decimal v1.3.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/sivchari/tenv v1.7.1 // indirect - github.com/skeema/knownhosts v1.2.1 // indirect github.com/sonatard/noctx v0.0.2 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/spf13/afero v1.11.0 // indirect @@ -361,7 +228,7 @@ require ( github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.9.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 @@ -372,36 +239,21 @@ require ( github.com/tomarrell/wrapcheck/v2 v2.8.3 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect - github.com/ulikunitz/xz v0.5.11 // indirect github.com/ultraware/funlen v0.1.0 // indirect github.com/ultraware/whitespace v0.1.0 // indirect github.com/uudashr/gocognit v1.1.2 // indirect - github.com/vbatts/tar-split v0.11.6 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.2.0 // indirect gitlab.com/bosi/decorder v0.4.1 // indirect - gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect golang.org/x/image v0.18.0 // indirect golang.org/x/text v0.16.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 - howett.net/plist v1.0.0 // indirect - k8s.io/apiextensions-apiserver v0.30.3 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 + gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/gofumpt v0.6.0 // indirect mvdan.cc/unparam v0.0.0-20240104100049-c549a3470d14 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect ) diff --git a/go.sum b/go.sum index fadfb22b1a0c8..bd2ea7aa2be40 100644 --- a/go.sum +++ b/go.sum @@ -34,61 +34,31 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= -filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= -fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= -fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/4meepo/tagalign v1.3.3 h1:ZsOxcwGD/jP4U/aw7qeWu58i7dwYemfy5Y+IF1ACoNw= github.com/4meepo/tagalign v1.3.3/go.mod h1:Q9c1rYMZJc9dPRkbQPpcBNCLEmY2njbAsXhQOZFE2dE= github.com/Abirdcfly/dupword v0.0.14 h1:3U4ulkc8EUo+CaT105/GJ1BQwtgyj6+VaBVbAX11Ba8= github.com/Abirdcfly/dupword v0.0.14/go.mod h1:VKDAbxdY8YbKUByLGg8EETzYSuC4crm9WwI6Y3S0cLI= -github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= -github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/Antonboom/errname v0.1.12 h1:oh9ak2zUtsLp5oaEd/erjB4GPu9w19NyoIskZClDcQY= github.com/Antonboom/errname v0.1.12/go.mod h1:bK7todrzvlaZoQagP1orKzWXv59X/x0W0Io2XT1Ssro= github.com/Antonboom/nilnil v0.1.7 h1:ofgL+BA7vlA1K2wNQOsHzLJ2Pw5B5DpWRLdDAVvvTow= github.com/Antonboom/nilnil v0.1.7/go.mod h1:TP+ScQWVEq0eSIxqU8CbdT5DFWoHp0MbP+KMUO1BKYQ= github.com/Antonboom/testifylint v1.2.0 h1:015bxD8zc5iY8QwTp4+RG9I4kIbqwvGX9TrBbb7jGdM= github.com/Antonboom/testifylint v1.2.0/go.mod h1:rkmEqjqVnHDRNsinyN6fPSLnoajzFwsCcguJgwADBkw= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= -github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Djarvur/go-err113 v0.1.0 h1:uCRZZOdMQ0TZPHYTdYpoC0bLYJKPEHPUJ8MeAa51lNU= github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= github.com/GaijinEntertainment/go-exhaustruct/v3 v3.2.0 h1:sATXp1x6/axKxz2Gjxv8MALP0bXaNRfQinEwyfMcx8c= github.com/GaijinEntertainment/go-exhaustruct/v3 v3.2.0/go.mod h1:Nl76DrGNJTA1KJ0LePKBw/vznBX1EHbAZX8mwjR82nI= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= -github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= -github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= -github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OpenPeeDeeP/depguard/v2 v2.2.0 h1:vDfG60vDtIuf0MEOhmLlLLSzqaRM8EMcgJPdp74zmpA= github.com/OpenPeeDeeP/depguard/v2 v2.2.0/go.mod h1:CIzddKRvLBC4Au5aYP/i3nyaWQ+ClszLIuVocRiCYFQ= -github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= -github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= -github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= -github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= -github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= -github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= @@ -114,68 +84,10 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s= github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= -github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= -github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= -github.com/aws/aws-sdk-go-v2/config v1.18.22/go.mod h1:mN7Li1wxaPxSSy4Xkr6stFuinJGf3VZW3ZSNvO0q6sI= -github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw= -github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU= -github.com/aws/aws-sdk-go-v2/credentials v1.13.21/go.mod h1:90Dk1lJoMyspa/EDUrldTxsPns0wn6+KpRKpdAWc0uA= -github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= -github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3/go.mod h1:4Q0UFP0YJf0NrsEuEYHpM9fTSEVnD16Z3uyEF7J9JGM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64 h1:9QJQs36z61YB8nxGwRDfWXEDYbU6H7jdI6zFiAX1vag= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64/go.mod h1:4Q7R9MFpXRdjO3YnAfUTdnuENs32WzBkASt6VxSYDYQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34/go.mod h1:Etz2dj6UHYuw+Xw830KfzCfWGMzqvUTCjUj5b76GVDc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25 h1:AzwRi5OKKwo4QNqPf7TjeO+tK8AyOK3GVSwmRPo7/Cs= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25/go.mod h1:SUbB4wcbSEyCvqBxv/O/IBf93RbEze7U7OnoTlpPB+g= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28 h1:vGWm5vTpMr39tEZfQeDiDAMgk+5qsnvRny3FjLpnH5w= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28/go.mod h1:spfrICMD6wCAhjhzHuy6DOZZ+LAIY10UxhUmLzpJTTs= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2 h1:NbWkRxEEIRSCqxhsHQuMiTH7yo+JZW1gp8v3elSVMTQ= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2/go.mod h1:4tfW5l4IAB32VWCDEBxCRtR9T4BWy4I4kr1spr8NgZM= -github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0 h1:L5h2fymEdVJYvn6hYO8Jx48YmC6xVmjmgHJV3oGKgmc= -github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0/go.mod h1:J9kLNzEiHSeGMyN7238EjJmBpCniVzFda75Gxl/NqB8= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= -github.com/aws/aws-sdk-go-v2/service/sso v1.12.9/go.mod h1:ouy2P4z6sJN70fR3ka3wD3Ro3KezSxU6eKGQI2+2fjI= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.9/go.mod h1:AFvkxc8xfBe8XA+5St5XIHHrQQtkxqrRincx4hmMHOk= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= -github.com/aws/aws-sdk-go-v2/service/sts v1.18.10/go.mod h1:BgQOMsg8av8jset59jelyPW7NoZcZXLVpDsXunGDrk8= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= -github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= -github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= -github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -184,14 +96,10 @@ github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJR github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bkielbasa/cyclop v1.2.1 h1:AeF71HZDob1P2/pRm1so9cd1alZnrpyc4q2uP2l0gJY= github.com/bkielbasa/cyclop v1.2.1/go.mod h1:K/dT/M0FPAiYjBgQGau7tz+3TMh4FWAEqlMhzFWCrgM= -github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= -github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= github.com/bombsimon/wsl/v4 v4.2.1 h1:Cxg6u+XDWff75SIFFmNsqnIOgob+Q9hG6y/ioKbRFiM= github.com/bombsimon/wsl/v4 v4.2.1/go.mod h1:Xu/kDxGZTofQcDGCtQe9KCzhHphIe0fDuyWTxER9Feo= -github.com/bramvdbogaerde/go-scp v1.4.0 h1:jKMwpwCbcX1KyvDbm/PDJuXcMuNVlLGi0Q0reuzjyKY= -github.com/bramvdbogaerde/go-scp v1.4.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= github.com/breml/bidichk v0.2.7 h1:dAkKQPLl/Qrk7hnP6P+E0xOodrq8Us7+U0o4UBOAlQY= github.com/breml/bidichk v0.2.7/go.mod h1:YodjipAGI9fGcYM7II6wFvGhdMYsC5pHDlGzqvEW3tQ= github.com/breml/errchkjson v0.3.6 h1:VLhVkqSBH96AvXEyclMR37rZslRrY2kcyq+31HCsVrA= @@ -200,20 +108,10 @@ github.com/butuzov/ireturn v0.3.0 h1:hTjMqWw3y5JC3kpnC5vXmFJAWI/m31jaCYQqzkS6PL0 github.com/butuzov/ireturn v0.3.0/go.mod h1:A09nIiwiqzN/IoVo9ogpa0Hzi9fex1kd9PSD6edP5ZA= github.com/butuzov/mirror v1.1.0 h1:ZqX54gBVMXu78QLoiqdwpl2mgmoOJTk7s4p4o+0avZI= github.com/butuzov/mirror v1.1.0/go.mod h1:8Q0BdQU6rC6WILDiBM60DBfvV78OLJmMmixe7GF45AE= -github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= -github.com/caarlos0/go-rpmutils v0.2.1-0.20211112020245-2cd62ff89b11 h1:IRrDwVlWQr6kS1U8/EtyA1+EHcc4yl8pndcqXWrEamg= -github.com/caarlos0/go-rpmutils v0.2.1-0.20211112020245-2cd62ff89b11/go.mod h1:je2KZ+LxaCNvCoKg32jtOIULcFogJKcL1ZWUaIBjKj0= -github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= -github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= github.com/catenacyber/perfsprint v0.7.1 h1:PGW5G/Kxn+YrN04cRAZKC+ZuvlVwolYMrIyyTJ/rMmc= github.com/catenacyber/perfsprint v0.7.1/go.mod h1:/wclWYompEyjUD2FuIIDVKNkqz7IgBIWXIH3V0Zol50= -github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= -github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -231,38 +129,16 @@ github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P github.com/ckaznocha/intrange v0.1.0 h1:ZiGBhvrdsKpoEfzh9CjBfDSZof6QB0ORY5tXasUtiew= github.com/ckaznocha/intrange v0.1.0/go.mod h1:Vwa9Ekex2BrEQMg6zlrWwbs/FtYw7eS5838Q7UjK7TQ= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= -github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= -github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU= -github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= -github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= -github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/curioswitch/go-reassign v0.2.0 h1:G9UZyOcpk/d7Gd6mqYgd8XYWFMw/znxwGDUstnC9DIo= github.com/curioswitch/go-reassign v0.2.0/go.mod h1:x6OpXuWvgfQaMGks2BZybTngWjT84hqJfKoO8Tt/Roc= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/daixiang0/gci v0.12.3 h1:yOZI7VAxAGPQmkb1eqt5g/11SUlwoat1fSblGLmdiQc= github.com/daixiang0/gci v0.12.3/go.mod h1:xtHP9N7AHdNvtRNfcx9gwTDfw7FRJx4bZUsiEfiNNAI= -github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 h1:YI1gOOdmMk3xodBao7fehcvoZsEeOyy/cfhlpCSPgM4= -github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14/go.mod h1:Sth2QfxfATb/nW4EsrSi2KyJmbcniZ8TgTaji17D6ms= -github.com/dave/brenda v1.1.0 h1:Sl1LlwXnbw7xMhq3y2x11McFu43AjDcwkllxxgZ3EZw= -github.com/dave/brenda v1.1.0/go.mod h1:4wCUr6gSlu5/1Tk7akE5X7UorwiQ8Rij0SKH3/BGMOM= -github.com/dave/courtney v0.4.0 h1:Vb8hi+k3O0h5++BR96FIcX0x3NovRbnhGd/dRr8inBk= -github.com/dave/courtney v0.4.0/go.mod h1:3WSU3yaloZXYAxRuWt8oRyVb9SaRiMBt5Kz/2J227tM= -github.com/dave/patsy v0.0.0-20210517141501-957256f50cba h1:1o36L4EKbZzazMk8iGC4kXpVnZ6TPxR2mZ9qVKjNNAs= -github.com/dave/patsy v0.0.0-20210517141501-957256f50cba/go.mod h1:qfR88CgEGLoiqDaE+xxDCi5QA5v4vUoW0UCX2Nd5Tlc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -273,54 +149,20 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ= -github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= -github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= -github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= -github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= -github.com/elastic/crd-ref-docs v0.0.12 h1:F3seyncbzUz3rT3d+caeYWhumb5ojYQ6Bl0Z+zOp16M= -github.com/elastic/crd-ref-docs v0.0.12/go.mod h1:X83mMBdJt05heJUYiS3T0yJ/JkCuliuhSUNav5Gjo/U= -github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= -github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= -github.com/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU= -github.com/emicklei/go-restful/v3 v3.11.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= -github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= -github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= -github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= -github.com/evanw/esbuild v0.19.11 h1:mbPO1VJ/df//jjUd+p/nRLYCpizXxXb2w/zZMShxa2k= -github.com/evanw/esbuild v0.19.11/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/firefart/nonamedreturns v1.0.4 h1:abzI1p7mAEPYuR4A+VLKn4eNDOycjYo2phmY9sfv40Y= github.com/firefart/nonamedreturns v1.0.4/go.mod h1:TDhe/tjI1BXo48CmYbUduTV7BdIga8MAO/xbKdcVsGI= -github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= -github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -335,18 +177,8 @@ github.com/ghostiam/protogetter v0.3.5 h1:+f7UiF8XNd4w3a//4DnusQ2SZjPkUjxkMEfjbx github.com/ghostiam/protogetter v0.3.5/go.mod h1:7lpeDnEJ1ZjL/YtyoN99ljO4z0pd3H0d18/t2dPBxHw= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= -github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/go-critic/go-critic v0.11.2 h1:81xH/2muBphEgPtcwH1p6QD+KzXl2tMSi3hXjBSxDnM= github.com/go-critic/go-critic v0.11.2/go.mod h1:OePaicfjsf+KPy33yq4gzv6CO7TEQ9Rom6ns1KsJnl8= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= -github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= -github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -358,32 +190,16 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= -github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= -github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= -github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= -github.com/go-openapi/swag v0.22.7 h1:JWrc1uc/P9cSomxfnsFSVWoE1FW6bNbrVPmpQYpCcR8= -github.com/go-openapi/swag v0.22.7/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= -github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= -github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= @@ -407,21 +223,13 @@ github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsM github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U= github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= -github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA= -github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM= -github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -451,10 +259,6 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a h1:w8hkcTqaFpzKqonE9uMCefW1WDie15eSP/4MssdenaM= github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a/go.mod h1:ryS0uhF+x9jgbj/N71xsEqODy9BN81/GonCZiOzirOk= github.com/golangci/gofmt v0.0.0-20231018234816-f50ced29576e h1:ULcKCDV1LOZPFxGZaA6TlQbiM3J2GCPnkx/bGF6sX/g= @@ -471,10 +275,8 @@ github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNF github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed/go.mod h1:XLXN8bNw4CGRPaqgl3bv/lhz7bsGPh4/xSaMTbo2vkQ= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= -github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -487,18 +289,11 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo= -github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= -github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= -github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 h1:CVuJwN34x4xM2aT4sIKhmeib40NeBPhRihNjQmpJsA4= -github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= @@ -510,26 +305,15 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/rpmpack v0.5.0 h1:L16KZ3QvkFGpYhmp23iQip+mx1X39foEsqszjMNBm8A= -github.com/google/rpmpack v0.5.0/go.mod h1:uqVAUVQLq8UY2hCDfmJ/+rtO3aw7qyhc90rCVEabEfI= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= -github.com/goreleaser/chglog v0.5.0 h1:Sk6BMIpx8+vpAf8KyPit34OgWui8c7nKTMHhYx88jJ4= -github.com/goreleaser/chglog v0.5.0/go.mod h1:Ri46M3lrMuv76FHszs3vtABR8J8k1w9JHYAzxeeOl28= -github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I= -github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU= -github.com/goreleaser/nfpm/v2 v2.33.1 h1:EkdAzZyVhAI9JC1vjmjjbmnNzyH1J6Cu4JCsA7YcQuc= -github.com/goreleaser/nfpm/v2 v2.33.1/go.mod h1:8wwWWvJWmn84xo/Sqiv0aMvEGTHlHZTXTEuVSgQpkIM= github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= @@ -546,9 +330,6 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.4.0 h1:nhdCmubdmDF6VEatUNjgUZBJKWRqugoISdUv3PPQgHY= github.com/gostaticanalysis/testutil v0.4.0/go.mod h1:bLIoPefWXrRi/ssLFWX1dx7Repi5x3CuviD3dgAZaBU= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -560,28 +341,15 @@ github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f h1:ov45/OzrJG8EKbGjn7jJZQJTN7Z1t73sFYNIRd64YlI= -github.com/hugelgupf/vmtest v0.0.0-20240102225328-693afabdd27f/go.mod h1:JoDrYMZpDPYo6uH9/f6Peqms3zNNWT2XiGgioMOIGuI= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c h1:gYfYE403/nlrGNYj6BEOs9ucLCAGB9gstlSk92DttTg= -github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jgautheron/goconst v1.7.0 h1:cEqH+YBKLsECnRSd4F4TK5ri8t/aXtt/qoL0Ft252B0= github.com/jgautheron/goconst v1.7.0/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= @@ -590,12 +358,6 @@ github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af h1:KA9B github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af/go.mod h1:HEWGJkRDzjJY2sqdDwxccsGicWEf9BQOZsq2tV+xzM0= github.com/jjti/go-spancheck v0.5.3 h1:vfq4s2IB8T3HvbpiwDTYgVPj1Ze/ZSXrTtaZRTc7CuM= github.com/jjti/go-spancheck v0.5.3/go.mod h1:eQdOX1k3T+nAKvZDyLC3Eby0La4dZ+I19iOl5NzSPFE= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= @@ -605,23 +367,15 @@ github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4os github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julz/importas v0.1.0 h1:F78HnrsjY3cR7j0etXy5+TU1Zuy7Xt08X/1aJnH5xXY= github.com/julz/importas v0.1.0/go.mod h1:oSFU2R4XK/P7kNBrnL/FEQlDGN1/6WoxXEjSSXO0DV0= github.com/karamaru-alpha/copyloopvar v1.0.8 h1:gieLARwuByhEMxRwM3GRS/juJqFbLraftXIKDDNJ50Q= github.com/karamaru-alpha/copyloopvar v1.0.8/go.mod h1:u7CIfztblY0jZLOQZgH3oYsJzpC2A7S6u/lfgSXHy0k= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.7.0 h1:+SbscKmWJ5mOK/bO1zS60F5I9WwZDWOfRsC4RwfwRV0= github.com/kisielk/errcheck v1.7.0/go.mod h1:1kLL+jV4e+CFfueBmI1dSK2ADDyQnlrnrY/FqKluHJQ= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -629,14 +383,10 @@ github.com/kkHAIKE/contextcheck v1.1.4 h1:B6zAaLhOEEcjvUgIYEqystmnFk1Oemn8bvJhbt github.com/kkHAIKE/contextcheck v1.1.4/go.mod h1:1+i/gWqokIa+dm31mqGLZhZJ7Uh44DJGZVmr6QRBNJg= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= -github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -655,8 +405,6 @@ github.com/ldez/gomoddirectives v0.2.3 h1:y7MBaisZVDYmKvt9/l1mjNCiSA1BVn34U0ObUc github.com/ldez/gomoddirectives v0.2.3/go.mod h1:cpgBogWITnCfRq2qGoDkKMEVSaarhdBr6g8G04uz6d0= github.com/ldez/tagliatelle v0.5.0 h1:epgfuYt9v0CG3fms0pEgIMNPuFf/LpPIfjk4kyqSioo= github.com/ldez/tagliatelle v0.5.0/go.mod h1:rj1HmWiL1MiKQuOONhd09iySTEkUuE/8+5jtPYz9xa4= -github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= -github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leonklingele/grouper v1.1.1 h1:suWXRU57D4/Enn6pXR0QVqqWWrnJ9Osrz+5rjt8ivzU= github.com/leonklingele/grouper v1.1.1/go.mod h1:uk3I3uDfi9B6PeUjsCKi6ndcf63Uy7snXgR4yDYQVDY= github.com/lufeee/execinquery v1.2.1 h1:hf0Ems4SHcUGBxpGN7Jz78z1ppVkP/837ZlETPCEtOM= @@ -665,8 +413,6 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= @@ -696,35 +442,19 @@ github.com/mgechev/revive v1.3.7 h1:502QY0vQGe9KtYJ9FpxMz9rL+Fc/P13CI5POL4uHCcE= github.com/mgechev/revive v1.3.7/go.mod h1:RJ16jUbF0OWC3co/+XTxmFNgEpUPwnnA0BRllX2aDNA= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA= -github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/moricho/tparallel v0.3.1 h1:fQKD4U1wRMAYNngDonW5XupoB/ZGJHdpzrWqgyg9krA= github.com/moricho/tparallel v0.3.1/go.mod h1:leENX2cUv7Sv2qDgdi0D0fCftN8fRC67Bcn8pqzeYNI= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= @@ -737,20 +467,12 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.16.1 h1:uDIPSxgVHZ7PgbJElRDGzymkXH+JaF7mjew+Thjnt6Q= github.com/nunnatsa/ginkgolinter v0.16.1/go.mod h1:4tWRinDN1FeJgU+iJANW/kz7xKN5nYRAOfJDQUS9dOQ= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= -github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/ginkgo/v2 v2.17.2 h1:7eMhcy3GimbsA3hEnVKdw/PQM9XN9krpKVXsZdph0/g= +github.com/onsi/ginkgo/v2 v2.17.2/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= @@ -760,13 +482,9 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9 github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= -github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= -github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -774,8 +492,6 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= -github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -811,8 +527,6 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/prometheus/prometheus v0.49.2-0.20240125131847-c3b8ef1694ff h1:X1Tly81aZ22DA1fxBdfvR3iw8+yFoUBUHMEd+AX/ZXI= -github.com/prometheus/prometheus v0.49.2-0.20240125131847-c3b8ef1694ff/go.mod h1:FvE8dtQ1Ww63IlyKBn1V4s+zMwF9kHkVNkQBR1pM4CU= github.com/quasilyte/go-ruleguard v0.4.2 h1:htXcXDK6/rO12kiTHKfHuqR4kr3Y4M0J0rOL6CH/BYs= github.com/quasilyte/go-ruleguard v0.4.2/go.mod h1:GJLgqsLeo4qgavUoL8JeGFNS7qcisx3awV/w9eWTmNI= github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= @@ -825,7 +539,6 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -835,6 +548,12 @@ github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9f github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/sagernet/gvisor v0.0.0-20241021032506-a4324256e4a3 h1:RxEz7LhPNiF/gX/Hg+OXr5lqsM9iVAgmaK1L1vzlDRM= +github.com/sagernet/gvisor v0.0.0-20241021032506-a4324256e4a3/go.mod h1:ehZwnT2UpmOWAHFL48XdBhnd4Qu4hN2O3Ji0us3ZHMw= +github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= +github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/wireguard-go v0.0.1-beta.4 h1:8uyM5fxfEXdu4RH05uOK+v25i3lTNdCYMPSAUJ14FnI= +github.com/sagernet/wireguard-go v0.0.1-beta.4/go.mod h1:jGXij2Gn2wbrWuYNUmmNhf1dwcZtvyAvQoe8Xd8MbUo= github.com/sanposhiho/wastedassign/v2 v2.0.7 h1:J+6nrY4VW+gC9xFzUc+XjPD3g3wF3je/NsJFwFK7Uxc= github.com/sanposhiho/wastedassign/v2 v2.0.7/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= @@ -846,40 +565,25 @@ github.com/sashamelentyev/usestdlibvars v1.25.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7 github.com/securego/gosec/v2 v2.19.0 h1:gl5xMkOI0/E6Hxx0XCY2XujA3V7SNSefA8sC+3f1gnk= github.com/securego/gosec/v2 v2.19.0/go.mod h1:hOkDcHz9J/XIgIlPDXalxjeVYsHxoWUc5zJSHxcB8YM= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/sivchari/tenv v1.7.1 h1:PSpuD4bu6fSmtWMxSGWcvqUUgIn7k3yOJhOIzVWn8Ak= github.com/sivchari/tenv v1.7.1/go.mod h1:64yStXKSOxDfX47NlhVwND4dHwfZDdbp2Lyl018Icvg= -github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= -github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/smartystreets/assertions v1.13.1 h1:Ef7KhSmjZcK6AVf9YbJdvPYG9avaF0ZxudX+ThRdWfU= -github.com/smartystreets/assertions v1.13.1/go.mod h1:cXr/IwVfSo/RbCSPhoAPv73p3hlSdrBH/b3SdnW/LMY= -github.com/smartystreets/goconvey v1.8.0 h1:Oi49ha/2MURE0WexF052Z0m+BNSGirfjg5RL+JXWq3w= -github.com/smartystreets/goconvey v1.8.0/go.mod h1:EdX8jtrTIj26jmjCOVNMVSIYAtgexqXKHOXW2Dx9JLg= github.com/sonatard/noctx v0.0.2 h1:L7Dz4De2zDQhW8S0t+KUjY0MAQJd6SgVwhzNIc4ok00= github.com/sonatard/noctx v0.0.2/go.mod h1:kzFz+CzWSjQ2OzIm46uJZoXuBpa2+0y3T36U18dWqIo= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -907,12 +611,9 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU= -github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c h1:+aPplBwWcHBo6q9xrfWdMrT9o4kltkmmvpemgIjep/8= @@ -923,16 +624,12 @@ github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 h1:34icjjmqJ2HP github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= -github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns= -github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41/go.mod h1:/roCdA6gg6lQyw/Oz6gIIGu3ggJKYhF+WC/AQReE5XQ= github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw= github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= -github.com/tailscale/mkctr v0.0.0-20241111153353-1a38f6676f10 h1:ZB47BgnHcEHQJODkDubs5ZiNeJxMhcgzefV3lykRwVQ= -github.com/tailscale/mkctr v0.0.0-20241111153353-1a38f6676f10/go.mod h1:iDx/0Rr9VV/KanSUDpJ6I/ROf0sQ7OqljXc/esl0UIA= github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w= @@ -941,8 +638,6 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:t github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ= -github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -965,35 +660,21 @@ github.com/tomarrell/wrapcheck/v2 v2.8.3 h1:5ov+Cbhlgi7s/a42BprYoxsr73CbdMUTzE3b github.com/tomarrell/wrapcheck/v2 v2.8.3/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= -github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ= -github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= -github.com/u-root/gobusybox/src v0.0.0-20231228173702-b69f654846aa h1:unMPGGK/CRzfg923allsikmvk2l7beBeFPUNC4RVX/8= -github.com/u-root/gobusybox/src v0.0.0-20231228173702-b69f654846aa/go.mod h1:Zj4Tt22fJVn/nz/y6Ergm1SahR9dio1Zm/D2/S0TmXM= -github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= -github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw= github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= -github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= -github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ultraware/funlen v0.1.0 h1:BuqclbkY6pO+cvxoq7OsktIXZpgBSkYTQtmwhAK81vI= github.com/ultraware/funlen v0.1.0/go.mod h1:XJqmOQja6DpxarLj6Jj1U7JuoS8PvL4nEqDaQhy22p4= github.com/ultraware/whitespace v0.1.0 h1:O1HKYoh0kIeqE8sFqZf1o0qbORXUCOQFrlaQyZsczZw= github.com/ultraware/whitespace v0.1.0/go.mod h1:/se4r3beMFNmewJ4Xmz0nMQ941GJt+qmSHGP9emHYe0= github.com/uudashr/gocognit v1.1.2 h1:l6BAEKJqQH2UpKAPKdMfZf5kE4W/2xk8pfU1OVLvniI= github.com/uudashr/gocognit v1.1.2/go.mod h1:aAVdLURqcanke8h3vg35BC++eseDm66Z7KmchI5et4k= -github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs= -github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU= github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg= -github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= -github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= github.com/yeya24/promlinter v0.2.0 h1:xFKDQ82orCU5jQujdaD8stOHiv8UN68BSdn2a8u8Y3o= @@ -1009,8 +690,6 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= gitlab.com/bosi/decorder v0.4.1 h1:VdsdfxhstabyhZovHafFw+9eJ6eU0d2CkFNJcZz/NU4= gitlab.com/bosi/decorder v0.4.1/go.mod h1:jecSqWUew6Yle1pCr2eLWTensJMmsxHsBwt+PVbkAqA= -gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= -gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= go-simpler.org/assert v0.7.0 h1:OzWWZqfNxt8cLS+MlUp6Tgk1HjPkmgdKBq9qvy8lZsA= go-simpler.org/assert v0.7.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= go-simpler.org/musttag v0.9.0 h1:Dzt6/tyP9ONr5g9h9P3cnYWCxeBFRkd0uJL/w+1Mxos= @@ -1022,22 +701,6 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= -go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= -go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0/go.mod h1:noq80iT8rrHP1SfybmPiRGc9dc5M8RPmGvtwo7Oo7tc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= -go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= -go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= -go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= -go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= -go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= -go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= -go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= -go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -1057,11 +720,7 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= -golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1146,13 +805,11 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1161,8 +818,6 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= -golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1191,7 +846,6 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1234,7 +888,6 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1247,7 +900,6 @@ golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1257,18 +909,16 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -1311,7 +961,6 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= @@ -1319,7 +968,6 @@ golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= @@ -1339,14 +987,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= -golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -1369,8 +1013,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -1400,11 +1042,6 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917 h1:nz5NESFLZbJGPFxDT/HCn+V1mZ8JGNoY4nUpmW/Y2eg= -google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac h1:OZkkudMUu9LVQMCoRUbI/1p5VCo9BOrlvkqMvWtqa6s= -google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:B5xPO//w8qmBDjGReYLpR6UJPnkldGkCSMoH/2vxJeg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac h1:nUQEQmH/csSvFECKYRv6HWEyypysidKl2I6Qpsglq/0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1417,8 +1054,6 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= -google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1440,32 +1075,18 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= -gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= -gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8= -gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -1475,24 +1096,6 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= -howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= -howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -k8s.io/api v0.30.3 h1:ImHwK9DCsPA9uoU3rVh4QHAHHK5dTSv1nxJUapx8hoQ= -k8s.io/api v0.30.3/go.mod h1:GPc8jlzoe5JG3pb0KJCSLX5oAFIW3/qNJITlDj8BH04= -k8s.io/apiextensions-apiserver v0.30.3 h1:oChu5li2vsZHx2IvnGP3ah8Nj3KyqG3kRSaKmijhB9U= -k8s.io/apiextensions-apiserver v0.30.3/go.mod h1:uhXxYDkMAvl6CJw4lrDN4CPbONkF3+XL9cacCT44kV4= -k8s.io/apimachinery v0.30.3 h1:q1laaWCmrszyQuSQCfNB8cFgCuDAoPszKY4ucAjDwHc= -k8s.io/apimachinery v0.30.3/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= -k8s.io/apiserver v0.30.3 h1:QZJndA9k2MjFqpnyYv/PH+9PE0SHhx3hBho4X0vE65g= -k8s.io/apiserver v0.30.3/go.mod h1:6Oa88y1CZqnzetd2JdepO0UXzQX4ZnOekx2/PtEjrOg= -k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k= -k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= mvdan.cc/gofumpt v0.6.0 h1:G3QvahNDmpD+Aek/bNOLrFR2XC6ZAdo62dZu65gmwGo= mvdan.cc/gofumpt v0.6.0/go.mod h1:4L0wf+kgIPZtcCWXynNS2e6bhmj73umwnuXSZarixzA= mvdan.cc/unparam v0.0.0-20240104100049-c549a3470d14 h1:zCr3iRRgdk5eIikZNDphGcM6KGVTx3Yu+/Uu9Es254w= @@ -1500,15 +1103,5 @@ mvdan.cc/unparam v0.0.0-20240104100049-c549a3470d14/go.mod h1:ZzZjEpJDOmx8TdVU6u rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/controller-runtime v0.18.4 h1:87+guW1zhvuPLh1PHybKdYFLU0YJp4FhJRmiHvm5BZw= -sigs.k8s.io/controller-runtime v0.18.4/go.mod h1:TVoGrfdpbA9VRFaRnKgk9P5/atA0pMwq+f+msb9M8Sg= -sigs.k8s.io/controller-tools v0.15.1-0.20240618033008-7824932b0cab h1:Fq4VD28nejtsijBNTeRRy9Tt3FVwq+o6NB7fIxja8uY= -sigs.k8s.io/controller-tools v0.15.1-0.20240618033008-7824932b0cab/go.mod h1:egedX5jq2KrZ3A2zaOz3e2DSsh5BhFyyjvNcBRIQel8= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/health/health.go b/health/health.go index 3bebcb98356f4..03e5da27c0220 100644 --- a/health/health.go +++ b/health/health.go @@ -19,16 +19,16 @@ import ( "sync/atomic" "time" - "tailscale.com/envknob" - "tailscale.com/metrics" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/util/cibuild" - "tailscale.com/util/mak" - "tailscale.com/util/multierr" - "tailscale.com/util/set" - "tailscale.com/util/usermetric" - "tailscale.com/version" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/util/cibuild" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/usermetric" + "github.com/sagernet/tailscale/version" ) var ( diff --git a/health/health_test.go b/health/health_test.go deleted file mode 100644 index 8107c1cf09db5..0000000000000 --- a/health/health_test.go +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package health - -import ( - "fmt" - "reflect" - "slices" - "testing" - "time" - - "tailscale.com/tailcfg" - "tailscale.com/types/opt" -) - -func TestAppendWarnableDebugFlags(t *testing.T) { - var tr Tracker - - for i := range 10 { - w := Register(&Warnable{ - Code: WarnableCode(fmt.Sprintf("warnable-code-%d", i)), - MapDebugFlag: fmt.Sprint(i), - }) - defer unregister(w) - if i%2 == 0 { - tr.SetUnhealthy(w, Args{"test-arg": fmt.Sprint(i)}) - } - } - - want := []string{"z", "y", "0", "2", "4", "6", "8"} - - var got []string - for range 20 { - got = append(got[:0], "z", "y") - got = tr.AppendWarnableDebugFlags(got) - if !reflect.DeepEqual(got, want) { - t.Fatalf("AppendWarnableDebugFlags = %q; want %q", got, want) - } - } -} - -// Test that all exported methods on *Tracker don't panic with a nil receiver. -func TestNilMethodsDontCrash(t *testing.T) { - var nilt *Tracker - rv := reflect.ValueOf(nilt) - for i := 0; i < rv.NumMethod(); i++ { - mt := rv.Type().Method(i) - t.Logf("calling Tracker.%s ...", mt.Name) - var args []reflect.Value - for j := 0; j < mt.Type.NumIn(); j++ { - if j == 0 && mt.Type.In(j) == reflect.TypeFor[*Tracker]() { - continue - } - args = append(args, reflect.Zero(mt.Type.In(j))) - } - rv.Method(i).Call(args) - } -} - -func TestSetUnhealthyWithDuplicateThenHealthyAgain(t *testing.T) { - ht := Tracker{} - if len(ht.Strings()) != 0 { - t.Fatalf("before first insertion, len(newTracker.Strings) = %d; want = 0", len(ht.Strings())) - } - - ht.SetUnhealthy(testWarnable, Args{ArgError: "Hello world 1"}) - want := []string{"Hello world 1"} - if !reflect.DeepEqual(ht.Strings(), want) { - t.Fatalf("after calling SetUnhealthy, newTracker.Strings() = %v; want = %v", ht.Strings(), want) - } - - // Adding a second warning state with the same WarningCode overwrites the existing warning state, - // the count shouldn't have changed. - ht.SetUnhealthy(testWarnable, Args{ArgError: "Hello world 2"}) - want = []string{"Hello world 2"} - if !reflect.DeepEqual(ht.Strings(), want) { - t.Fatalf("after insertion of same WarningCode, newTracker.Strings() = %v; want = %v", ht.Strings(), want) - } - - ht.SetHealthy(testWarnable) - want = []string{} - if !reflect.DeepEqual(ht.Strings(), want) { - t.Fatalf("after setting the healthy, newTracker.Strings() = %v; want = %v", ht.Strings(), want) - } -} - -func TestRemoveAllWarnings(t *testing.T) { - ht := Tracker{} - if len(ht.Strings()) != 0 { - t.Fatalf("before first insertion, len(newTracker.Strings) = %d; want = 0", len(ht.Strings())) - } - - ht.SetUnhealthy(testWarnable, Args{"Text": "Hello world 1"}) - if len(ht.Strings()) != 1 { - t.Fatalf("after first insertion, len(newTracker.Strings) = %d; want = %d", len(ht.Strings()), 1) - } - - ht.SetHealthy(testWarnable) - if len(ht.Strings()) != 0 { - t.Fatalf("after RemoveAll, len(newTracker.Strings) = %d; want = 0", len(ht.Strings())) - } -} - -// TestWatcher tests that a registered watcher function gets called with the correct -// Warnable and non-nil/nil UnhealthyState upon setting a Warnable to unhealthy/healthy. -func TestWatcher(t *testing.T) { - ht := Tracker{} - wantText := "Hello world" - becameUnhealthy := make(chan struct{}) - becameHealthy := make(chan struct{}) - - watcherFunc := func(w *Warnable, us *UnhealthyState) { - if w != testWarnable { - t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, testWarnable) - } - - if us != nil { - if us.Text != wantText { - t.Fatalf("unexpected us.Text: %s, want: %s", us.Text, wantText) - } - if us.Args[ArgError] != wantText { - t.Fatalf("unexpected us.Args[ArgError]: %s, want: %s", us.Args[ArgError], wantText) - } - becameUnhealthy <- struct{}{} - } else { - becameHealthy <- struct{}{} - } - } - - unregisterFunc := ht.RegisterWatcher(watcherFunc) - if len(ht.watchers) != 1 { - t.Fatalf("after RegisterWatcher, len(newTracker.watchers) = %d; want = 1", len(ht.watchers)) - } - ht.SetUnhealthy(testWarnable, Args{ArgError: wantText}) - - select { - case <-becameUnhealthy: - // Test passed because the watcher got notified of an unhealthy state - case <-becameHealthy: - // Test failed because the watcher got of a healthy state instead of an unhealthy one - t.Fatalf("watcherFunc was called with a healthy state") - case <-time.After(1 * time.Second): - t.Fatalf("watcherFunc didn't get called upon calling SetUnhealthy") - } - - ht.SetHealthy(testWarnable) - - select { - case <-becameUnhealthy: - // Test failed because the watcher got of an unhealthy state instead of a healthy one - t.Fatalf("watcherFunc was called with an unhealthy state") - case <-becameHealthy: - // Test passed because the watcher got notified of a healthy state - case <-time.After(1 * time.Second): - t.Fatalf("watcherFunc didn't get called upon calling SetUnhealthy") - } - - unregisterFunc() - if len(ht.watchers) != 0 { - t.Fatalf("after unregisterFunc, len(newTracker.watchers) = %d; want = 0", len(ht.watchers)) - } -} - -// TestWatcherWithTimeToVisible tests that a registered watcher function gets called with the correct -// Warnable and non-nil/nil UnhealthyState upon setting a Warnable to unhealthy/healthy, but the Warnable -// has a TimeToVisible set, which means that a watcher should only be notified of an unhealthy state after -// the TimeToVisible duration has passed. -func TestSetUnhealthyWithTimeToVisible(t *testing.T) { - ht := Tracker{} - mw := Register(&Warnable{ - Code: "test-warnable-3-secs-to-visible", - Title: "Test Warnable with 3 seconds to visible", - Text: StaticMessage("Hello world"), - TimeToVisible: 2 * time.Second, - ImpactsConnectivity: true, - }) - defer unregister(mw) - - becameUnhealthy := make(chan struct{}) - becameHealthy := make(chan struct{}) - - watchFunc := func(w *Warnable, us *UnhealthyState) { - if w != mw { - t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, w) - } - - if us != nil { - becameUnhealthy <- struct{}{} - } else { - becameHealthy <- struct{}{} - } - } - - ht.RegisterWatcher(watchFunc) - ht.SetUnhealthy(mw, Args{ArgError: "Hello world"}) - - select { - case <-becameUnhealthy: - // Test failed because the watcher got notified of an unhealthy state - t.Fatalf("watcherFunc was called with an unhealthy state") - case <-becameHealthy: - // Test failed because the watcher got of a healthy state - t.Fatalf("watcherFunc was called with a healthy state") - case <-time.After(1 * time.Second): - // As expected, watcherFunc still had not been called after 1 second - } -} - -func TestRegisterWarnablePanicsWithDuplicate(t *testing.T) { - w := &Warnable{ - Code: "test-warnable-1", - } - - Register(w) - defer unregister(w) - if registeredWarnables[w.Code] != w { - t.Fatalf("after Register, registeredWarnables[%s] = %v; want = %v", w.Code, registeredWarnables[w.Code], w) - } - - defer func() { - if r := recover(); r == nil { - t.Fatalf("Registering the same Warnable twice didn't panic") - } - }() - Register(w) -} - -// TestCheckDependsOnAppearsInUnhealthyState asserts that the DependsOn field in the UnhealthyState -// is populated with the WarnableCode(s) of the Warnable(s) that a warning depends on. -func TestCheckDependsOnAppearsInUnhealthyState(t *testing.T) { - ht := Tracker{} - w1 := Register(&Warnable{ - Code: "w1", - Text: StaticMessage("W1 Text"), - DependsOn: []*Warnable{}, - }) - defer unregister(w1) - w2 := Register(&Warnable{ - Code: "w2", - Text: StaticMessage("W2 Text"), - DependsOn: []*Warnable{w1}, - }) - defer unregister(w2) - - ht.SetUnhealthy(w1, Args{ArgError: "w1 is unhealthy"}) - us1, ok := ht.CurrentState().Warnings[w1.Code] - if !ok { - t.Fatalf("Expected an UnhealthyState for w1, got nothing") - } - wantDependsOn := []WarnableCode{warmingUpWarnable.Code} - if !reflect.DeepEqual(us1.DependsOn, wantDependsOn) { - t.Fatalf("Expected DependsOn = %v in the unhealthy state, got: %v", wantDependsOn, us1.DependsOn) - } - ht.SetUnhealthy(w2, Args{ArgError: "w2 is also unhealthy now"}) - us2, ok := ht.CurrentState().Warnings[w2.Code] - if !ok { - t.Fatalf("Expected an UnhealthyState for w2, got nothing") - } - wantDependsOn = slices.Concat([]WarnableCode{w1.Code}, wantDependsOn) - if !reflect.DeepEqual(us2.DependsOn, wantDependsOn) { - t.Fatalf("Expected DependsOn = %v in the unhealthy state, got: %v", wantDependsOn, us2.DependsOn) - } -} - -func TestShowUpdateWarnable(t *testing.T) { - tests := []struct { - desc string - check bool - apply opt.Bool - cv *tailcfg.ClientVersion - wantWarnable *Warnable - wantShow bool - }{ - { - desc: "nil CientVersion", - check: true, - cv: nil, - wantWarnable: nil, - wantShow: false, - }, - { - desc: "RunningLatest", - check: true, - cv: &tailcfg.ClientVersion{RunningLatest: true}, - wantWarnable: nil, - wantShow: false, - }, - { - desc: "no LatestVersion", - check: true, - cv: &tailcfg.ClientVersion{RunningLatest: false, LatestVersion: ""}, - wantWarnable: nil, - wantShow: false, - }, - { - desc: "show regular update", - check: true, - cv: &tailcfg.ClientVersion{RunningLatest: false, LatestVersion: "1.2.3"}, - wantWarnable: updateAvailableWarnable, - wantShow: true, - }, - { - desc: "show security update", - check: true, - cv: &tailcfg.ClientVersion{RunningLatest: false, LatestVersion: "1.2.3", UrgentSecurityUpdate: true}, - wantWarnable: securityUpdateAvailableWarnable, - wantShow: true, - }, - { - desc: "update check disabled", - check: false, - cv: &tailcfg.ClientVersion{RunningLatest: false, LatestVersion: "1.2.3"}, - wantWarnable: nil, - wantShow: false, - }, - { - desc: "hide update with auto-updates", - check: true, - apply: opt.NewBool(true), - cv: &tailcfg.ClientVersion{RunningLatest: false, LatestVersion: "1.2.3"}, - wantWarnable: nil, - wantShow: false, - }, - { - desc: "show security update with auto-updates", - check: true, - apply: opt.NewBool(true), - cv: &tailcfg.ClientVersion{RunningLatest: false, LatestVersion: "1.2.3", UrgentSecurityUpdate: true}, - wantWarnable: securityUpdateAvailableWarnable, - wantShow: true, - }, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - tr := &Tracker{ - checkForUpdates: tt.check, - applyUpdates: tt.apply, - latestVersion: tt.cv, - } - gotWarnable, gotShow := tr.showUpdateWarnable() - if gotWarnable != tt.wantWarnable { - t.Errorf("got warnable: %v, want: %v", gotWarnable, tt.wantWarnable) - } - if gotShow != tt.wantShow { - t.Errorf("got show: %v, want: %v", gotShow, tt.wantShow) - } - }) - } -} diff --git a/health/warnings.go b/health/warnings.go index 7a21f9695ff6d..714a315158ce7 100644 --- a/health/warnings.go +++ b/health/warnings.go @@ -8,7 +8,7 @@ import ( "runtime" "time" - "tailscale.com/version" + "github.com/sagernet/tailscale/version" ) /** diff --git a/hostinfo/hostinfo.go b/hostinfo/hostinfo.go index 3d4216922a12b..e5700089e98bb 100644 --- a/hostinfo/hostinfo.go +++ b/hostinfo/hostinfo.go @@ -18,16 +18,16 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/util/cloudenv" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/lineiter" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" "go4.org/mem" - "tailscale.com/envknob" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/util/cloudenv" - "tailscale.com/util/dnsname" - "tailscale.com/util/lineiter" - "tailscale.com/version" - "tailscale.com/version/distro" ) var started = time.Now() diff --git a/hostinfo/hostinfo_container_linux_test.go b/hostinfo/hostinfo_container_linux_test.go deleted file mode 100644 index 594a5f5120a6a..0000000000000 --- a/hostinfo/hostinfo_container_linux_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux && !android && ts_package_container - -package hostinfo - -import ( - "testing" -) - -func TestInContainer(t *testing.T) { - if got := inContainer(); !got.EqualBool(true) { - t.Errorf("inContainer = %v; want true due to ts_package_container build tag", got) - } -} diff --git a/hostinfo/hostinfo_freebsd.go b/hostinfo/hostinfo_freebsd.go index 3661b13229ac5..2bd5b7920ef6d 100644 --- a/hostinfo/hostinfo_freebsd.go +++ b/hostinfo/hostinfo_freebsd.go @@ -10,9 +10,9 @@ import ( "os" "os/exec" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/version/distro" "golang.org/x/sys/unix" - "tailscale.com/types/ptr" - "tailscale.com/version/distro" ) func init() { diff --git a/hostinfo/hostinfo_linux.go b/hostinfo/hostinfo_linux.go index 66484a3588027..7b951526493ec 100644 --- a/hostinfo/hostinfo_linux.go +++ b/hostinfo/hostinfo_linux.go @@ -10,10 +10,10 @@ import ( "os" "strings" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/util/lineiter" + "github.com/sagernet/tailscale/version/distro" "golang.org/x/sys/unix" - "tailscale.com/types/ptr" - "tailscale.com/util/lineiter" - "tailscale.com/version/distro" ) func init() { diff --git a/hostinfo/hostinfo_linux_test.go b/hostinfo/hostinfo_linux_test.go deleted file mode 100644 index c8bd2abbeb230..0000000000000 --- a/hostinfo/hostinfo_linux_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux && !android && !ts_package_container - -package hostinfo - -import ( - "testing" -) - -func TestQnap(t *testing.T) { - version_info := `commit 2910d3a594b068024ed01a64a0fe4168cb001a12 -Date: 2022-05-30 16:08:45 +0800 -================================================ -* QTSFW_5.0.0 -remotes/origin/QTSFW_5.0.0` - - got := getQnapQtsVersion(version_info) - want := "5.0.0" - if got != want { - t.Errorf("got %q; want %q", got, want) - } - - got = getQnapQtsVersion("") - want = "" - if got != want { - t.Errorf("got %q; want %q", got, want) - } - - got = getQnapQtsVersion("just a bunch of junk") - want = "" - if got != want { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestInContainer(t *testing.T) { - if got := inContainer(); !got.EqualBool(false) { - t.Errorf("inContainer = %v; want false due to absence of ts_package_container build tag", got) - } -} diff --git a/hostinfo/hostinfo_test.go b/hostinfo/hostinfo_test.go deleted file mode 100644 index 9fe32e0449be1..0000000000000 --- a/hostinfo/hostinfo_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package hostinfo - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestNew(t *testing.T) { - hi := New() - if hi == nil { - t.Fatal("no Hostinfo") - } - j, err := json.MarshalIndent(hi, " ", "") - if err != nil { - t.Fatal(err) - } - t.Logf("Got: %s", j) -} - -func TestOSVersion(t *testing.T) { - if osVersion == nil { - t.Skip("not available for OS") - } - t.Logf("Got: %#q", osVersion()) -} - -func TestEtcAptSourceFileIsDisabled(t *testing.T) { - tests := []struct { - name string - in string - want bool - }{ - {"empty", "", false}, - {"normal", "deb foo\n", false}, - {"normal-commented", "# deb foo\n", false}, - {"normal-disabled-by-ubuntu", "# deb foo # disabled on upgrade to dingus\n", true}, - {"normal-disabled-then-uncommented", "deb foo # disabled on upgrade to dingus\n", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := etcAptSourceFileIsDisabled(strings.NewReader(tt.in)) - if got != tt.want { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } -} diff --git a/hostinfo/hostinfo_uname.go b/hostinfo/hostinfo_uname.go index 32b733a03bcb3..05c028c04f80e 100644 --- a/hostinfo/hostinfo_uname.go +++ b/hostinfo/hostinfo_uname.go @@ -8,8 +8,8 @@ package hostinfo import ( "runtime" + "github.com/sagernet/tailscale/types/ptr" "golang.org/x/sys/unix" - "tailscale.com/types/ptr" ) func init() { diff --git a/hostinfo/hostinfo_windows.go b/hostinfo/hostinfo_windows.go index f0422f5a001c5..a55306d0b6dc7 100644 --- a/hostinfo/hostinfo_windows.go +++ b/hostinfo/hostinfo_windows.go @@ -9,11 +9,11 @@ import ( "path/filepath" "strings" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/util/winutil/winenv" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "tailscale.com/types/ptr" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/winenv" ) func init() { diff --git a/hostinfo/wol.go b/hostinfo/wol.go index 3a30af2fe3a37..6f1e42231b8bc 100644 --- a/hostinfo/wol.go +++ b/hostinfo/wol.go @@ -10,7 +10,7 @@ import ( "strings" "unicode" - "tailscale.com/envknob" + "github.com/sagernet/tailscale/envknob" ) // TODO(bradfitz): this is all too simplistic and static. It needs to run diff --git a/internal/noiseconn/conn.go b/internal/noiseconn/conn.go index 7476b7ecc5a6a..33c6468aabd19 100644 --- a/internal/noiseconn/conn.go +++ b/internal/noiseconn/conn.go @@ -18,9 +18,9 @@ import ( "net/http" "sync" + "github.com/sagernet/tailscale/control/controlbase" + "github.com/sagernet/tailscale/tailcfg" "golang.org/x/net/http2" - "tailscale.com/control/controlbase" - "tailscale.com/tailcfg" ) // Conn is a wrapper around controlbase.Conn. diff --git a/ipn/backend.go b/ipn/backend.go index 91a35df0d0da0..a5b730f83687d 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -8,15 +8,15 @@ import ( "strings" "time" - "tailscale.com/drive" - "tailscale.com/health" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/types/empty" - "tailscale.com/types/key" - "tailscale.com/types/netmap" - "tailscale.com/types/structs" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/empty" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/structs" + "github.com/sagernet/tailscale/types/views" ) type State int diff --git a/ipn/conf.go b/ipn/conf.go index 1b2831b03b6c6..f24855d20dd3a 100644 --- a/ipn/conf.go +++ b/ipn/conf.go @@ -6,9 +6,9 @@ package ipn import ( "net/netip" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/preftype" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/preftype" ) // ConfigVAlpha is the config file format for the "alpha0" version. diff --git a/ipn/conffile/cloudconf.go b/ipn/conffile/cloudconf.go index 650611cf161fc..dec53d644fca4 100644 --- a/ipn/conffile/cloudconf.go +++ b/ipn/conffile/cloudconf.go @@ -10,7 +10,7 @@ import ( "net/http" "strings" - "tailscale.com/omit" + "github.com/sagernet/tailscale/omit" ) func getEC2MetadataToken() (string, error) { diff --git a/ipn/conffile/conffile.go b/ipn/conffile/conffile.go index a2bafb8b7fd22..c81352cafcbd1 100644 --- a/ipn/conffile/conffile.go +++ b/ipn/conffile/conffile.go @@ -13,7 +13,7 @@ import ( "os" "runtime" - "tailscale.com/ipn" + "github.com/sagernet/tailscale/ipn" ) // Config describes a config file. diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 0e9698faf4488..0997f16716556 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -9,12 +9,12 @@ import ( "maps" "net/netip" - "tailscale.com/drive" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/preftype" - "tailscale.com/types/ptr" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/preftype" + "github.com/sagernet/tailscale/types/ptr" ) // Clone makes a deep copy of Prefs. diff --git a/ipn/ipn_test.go b/ipn/ipn_test.go deleted file mode 100644 index cba70bccd658a..0000000000000 --- a/ipn/ipn_test.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipn - -import ( - "testing" - - "tailscale.com/tstest/deptest" -) - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - BadDeps: map[string]string{ - "testing": "do not use testing package in production code", - "gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756", - "gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756", - }, - }.Check(t) -} diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 83a7aebb1de43..de603cdb54f0e 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -10,12 +10,12 @@ import ( "errors" "net/netip" - "tailscale.com/drive" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/preftype" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/preftype" + "github.com/sagernet/tailscale/types/views" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig diff --git a/ipn/ipnauth/actor.go b/ipn/ipnauth/actor.go index 1070172688a84..edc8ad566f6f4 100644 --- a/ipn/ipnauth/actor.go +++ b/ipn/ipnauth/actor.go @@ -6,7 +6,7 @@ package ipnauth import ( "fmt" - "tailscale.com/ipn" + "github.com/sagernet/tailscale/ipn" ) // Actor is any actor using the [ipnlocal.LocalBackend]. diff --git a/ipn/ipnauth/ipnauth.go b/ipn/ipnauth/ipnauth.go index e6560570cd755..f16ae6decb062 100644 --- a/ipn/ipnauth/ipnauth.go +++ b/ipn/ipnauth/ipnauth.go @@ -14,15 +14,15 @@ import ( "runtime" "strconv" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/safesocket" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/groupmember" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/version/distro" "github.com/tailscale/peercred" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/safesocket" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/groupmember" - "tailscale.com/util/winutil" - "tailscale.com/version/distro" ) // ErrNotImplemented is returned by ConnIdentity.WindowsToken when it is not diff --git a/ipn/ipnauth/ipnauth_notwindows.go b/ipn/ipnauth/ipnauth_notwindows.go index d9d11bd0a17a1..d183059dfe779 100644 --- a/ipn/ipnauth/ipnauth_notwindows.go +++ b/ipn/ipnauth/ipnauth_notwindows.go @@ -8,8 +8,8 @@ package ipnauth import ( "net" + "github.com/sagernet/tailscale/types/logger" "github.com/tailscale/peercred" - "tailscale.com/types/logger" ) // GetConnIdentity extracts the identity information from the connection diff --git a/ipn/ipnauth/ipnauth_windows.go b/ipn/ipnauth/ipnauth_windows.go index 9abd04cd19408..acc9fbe2d6c8e 100644 --- a/ipn/ipnauth/ipnauth_windows.go +++ b/ipn/ipnauth/ipnauth_windows.go @@ -9,11 +9,11 @@ import ( "runtime" "unsafe" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/safesocket" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/winutil" "golang.org/x/sys/windows" - "tailscale.com/ipn" - "tailscale.com/safesocket" - "tailscale.com/types/logger" - "tailscale.com/util/winutil" ) // GetConnIdentity extracts the identity information from the connection diff --git a/ipn/ipnauth/test_actor.go b/ipn/ipnauth/test_actor.go index d38aa21968bb2..dc1c8e984dd77 100644 --- a/ipn/ipnauth/test_actor.go +++ b/ipn/ipnauth/test_actor.go @@ -4,7 +4,7 @@ package ipnauth import ( - "tailscale.com/ipn" + "github.com/sagernet/tailscale/ipn" ) var _ Actor = (*TestActor)(nil) diff --git a/ipn/ipnlocal/autoupdate.go b/ipn/ipnlocal/autoupdate.go index b7d217a10b5b0..7965439ca6e1e 100644 --- a/ipn/ipnlocal/autoupdate.go +++ b/ipn/ipnlocal/autoupdate.go @@ -9,9 +9,9 @@ import ( "context" "time" - "tailscale.com/clientupdate" - "tailscale.com/ipn" - "tailscale.com/version" + "github.com/sagernet/tailscale/clientupdate" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/version" ) func (b *LocalBackend) stopOfflineAutoUpdate() { diff --git a/ipn/ipnlocal/autoupdate_disabled.go b/ipn/ipnlocal/autoupdate_disabled.go index 88ed68c95fd48..a2228a0fafbb3 100644 --- a/ipn/ipnlocal/autoupdate_disabled.go +++ b/ipn/ipnlocal/autoupdate_disabled.go @@ -6,7 +6,7 @@ package ipnlocal import ( - "tailscale.com/ipn" + "github.com/sagernet/tailscale/ipn" ) func (b *LocalBackend) stopOfflineAutoUpdate() { diff --git a/ipn/ipnlocal/bus.go b/ipn/ipnlocal/bus.go index 111a877d849d8..8443a800371f0 100644 --- a/ipn/ipnlocal/bus.go +++ b/ipn/ipnlocal/bus.go @@ -7,8 +7,8 @@ import ( "context" "time" - "tailscale.com/ipn" - "tailscale.com/tstime" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/tstime" ) type rateLimitingBusSender struct { diff --git a/ipn/ipnlocal/bus_test.go b/ipn/ipnlocal/bus_test.go deleted file mode 100644 index 5c75ac54d688d..0000000000000 --- a/ipn/ipnlocal/bus_test.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "context" - "reflect" - "slices" - "testing" - "time" - - "tailscale.com/drive" - "tailscale.com/ipn" - "tailscale.com/tstest" - "tailscale.com/tstime" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/views" -) - -func TestIsNotableNotify(t *testing.T) { - tests := []struct { - name string - notify *ipn.Notify - want bool - }{ - {"nil", nil, false}, - {"empty", &ipn.Notify{}, false}, - {"version", &ipn.Notify{Version: "foo"}, false}, - {"netmap", &ipn.Notify{NetMap: new(netmap.NetworkMap)}, false}, - {"engine", &ipn.Notify{Engine: new(ipn.EngineStatus)}, false}, - } - - // Then for all other fields, assume they're notable. - // We use reflect to catch fields that might be added in the future without - // remembering to update the [isNotableNotify] function. - rt := reflect.TypeFor[ipn.Notify]() - for i := range rt.NumField() { - n := &ipn.Notify{} - sf := rt.Field(i) - switch sf.Name { - case "_", "NetMap", "Engine", "Version": - // Already covered above or not applicable. - continue - case "DriveShares": - n.DriveShares = views.SliceOfViews[*drive.Share, drive.ShareView](make([]*drive.Share, 1)) - default: - rf := reflect.ValueOf(n).Elem().Field(i) - switch rf.Kind() { - case reflect.Pointer: - rf.Set(reflect.New(rf.Type().Elem())) - case reflect.String: - rf.SetString("foo") - case reflect.Slice: - rf.Set(reflect.MakeSlice(rf.Type(), 1, 1)) - default: - t.Errorf("unhandled field kind %v for %q", rf.Kind(), sf.Name) - } - } - - tests = append(tests, struct { - name string - notify *ipn.Notify - want bool - }{ - name: "field-" + rt.Field(i).Name, - notify: n, - want: true, - }) - } - - for _, tt := range tests { - if got := isNotableNotify(tt.notify); got != tt.want { - t.Errorf("%v: got %v; want %v", tt.name, got, tt.want) - } - } -} - -type rateLimitingBusSenderTester struct { - tb testing.TB - got []*ipn.Notify - clock *tstest.Clock - s *rateLimitingBusSender -} - -func (st *rateLimitingBusSenderTester) init() { - if st.s != nil { - return - } - st.clock = tstest.NewClock(tstest.ClockOpts{ - Start: time.Unix(1731777537, 0), // time I wrote this test :) - }) - st.s = &rateLimitingBusSender{ - clock: tstime.DefaultClock{Clock: st.clock}, - fn: func(n *ipn.Notify) bool { - st.got = append(st.got, n) - return true - }, - } -} - -func (st *rateLimitingBusSenderTester) send(n *ipn.Notify) { - st.tb.Helper() - st.init() - if !st.s.send(n) { - st.tb.Fatal("unexpected send failed") - } -} - -func (st *rateLimitingBusSenderTester) advance(d time.Duration) { - st.tb.Helper() - st.clock.Advance(d) - select { - case <-st.s.flushChan(): - if !st.s.flush() { - st.tb.Fatal("unexpected flush failed") - } - default: - } -} - -func TestRateLimitingBusSender(t *testing.T) { - nm1 := &ipn.Notify{NetMap: new(netmap.NetworkMap)} - nm2 := &ipn.Notify{NetMap: new(netmap.NetworkMap)} - eng1 := &ipn.Notify{Engine: new(ipn.EngineStatus)} - eng2 := &ipn.Notify{Engine: new(ipn.EngineStatus)} - - t.Run("unbuffered", func(t *testing.T) { - st := &rateLimitingBusSenderTester{tb: t} - st.send(nm1) - st.send(nm2) - st.send(eng1) - st.send(eng2) - if !slices.Equal(st.got, []*ipn.Notify{nm1, nm2, eng1, eng2}) { - t.Errorf("got %d items; want 4 specific ones, unmodified", len(st.got)) - } - }) - - t.Run("buffered", func(t *testing.T) { - st := &rateLimitingBusSenderTester{tb: t} - st.init() - st.s.interval = 1 * time.Second - st.send(&ipn.Notify{Version: "initial"}) - if len(st.got) != 1 { - t.Fatalf("got %d items; expected 1 (first to flush immediately)", len(st.got)) - } - st.send(nm1) - st.send(nm2) - st.send(eng1) - st.send(eng2) - if len(st.got) != 1 { - if len(st.got) != 1 { - t.Fatalf("got %d items; expected still just that first 1", len(st.got)) - } - } - - // But moving the clock should flush the rest, collasced into one new one. - st.advance(5 * time.Second) - if len(st.got) != 2 { - t.Fatalf("got %d items; want 2", len(st.got)) - } - gotn := st.got[1] - if gotn.NetMap != nm2.NetMap { - t.Errorf("got wrong NetMap; got %p", gotn.NetMap) - } - if gotn.Engine != eng2.Engine { - t.Errorf("got wrong Engine; got %p", gotn.Engine) - } - if t.Failed() { - t.Logf("failed Notify was: %v", logger.AsJSON(gotn)) - } - }) - - // Test the Run method - t.Run("run", func(t *testing.T) { - st := &rateLimitingBusSenderTester{tb: t} - st.init() - st.s.interval = 1 * time.Second - st.s.lastFlush = st.clock.Now() // pretend we just flushed - - flushc := make(chan *ipn.Notify, 1) - st.s.fn = func(n *ipn.Notify) bool { - flushc <- n - return true - } - didSend := make(chan bool, 2) - st.s.didSendTestHook = func() { didSend <- true } - waitSend := func() { - select { - case <-didSend: - case <-time.After(5 * time.Second): - t.Error("timeout waiting for call to send") - } - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - incoming := make(chan *ipn.Notify, 2) - go func() { - incoming <- nm1 - waitSend() - incoming <- nm2 - waitSend() - st.advance(5 * time.Second) - select { - case n := <-flushc: - if n.NetMap != nm2.NetMap { - t.Errorf("got wrong NetMap; got %p", n.NetMap) - } - case <-time.After(10 * time.Second): - t.Error("timeout") - } - cancel() - }() - - st.s.Run(ctx, incoming) - }) -} diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index f3a4a3a3d2b29..591529f24b507 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -23,18 +23,18 @@ import ( "time" "github.com/kortschak/wol" - "tailscale.com/clientupdate" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/net/sockstats" - "tailscale.com/posture" - "tailscale.com/tailcfg" - "tailscale.com/util/clientmetric" - "tailscale.com/util/goroutines" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy" - "tailscale.com/version" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/clientupdate" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/posture" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/goroutines" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/syspolicy" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" ) // c2nHandlers maps an HTTP method and URI path (without query parameters) to diff --git a/ipn/ipnlocal/c2n_test.go b/ipn/ipnlocal/c2n_test.go deleted file mode 100644 index cc31e284af8a1..0000000000000 --- a/ipn/ipnlocal/c2n_test.go +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "cmp" - "crypto/x509" - "encoding/json" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "reflect" - "testing" - "time" - - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/types/logger" - "tailscale.com/util/must" -) - -func TestHandleC2NTLSCertStatus(t *testing.T) { - b := &LocalBackend{ - store: &mem.Store{}, - varRoot: t.TempDir(), - } - certDir, err := b.certDir() - if err != nil { - t.Fatalf("certDir error: %v", err) - } - if _, err := b.getCertStore(); err != nil { - t.Fatalf("getCertStore error: %v", err) - } - - testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem") - if err != nil { - t.Fatal(err) - } - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(testRoot) { - t.Fatal("Unable to add test CA to the cert pool") - } - testX509Roots = roots - defer func() { testX509Roots = nil }() - - tests := []struct { - name string - domain string - copyFile bool // copy testdata/example.com.pem to the certDir - wantStatus int // 0 means 200 - wantError string // wanted non-JSON non-200 error - now time.Time - want *tailcfg.C2NTLSCertInfo - }{ - { - name: "no domain", - wantStatus: 400, - wantError: "no 'domain'\n", - }, - { - name: "missing", - domain: "example.com", - want: &tailcfg.C2NTLSCertInfo{ - Error: "no certificate", - Missing: true, - }, - }, - { - name: "valid", - domain: "example.com", - now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC), - copyFile: true, - want: &tailcfg.C2NTLSCertInfo{ - Valid: true, - NotBefore: "2023-02-07T20:34:18Z", - NotAfter: "2025-05-07T19:34:18Z", - }, - }, - { - name: "expired", - domain: "example.com", - now: time.Date(2030, time.February, 20, 0, 0, 0, 0, time.UTC), - copyFile: true, - want: &tailcfg.C2NTLSCertInfo{ - Error: "cert expired", - Expired: true, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - os.RemoveAll(certDir) // reset per test - if tt.copyFile { - os.MkdirAll(certDir, 0755) - if err := os.WriteFile(filepath.Join(certDir, "example.com.crt"), - must.Get(os.ReadFile("testdata/example.com.pem")), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(certDir, "example.com.key"), - must.Get(os.ReadFile("testdata/example.com-key.pem")), 0644); err != nil { - t.Fatal(err) - } - } - b.clock = tstest.NewClock(tstest.ClockOpts{ - Start: tt.now, - }) - - rec := httptest.NewRecorder() - handleC2NTLSCertStatus(b, rec, httptest.NewRequest("GET", "/tls-cert-status?domain="+url.QueryEscape(tt.domain), nil)) - res := rec.Result() - wantStatus := cmp.Or(tt.wantStatus, 200) - if res.StatusCode != wantStatus { - t.Fatalf("status code = %v; want %v. Body: %s", res.Status, wantStatus, rec.Body.Bytes()) - } - if wantStatus == 200 { - var got tailcfg.C2NTLSCertInfo - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("bad JSON: %v", err) - } - if !reflect.DeepEqual(&got, tt.want) { - t.Errorf("got %v; want %v", logger.AsJSON(got), logger.AsJSON(tt.want)) - } - } else if tt.wantError != "" { - if got := rec.Body.String(); got != tt.wantError { - t.Errorf("body = %q; want %q", got, tt.wantError) - } - } - }) - } - -} diff --git a/ipn/ipnlocal/cert.go b/ipn/ipnlocal/cert.go index d87374bbbcd61..96f46a7c57dd2 100644 --- a/ipn/ipnlocal/cert.go +++ b/ipn/ipnlocal/cert.go @@ -32,18 +32,18 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/ipn/store" + "github.com/sagernet/tailscale/ipn/store/mem" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" "github.com/tailscale/golang-x-crypto/acme" - "tailscale.com/atomicfile" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/ipn/store" - "tailscale.com/ipn/store/mem" - "tailscale.com/types/logger" - "tailscale.com/util/testenv" - "tailscale.com/version" - "tailscale.com/version/distro" ) // Process-wide cache. (A new *Handler is created per connection, diff --git a/ipn/ipnlocal/cert_test.go b/ipn/ipnlocal/cert_test.go deleted file mode 100644 index 3ae7870e3174f..0000000000000 --- a/ipn/ipnlocal/cert_test.go +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !ios && !android && !js - -package ipnlocal - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "embed" - "encoding/pem" - "math/big" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "tailscale.com/ipn/store/mem" -) - -func TestValidLookingCertDomain(t *testing.T) { - tests := []struct { - in string - want bool - }{ - {"foo.com", true}, - {"foo..com", false}, - {"foo/com.com", false}, - {"NUL", false}, - {"", false}, - {"foo\\bar.com", false}, - {"foo\x00bar.com", false}, - } - for _, tt := range tests { - if got := validLookingCertDomain(tt.in); got != tt.want { - t.Errorf("validLookingCertDomain(%q) = %v, want %v", tt.in, got, tt.want) - } - } -} - -//go:embed testdata/* -var certTestFS embed.FS - -func TestCertStoreRoundTrip(t *testing.T) { - const testDomain = "example.com" - - // Use a fixed verification timestamp so validity doesn't fall off when the - // cert expires. If you update the test data below, this may also need to be - // updated. - testNow := time.Date(2023, time.February, 10, 0, 0, 0, 0, time.UTC) - - // To re-generate a root certificate and domain certificate for testing, - // use: - // - // go run filippo.io/mkcert@latest example.com - // - // The content is not important except to be structurally valid so we can be - // sure the round-trip succeeds. - testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem") - if err != nil { - t.Fatal(err) - } - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(testRoot) { - t.Fatal("Unable to add test CA to the cert pool") - } - - testCert, err := certTestFS.ReadFile("testdata/example.com.pem") - if err != nil { - t.Fatal(err) - } - testKey, err := certTestFS.ReadFile("testdata/example.com-key.pem") - if err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - store certStore - }{ - {"FileStore", certFileStore{dir: t.TempDir(), testRoots: roots}}, - {"StateStore", certStateStore{StateStore: new(mem.Store), testRoots: roots}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if err := test.store.WriteCert(testDomain, testCert); err != nil { - t.Fatalf("WriteCert: unexpected error: %v", err) - } - if err := test.store.WriteKey(testDomain, testKey); err != nil { - t.Fatalf("WriteKey: unexpected error: %v", err) - } - - kp, err := test.store.Read(testDomain, testNow) - if err != nil { - t.Fatalf("Read: unexpected error: %v", err) - } - if diff := cmp.Diff(kp.CertPEM, testCert); diff != "" { - t.Errorf("Certificate (-got, +want):\n%s", diff) - } - if diff := cmp.Diff(kp.KeyPEM, testKey); diff != "" { - t.Errorf("Key (-got, +want):\n%s", diff) - } - }) - } -} - -func TestShouldStartDomainRenewal(t *testing.T) { - reset := func() { - renewMu.Lock() - defer renewMu.Unlock() - clear(renewCertAt) - } - - mustMakePair := func(template *x509.Certificate) *TLSCertKeyPair { - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - panic(err) - } - - b, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) - if err != nil { - panic(err) - } - certPEM := pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: b, - }) - - return &TLSCertKeyPair{ - Cached: false, - CertPEM: certPEM, - KeyPEM: []byte("unused"), - } - } - - now := time.Unix(1685714838, 0) - subject := pkix.Name{ - Organization: []string{"Tailscale, Inc."}, - Country: []string{"CA"}, - Province: []string{"ON"}, - Locality: []string{"Toronto"}, - StreetAddress: []string{"290 Bremner Blvd"}, - PostalCode: []string{"M5V 3L9"}, - } - - testCases := []struct { - name string - notBefore time.Time - lifetime time.Duration - want bool - wantErr string - }{ - { - name: "should renew", - notBefore: now.AddDate(0, 0, -89), - lifetime: 90 * 24 * time.Hour, - want: true, - }, - { - name: "short-lived renewal", - notBefore: now.AddDate(0, 0, -7), - lifetime: 10 * 24 * time.Hour, - want: true, - }, - { - name: "no renew", - notBefore: now.AddDate(0, 0, -59), // 59 days ago == not 2/3rds of the way through 90 days yet - lifetime: 90 * 24 * time.Hour, - want: false, - }, - } - b := new(LocalBackend) - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - reset() - - ret, err := b.domainRenewalTimeByExpiry(mustMakePair(&x509.Certificate{ - SerialNumber: big.NewInt(2019), - Subject: subject, - NotBefore: tt.notBefore, - NotAfter: tt.notBefore.Add(tt.lifetime), - })) - - if tt.wantErr != "" { - if err == nil { - t.Errorf("wanted error, got nil") - } else if err.Error() != tt.wantErr { - t.Errorf("got err=%q, want %q", err.Error(), tt.wantErr) - } - } else { - renew := now.After(ret) - if renew != tt.want { - t.Errorf("got renew=%v (ret=%v), want renew %v", renew, ret, tt.want) - } - } - }) - } -} diff --git a/ipn/ipnlocal/dnsconfig_test.go b/ipn/ipnlocal/dnsconfig_test.go deleted file mode 100644 index 19d8e8b86b5ee..0000000000000 --- a/ipn/ipnlocal/dnsconfig_test.go +++ /dev/null @@ -1,422 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "cmp" - "encoding/json" - "net/netip" - "reflect" - "testing" - - "tailscale.com/ipn" - "tailscale.com/net/dns" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/types/dnstype" - "tailscale.com/types/netmap" - "tailscale.com/util/cloudenv" - "tailscale.com/util/dnsname" -) - -func ipps(ippStrs ...string) (ipps []netip.Prefix) { - for _, s := range ippStrs { - if ip, err := netip.ParseAddr(s); err == nil { - ipps = append(ipps, netip.PrefixFrom(ip, ip.BitLen())) - continue - } - ipps = append(ipps, netip.MustParsePrefix(s)) - } - return -} - -func ips(ss ...string) (ips []netip.Addr) { - for _, s := range ss { - ips = append(ips, netip.MustParseAddr(s)) - } - return -} - -func nodeViews(v []*tailcfg.Node) []tailcfg.NodeView { - nv := make([]tailcfg.NodeView, len(v)) - for i, n := range v { - nv[i] = n.View() - } - return nv -} - -func TestDNSConfigForNetmap(t *testing.T) { - tests := []struct { - name string - nm *netmap.NetworkMap - expired bool - peers []tailcfg.NodeView - os string // version.OS value; empty means linux - cloud cloudenv.Cloud - prefs *ipn.Prefs - want *dns.Config - wantLog string - }{ - { - name: "empty", - nm: &netmap.NetworkMap{}, - prefs: &ipn.Prefs{}, - want: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: map[dnsname.FQDN][]netip.Addr{}, - }, - }, - { - name: "self_name_and_peers", - nm: &netmap.NetworkMap{ - Name: "myname.net", - SelfNode: (&tailcfg.Node{ - Addresses: ipps("100.101.101.101"), - }).View(), - }, - peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Name: "peera.net", - Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"), - }, - { - ID: 2, - Name: "b.net", - Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"), - }, - { - ID: 3, - Name: "v6-only.net", - Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6 - }, - }), - prefs: &ipn.Prefs{}, - want: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: map[dnsname.FQDN][]netip.Addr{ - "b.net.": ips("100.102.0.1", "100.102.0.2"), - "myname.net.": ips("100.101.101.101"), - "peera.net.": ips("100.102.0.1", "100.102.0.2"), - "v6-only.net.": ips("fe75::3"), - }, - }, - }, - { - // An ephemeral node with only an IPv6 address - // should get IPv6 records for all its peers, - // even if they have IPv4. - name: "v6_only_self", - nm: &netmap.NetworkMap{ - Name: "myname.net", - SelfNode: (&tailcfg.Node{ - Addresses: ipps("fe75::1"), - }).View(), - }, - peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Name: "peera.net", - Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001"), - }, - { - ID: 2, - Name: "b.net", - Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::2"), - }, - { - ID: 3, - Name: "v6-only.net", - Addresses: ipps("fe75::3"), // no IPv4, so we don't ignore IPv6 - }, - }), - prefs: &ipn.Prefs{}, - want: &dns.Config{ - OnlyIPv6: true, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: map[dnsname.FQDN][]netip.Addr{ - "b.net.": ips("fe75::2"), - "myname.net.": ips("fe75::1"), - "peera.net.": ips("fe75::1001"), - "v6-only.net.": ips("fe75::3"), - }, - }, - }, - { - name: "extra_records", - nm: &netmap.NetworkMap{ - Name: "myname.net", - SelfNode: (&tailcfg.Node{ - Addresses: ipps("100.101.101.101"), - }).View(), - DNS: tailcfg.DNSConfig{ - ExtraRecords: []tailcfg.DNSRecord{ - {Name: "foo.com", Value: "1.2.3.4"}, - {Name: "bar.com", Value: "1::6"}, - {Name: "sdlfkjsdklfj", Type: "IGNORE"}, - }, - }, - }, - prefs: &ipn.Prefs{}, - want: &dns.Config{ - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - Hosts: map[dnsname.FQDN][]netip.Addr{ - "myname.net.": ips("100.101.101.101"), - "foo.com.": ips("1.2.3.4"), - "bar.com.": ips("1::6"), - }, - }, - }, - { - name: "corp_dns_misc", - nm: &netmap.NetworkMap{ - Name: "host.some.domain.net.", - DNS: tailcfg.DNSConfig{ - Proxied: true, - Domains: []string{"foo.com", "bar.com"}, - }, - }, - prefs: &ipn.Prefs{ - CorpDNS: true, - }, - want: &dns.Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{}, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{ - "0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa.": nil, - "100.100.in-addr.arpa.": nil, - "101.100.in-addr.arpa.": nil, - "102.100.in-addr.arpa.": nil, - "103.100.in-addr.arpa.": nil, - "104.100.in-addr.arpa.": nil, - "105.100.in-addr.arpa.": nil, - "106.100.in-addr.arpa.": nil, - "107.100.in-addr.arpa.": nil, - "108.100.in-addr.arpa.": nil, - "109.100.in-addr.arpa.": nil, - "110.100.in-addr.arpa.": nil, - "111.100.in-addr.arpa.": nil, - "112.100.in-addr.arpa.": nil, - "113.100.in-addr.arpa.": nil, - "114.100.in-addr.arpa.": nil, - "115.100.in-addr.arpa.": nil, - "116.100.in-addr.arpa.": nil, - "117.100.in-addr.arpa.": nil, - "118.100.in-addr.arpa.": nil, - "119.100.in-addr.arpa.": nil, - "120.100.in-addr.arpa.": nil, - "121.100.in-addr.arpa.": nil, - "122.100.in-addr.arpa.": nil, - "123.100.in-addr.arpa.": nil, - "124.100.in-addr.arpa.": nil, - "125.100.in-addr.arpa.": nil, - "126.100.in-addr.arpa.": nil, - "127.100.in-addr.arpa.": nil, - "64.100.in-addr.arpa.": nil, - "65.100.in-addr.arpa.": nil, - "66.100.in-addr.arpa.": nil, - "67.100.in-addr.arpa.": nil, - "68.100.in-addr.arpa.": nil, - "69.100.in-addr.arpa.": nil, - "70.100.in-addr.arpa.": nil, - "71.100.in-addr.arpa.": nil, - "72.100.in-addr.arpa.": nil, - "73.100.in-addr.arpa.": nil, - "74.100.in-addr.arpa.": nil, - "75.100.in-addr.arpa.": nil, - "76.100.in-addr.arpa.": nil, - "77.100.in-addr.arpa.": nil, - "78.100.in-addr.arpa.": nil, - "79.100.in-addr.arpa.": nil, - "80.100.in-addr.arpa.": nil, - "81.100.in-addr.arpa.": nil, - "82.100.in-addr.arpa.": nil, - "83.100.in-addr.arpa.": nil, - "84.100.in-addr.arpa.": nil, - "85.100.in-addr.arpa.": nil, - "86.100.in-addr.arpa.": nil, - "87.100.in-addr.arpa.": nil, - "88.100.in-addr.arpa.": nil, - "89.100.in-addr.arpa.": nil, - "90.100.in-addr.arpa.": nil, - "91.100.in-addr.arpa.": nil, - "92.100.in-addr.arpa.": nil, - "93.100.in-addr.arpa.": nil, - "94.100.in-addr.arpa.": nil, - "95.100.in-addr.arpa.": nil, - "96.100.in-addr.arpa.": nil, - "97.100.in-addr.arpa.": nil, - "98.100.in-addr.arpa.": nil, - "99.100.in-addr.arpa.": nil, - "some.domain.net.": nil, - }, - SearchDomains: []dnsname.FQDN{ - "foo.com.", - "bar.com.", - }, - }, - }, - { - // Prior to fixing https://github.com/tailscale/tailscale/issues/2116, - // Android had cases where it needed FallbackResolvers. This was the - // negative test for the case where Override-local-DNS was set, so the - // fallback resolvers did not need to be used. This test is still valid - // so we keep it, but the fallback test has been removed. - name: "android_does_NOT_need_fallbacks", - os: "android", - nm: &netmap.NetworkMap{ - DNS: tailcfg.DNSConfig{ - Resolvers: []*dnstype.Resolver{ - {Addr: "8.8.8.8"}, - }, - FallbackResolvers: []*dnstype.Resolver{ - {Addr: "8.8.4.4"}, - }, - Routes: map[string][]*dnstype.Resolver{ - "foo.com.": {{Addr: "1.2.3.4"}}, - }, - }, - }, - prefs: &ipn.Prefs{ - CorpDNS: true, - }, - want: &dns.Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{}, - DefaultResolvers: []*dnstype.Resolver{ - {Addr: "8.8.8.8"}, - }, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{ - "foo.com.": {{Addr: "1.2.3.4"}}, - }, - }, - }, - { - name: "exit_nodes_need_fallbacks", - nm: &netmap.NetworkMap{ - DNS: tailcfg.DNSConfig{ - FallbackResolvers: []*dnstype.Resolver{ - {Addr: "8.8.4.4"}, - }, - }, - }, - prefs: &ipn.Prefs{ - CorpDNS: true, - ExitNodeID: "some-id", - }, - want: &dns.Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{}, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - DefaultResolvers: []*dnstype.Resolver{ - {Addr: "8.8.4.4"}, - }, - }, - }, - { - name: "not_exit_node_NOT_need_fallbacks", - nm: &netmap.NetworkMap{ - DNS: tailcfg.DNSConfig{ - FallbackResolvers: []*dnstype.Resolver{ - {Addr: "8.8.4.4"}, - }, - }, - }, - prefs: &ipn.Prefs{ - CorpDNS: true, - }, - want: &dns.Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{}, - Routes: map[dnsname.FQDN][]*dnstype.Resolver{}, - }, - }, - { - name: "self_expired", - nm: &netmap.NetworkMap{ - Name: "myname.net", - SelfNode: (&tailcfg.Node{ - Addresses: ipps("100.101.101.101"), - }).View(), - }, - expired: true, - peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Name: "peera.net", - Addresses: ipps("100.102.0.1", "100.102.0.2", "fe75::1001", "fe75::1002"), - }, - }), - prefs: &ipn.Prefs{}, - want: &dns.Config{}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verOS := cmp.Or(tt.os, "linux") - var log tstest.MemLogger - got := dnsConfigForNetmap(tt.nm, peersMap(tt.peers), tt.prefs.View(), tt.expired, log.Logf, verOS) - if !reflect.DeepEqual(got, tt.want) { - gotj, _ := json.MarshalIndent(got, "", "\t") - wantj, _ := json.MarshalIndent(tt.want, "", "\t") - t.Errorf("wrong\n got: %s\n\nwant: %s\n", gotj, wantj) - } - if got := log.String(); got != tt.wantLog { - t.Errorf("log output wrong\n got: %q\nwant: %q\n", got, tt.wantLog) - } - }) - } -} - -func peersMap(s []tailcfg.NodeView) map[tailcfg.NodeID]tailcfg.NodeView { - m := make(map[tailcfg.NodeID]tailcfg.NodeView) - for _, n := range s { - if n.ID() == 0 { - panic("zero Node.ID") - } - m[n.ID()] = n - } - return m -} - -func TestAllowExitNodeDNSProxyToServeName(t *testing.T) { - b := &LocalBackend{} - if b.allowExitNodeDNSProxyToServeName("google.com") { - t.Fatal("unexpected true on backend with nil NetMap") - } - - b.netMap = &netmap.NetworkMap{ - DNS: tailcfg.DNSConfig{ - ExitNodeFilteredSet: []string{ - ".ts.net", - "some.exact.bad", - }, - }, - } - tests := []struct { - name string - want bool - }{ - // Allow by default: - {"google.com", true}, - {"GOOGLE.com", true}, - - // Rejected by suffix: - {"foo.TS.NET", false}, - {"foo.ts.net", false}, - - // Suffix doesn't match - {"ts.net", true}, - - // Rejected by exact match: - {"some.exact.bad", false}, - {"SOME.EXACT.BAD", false}, - - // But a prefix is okay. - {"prefix-okay.some.exact.bad", true}, - } - for _, tt := range tests { - got := b.allowExitNodeDNSProxyToServeName(tt.name) - if got != tt.want { - t.Errorf("for %q = %v; want %v", tt.name, got, tt.want) - } - } - -} diff --git a/ipn/ipnlocal/drive.go b/ipn/ipnlocal/drive.go index fe3622ba40e3e..47856334904f5 100644 --- a/ipn/ipnlocal/drive.go +++ b/ipn/ipnlocal/drive.go @@ -9,11 +9,11 @@ import ( "os" "slices" - "tailscale.com/drive" - "tailscale.com/ipn" - "tailscale.com/tailcfg" - "tailscale.com/types/netmap" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/views" ) const ( diff --git a/ipn/ipnlocal/expiry.go b/ipn/ipnlocal/expiry.go index 04c10226d50a0..e5154cb725c3c 100644 --- a/ipn/ipnlocal/expiry.go +++ b/ipn/ipnlocal/expiry.go @@ -6,12 +6,12 @@ package ipnlocal import ( "time" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" ) // For extra defense-in-depth, when we're testing expired nodes we check diff --git a/ipn/ipnlocal/expiry_test.go b/ipn/ipnlocal/expiry_test.go deleted file mode 100644 index af1aa337bbe0c..0000000000000 --- a/ipn/ipnlocal/expiry_test.go +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "fmt" - "reflect" - "strings" - "testing" - "time" - - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/types/key" - "tailscale.com/types/netmap" -) - -func TestFlagExpiredPeers(t *testing.T) { - n := func(id tailcfg.NodeID, name string, expiry time.Time, mod ...func(*tailcfg.Node)) *tailcfg.Node { - n := &tailcfg.Node{ID: id, Name: name, KeyExpiry: expiry} - for _, f := range mod { - f(n) - } - return n - } - - now := time.Unix(1673373129, 0) - - timeInPast := now.Add(-1 * time.Hour) - timeInFuture := now.Add(1 * time.Hour) - - timeBeforeEpoch := flagExpiredPeersEpoch.Add(-1 * time.Second) - if now.Before(timeBeforeEpoch) { - panic("current time in test cannot be before epoch") - } - - var expiredKey key.NodePublic - if err := expiredKey.UnmarshalText([]byte("nodekey:6da774d5d7740000000000000000000000000000000000000000000000000000")); err != nil { - panic(err) - } - - tests := []struct { - name string - controlTime *time.Time - netmap *netmap.NetworkMap - want []tailcfg.NodeView - }{ - { - name: "no_expiry", - controlTime: &now, - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", timeInFuture), - }), - }, - want: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", timeInFuture), - }), - }, - { - name: "expiry", - controlTime: &now, - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", timeInPast), - }), - }, - want: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", timeInPast, func(n *tailcfg.Node) { - n.Expired = true - n.Key = expiredKey - }), - }), - }, - { - name: "bad_ControlTime", - // controlTime here is intentionally before our hardcoded epoch - controlTime: &timeBeforeEpoch, - - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", timeBeforeEpoch.Add(-1*time.Hour)), // before ControlTime - }), - }, - want: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", timeBeforeEpoch.Add(-1*time.Hour)), // should have expired, but ControlTime is before epoch - }), - }, - { - name: "tagged_node", - controlTime: &now, - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", time.Time{}), // tagged node; zero expiry - }), - }, - want: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", time.Time{}), // not expired - }), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - em := newExpiryManager(t.Logf) - em.clock = tstest.NewClock(tstest.ClockOpts{Start: now}) - if tt.controlTime != nil { - em.onControlTime(*tt.controlTime) - } - em.flagExpiredPeers(tt.netmap, now) - if !reflect.DeepEqual(tt.netmap.Peers, tt.want) { - t.Errorf("wrong results\n got: %s\nwant: %s", formatNodes(tt.netmap.Peers), formatNodes(tt.want)) - } - }) - } -} - -func TestNextPeerExpiry(t *testing.T) { - n := func(id tailcfg.NodeID, name string, expiry time.Time, mod ...func(*tailcfg.Node)) *tailcfg.Node { - n := &tailcfg.Node{ID: id, Name: name, KeyExpiry: expiry} - for _, f := range mod { - f(n) - } - return n - } - - now := time.Unix(1675725516, 0) - - noExpiry := time.Time{} - timeInPast := now.Add(-1 * time.Hour) - timeInFuture := now.Add(1 * time.Hour) - timeInMoreFuture := now.Add(2 * time.Hour) - - tests := []struct { - name string - netmap *netmap.NetworkMap - want time.Time - }{ - { - name: "no_expiry", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", noExpiry), - n(2, "bar", noExpiry), - }), - SelfNode: n(3, "self", noExpiry).View(), - }, - want: noExpiry, - }, - { - name: "future_expiry_from_peer", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", noExpiry), - n(2, "bar", timeInFuture), - }), - SelfNode: n(3, "self", noExpiry).View(), - }, - want: timeInFuture, - }, - { - name: "future_expiry_from_self", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", noExpiry), - n(2, "bar", noExpiry), - }), - SelfNode: n(3, "self", timeInFuture).View(), - }, - want: timeInFuture, - }, - { - name: "future_expiry_from_multiple_peers", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - n(2, "bar", timeInMoreFuture), - }), - SelfNode: n(3, "self", noExpiry).View(), - }, - want: timeInFuture, - }, - { - name: "future_expiry_from_peer_and_self", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInMoreFuture), - }), - SelfNode: n(2, "self", timeInFuture).View(), - }, - want: timeInFuture, - }, - { - name: "only_self", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{}), - SelfNode: n(1, "self", timeInFuture).View(), - }, - want: timeInFuture, - }, - { - name: "peer_already_expired", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInPast), - }), - SelfNode: n(2, "self", timeInFuture).View(), - }, - want: timeInFuture, - }, - { - name: "self_already_expired", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInFuture), - }), - SelfNode: n(2, "self", timeInPast).View(), - }, - want: timeInFuture, - }, - { - name: "all_nodes_already_expired", - netmap: &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInPast), - }), - SelfNode: n(2, "self", timeInPast).View(), - }, - want: noExpiry, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - em := newExpiryManager(t.Logf) - em.clock = tstest.NewClock(tstest.ClockOpts{Start: now}) - got := em.nextPeerExpiry(tt.netmap, now) - if !got.Equal(tt.want) { - t.Errorf("got %q, want %q", got.Format(time.RFC3339), tt.want.Format(time.RFC3339)) - } else if !got.IsZero() && got.Before(now) { - t.Errorf("unexpectedly got expiry %q before now %q", got.Format(time.RFC3339), now.Format(time.RFC3339)) - } - }) - } - - t.Run("ClockSkew", func(t *testing.T) { - t.Logf("local time: %q", now.Format(time.RFC3339)) - em := newExpiryManager(t.Logf) - em.clock = tstest.NewClock(tstest.ClockOpts{Start: now}) - - // The local clock is "running fast"; our clock skew is -2h - em.clockDelta.Store(-2 * time.Hour) - t.Logf("'real' time: %q", now.Add(-2*time.Hour).Format(time.RFC3339)) - - // If we don't adjust for the local time, this would return a - // time in the past. - nm := &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - n(1, "foo", timeInPast), - }), - } - got := em.nextPeerExpiry(nm, now) - want := now.Add(30 * time.Second) - if !got.Equal(want) { - t.Errorf("got %q, want %q", got.Format(time.RFC3339), want.Format(time.RFC3339)) - } - }) -} - -func formatNodes(nodes []tailcfg.NodeView) string { - var sb strings.Builder - for i, n := range nodes { - if i > 0 { - sb.WriteString(", ") - } - fmt.Fprintf(&sb, "(%d, %q", n.ID(), n.Name()) - - if n.Online() != nil { - fmt.Fprintf(&sb, ", online=%v", *n.Online()) - } - if n.LastSeen() != nil { - fmt.Fprintf(&sb, ", lastSeen=%v", n.LastSeen().Unix()) - } - if n.Key() != (key.NodePublic{}) { - fmt.Fprintf(&sb, ", key=%v", n.Key().String()) - } - if n.Expired() { - fmt.Fprintf(&sb, ", expired=true") - } - sb.WriteString(")") - } - return sb.String() -} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index cbbea32aa8363..298d2961e8acf 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -36,89 +36,88 @@ import ( "sync/atomic" "time" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/tailscale/appc" + "github.com/sagernet/tailscale/client/tailscale/apitype" + "github.com/sagernet/tailscale/clientupdate" + "github.com/sagernet/tailscale/control/controlclient" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/doctor" + "github.com/sagernet/tailscale/doctor/ethtool" + "github.com/sagernet/tailscale/doctor/permissions" + "github.com/sagernet/tailscale/doctor/routetable" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/envknob/featureknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/health/healthmsg" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/conffile" + "github.com/sagernet/tailscale/ipn/ipnauth" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/ipn/policy" + "github.com/sagernet/tailscale/log/sockstatlog" + "github.com/sagernet/tailscale/net/captivedetection" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/dnsfallback" + "github.com/sagernet/tailscale/net/ipset" + "github.com/sagernet/tailscale/net/netcheck" + "github.com/sagernet/tailscale/net/netkernelconf" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/paths" + "github.com/sagernet/tailscale/portlist" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/taildrop" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/tsd" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/appctype" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/empty" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/preftype" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/deephash" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/httpm" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/util/osshare" + "github.com/sagernet/tailscale/util/osuser" + "github.com/sagernet/tailscale/util/rands" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/syspolicy" + "github.com/sagernet/tailscale/util/systemd" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/util/uniq" + "github.com/sagernet/tailscale/util/usermetric" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" + "github.com/sagernet/tailscale/wgengine" + "github.com/sagernet/tailscale/wgengine/capture" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/magicsock" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/tailscale/wgengine/wgcfg/nmcfg" "go4.org/mem" "go4.org/netipx" xmaps "golang.org/x/exp/maps" "golang.org/x/net/dns/dnsmessage" - "gvisor.dev/gvisor/pkg/tcpip" - "tailscale.com/appc" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/clientupdate" - "tailscale.com/control/controlclient" - "tailscale.com/control/controlknobs" - "tailscale.com/doctor" - "tailscale.com/doctor/ethtool" - "tailscale.com/doctor/permissions" - "tailscale.com/doctor/routetable" - "tailscale.com/drive" - "tailscale.com/envknob" - "tailscale.com/envknob/featureknob" - "tailscale.com/health" - "tailscale.com/health/healthmsg" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/conffile" - "tailscale.com/ipn/ipnauth" - "tailscale.com/ipn/ipnstate" - "tailscale.com/ipn/policy" - "tailscale.com/log/sockstatlog" - "tailscale.com/logpolicy" - "tailscale.com/net/captivedetection" - "tailscale.com/net/dns" - "tailscale.com/net/dnscache" - "tailscale.com/net/dnsfallback" - "tailscale.com/net/ipset" - "tailscale.com/net/netcheck" - "tailscale.com/net/netkernelconf" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/netutil" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tsdial" - "tailscale.com/paths" - "tailscale.com/portlist" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/taildrop" - "tailscale.com/tka" - "tailscale.com/tsd" - "tailscale.com/tstime" - "tailscale.com/types/appctype" - "tailscale.com/types/dnstype" - "tailscale.com/types/empty" - "tailscale.com/types/key" - "tailscale.com/types/lazy" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/netmap" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/preftype" - "tailscale.com/types/ptr" - "tailscale.com/types/views" - "tailscale.com/util/deephash" - "tailscale.com/util/dnsname" - "tailscale.com/util/httpm" - "tailscale.com/util/mak" - "tailscale.com/util/multierr" - "tailscale.com/util/osshare" - "tailscale.com/util/osuser" - "tailscale.com/util/rands" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy" - "tailscale.com/util/systemd" - "tailscale.com/util/testenv" - "tailscale.com/util/uniq" - "tailscale.com/util/usermetric" - "tailscale.com/version" - "tailscale.com/version/distro" - "tailscale.com/wgengine" - "tailscale.com/wgengine/capture" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/magicsock" - "tailscale.com/wgengine/router" - "tailscale.com/wgengine/wgcfg" - "tailscale.com/wgengine/wgcfg/nmcfg" ) var controlDebugFlags = getControlDebugFlags() @@ -371,6 +370,10 @@ type LocalBackend struct { // backend is healthy and captive portal detection is not required // (sending false). needsCaptiveDetection chan bool + + cfg *wgcfg.Config + rcfg *router.Config + dcfg *dns.Config } // HealthTracker returns the health tracker for the backend. @@ -486,10 +489,10 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo } netMon := sys.NetMon.Get() - b.sockstatLogger, err = sockstatlog.NewLogger(logpolicy.LogsDir(logf), logf, logID, netMon, sys.HealthTracker()) - if err != nil { - log.Printf("error setting up sockstat logger: %v", err) - } + //b.sockstatLogger, err = sockstatlog.NewLogger(logpolicy.LogsDir(logf), logf, logID, netMon, sys.HealthTracker()) + //if err != nil { + // log.Printf("error setting up sockstat logger: %v", err) + //} // Enable sockstats logs only on non-mobile unstable builds if version.IsUnstableBuild() && !version.IsMobile() && b.sockstatLogger != nil { b.sockstatLogger.SetLoggingEnabled(true) @@ -4261,6 +4264,10 @@ func (b *LocalBackend) authReconfig() { } b.initPeerAPIListener() + + b.cfg = cfg + b.rcfg = rcfg + b.dcfg = dcfg } // shouldUseOneCGNATRoute reports whether we should prefer to make one big diff --git a/ipn/ipnlocal/local_export.go b/ipn/ipnlocal/local_export.go new file mode 100644 index 0000000000000..2a283c57963ae --- /dev/null +++ b/ipn/ipnlocal/local_export.go @@ -0,0 +1,18 @@ +package ipnlocal + +import ( + "sync/atomic" + + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" +) + +func (b *LocalBackend) ExportFilter() *atomic.Pointer[filter.Filter] { + return &b.filterAtomic +} + +func (b *LocalBackend) ExportConfig() (*wgcfg.Config, *dns.Config, *router.Config) { + return b.cfg, b.dcfg, b.rcfg +} diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go deleted file mode 100644 index 6d25a418fc6a8..0000000000000 --- a/ipn/ipnlocal/local_test.go +++ /dev/null @@ -1,4554 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "math" - "net" - "net/http" - "net/netip" - "os" - "path/filepath" - "reflect" - "slices" - "strings" - "sync" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "go4.org/netipx" - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/appc" - "tailscale.com/appc/appctest" - "tailscale.com/clientupdate" - "tailscale.com/control/controlclient" - "tailscale.com/drive" - "tailscale.com/drive/driveimpl" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/conffile" - "tailscale.com/ipn/ipnauth" - "tailscale.com/ipn/store/mem" - "tailscale.com/net/netcheck" - "tailscale.com/net/netmon" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tsdial" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/dnstype" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/netmap" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/types/views" - "tailscale.com/util/dnsname" - "tailscale.com/util/mak" - "tailscale.com/util/must" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/syspolicy/source" - "tailscale.com/wgengine" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/wgcfg" -) - -func fakeStoreRoutes(*appc.RouteInfo) error { return nil } - -func inRemove(ip netip.Addr) bool { - for _, pfx := range removeFromDefaultRoute { - if pfx.Contains(ip) { - return true - } - } - return false -} - -func TestShrinkDefaultRoute(t *testing.T) { - tests := []struct { - route string - in []string - out []string - localIPFn func(netip.Addr) bool // true if this machine's local IP address should be "in" after shrinking. - }{ - { - route: "0.0.0.0/0", - in: []string{"1.2.3.4", "25.0.0.1"}, - out: []string{ - "10.0.0.1", - "10.255.255.255", - "192.168.0.1", - "192.168.255.255", - "172.16.0.1", - "172.31.255.255", - "100.101.102.103", - "224.0.0.1", - "169.254.169.254", - // Some random IPv6 stuff that shouldn't be in a v4 - // default route. - "fe80::", - "2601::1", - }, - localIPFn: func(ip netip.Addr) bool { return !inRemove(ip) && ip.Is4() }, - }, - { - route: "::/0", - in: []string{"::1", "2601::1"}, - out: []string{ - "fe80::1", - "ff00::1", - tsaddr.TailscaleULARange().Addr().String(), - }, - localIPFn: func(ip netip.Addr) bool { return !inRemove(ip) && ip.Is6() }, - }, - } - - // Construct a fake local network environment to make this test hermetic. - // localInterfaceRoutes and hostIPs would normally come from calling interfaceRoutes, - // and localAddresses would normally come from calling interfaces.LocalAddresses. - var b netipx.IPSetBuilder - for _, c := range []string{"127.0.0.0/8", "192.168.9.0/24", "fe80::/32"} { - p := netip.MustParsePrefix(c) - b.AddPrefix(p) - } - localInterfaceRoutes, err := b.IPSet() - if err != nil { - t.Fatal(err) - } - hostIPs := []netip.Addr{ - netip.MustParseAddr("127.0.0.1"), - netip.MustParseAddr("192.168.9.39"), - netip.MustParseAddr("fe80::1"), - netip.MustParseAddr("fe80::437d:feff:feca:49a7"), - } - localAddresses := []netip.Addr{ - netip.MustParseAddr("192.168.9.39"), - } - - for _, test := range tests { - def := netip.MustParsePrefix(test.route) - got, err := shrinkDefaultRoute(def, localInterfaceRoutes, hostIPs) - if err != nil { - t.Fatalf("shrinkDefaultRoute(%q): %v", test.route, err) - } - for _, ip := range test.in { - if !got.Contains(netip.MustParseAddr(ip)) { - t.Errorf("shrink(%q).Contains(%v) = false, want true", test.route, ip) - } - } - for _, ip := range test.out { - if got.Contains(netip.MustParseAddr(ip)) { - t.Errorf("shrink(%q).Contains(%v) = true, want false", test.route, ip) - } - } - for _, ip := range localAddresses { - want := test.localIPFn(ip) - if gotContains := got.Contains(ip); gotContains != want { - t.Errorf("shrink(%q).Contains(%v) = %v, want %v", test.route, ip, gotContains, want) - } - } - } -} - -func TestPeerRoutes(t *testing.T) { - pp := netip.MustParsePrefix - tests := []struct { - name string - peers []wgcfg.Peer - want []netip.Prefix - }{ - { - name: "small_v4", - peers: []wgcfg.Peer{ - { - AllowedIPs: []netip.Prefix{ - pp("100.101.102.103/32"), - }, - }, - }, - want: []netip.Prefix{ - pp("100.101.102.103/32"), - }, - }, - { - name: "big_v4", - peers: []wgcfg.Peer{ - { - AllowedIPs: []netip.Prefix{ - pp("100.101.102.103/32"), - pp("100.101.102.104/32"), - pp("100.101.102.105/32"), - }, - }, - }, - want: []netip.Prefix{ - pp("100.64.0.0/10"), - }, - }, - { - name: "has_1_v6", - peers: []wgcfg.Peer{ - { - AllowedIPs: []netip.Prefix{ - pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b240/128"), - }, - }, - }, - want: []netip.Prefix{ - pp("fd7a:115c:a1e0::/48"), - }, - }, - { - name: "has_2_v6", - peers: []wgcfg.Peer{ - { - AllowedIPs: []netip.Prefix{ - pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b240/128"), - pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b241/128"), - }, - }, - }, - want: []netip.Prefix{ - pp("fd7a:115c:a1e0::/48"), - }, - }, - { - name: "big_v4_big_v6", - peers: []wgcfg.Peer{ - { - AllowedIPs: []netip.Prefix{ - pp("100.101.102.103/32"), - pp("100.101.102.104/32"), - pp("100.101.102.105/32"), - pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b240/128"), - pp("fd7a:115c:a1e0:ab12:4843:cd96:6258:b241/128"), - }, - }, - }, - want: []netip.Prefix{ - pp("100.64.0.0/10"), - pp("fd7a:115c:a1e0::/48"), - }, - }, - { - name: "output-should-be-sorted", - peers: []wgcfg.Peer{ - { - AllowedIPs: []netip.Prefix{ - pp("100.64.0.2/32"), - pp("10.0.0.0/16"), - }, - }, - { - AllowedIPs: []netip.Prefix{ - pp("100.64.0.1/32"), - pp("10.0.0.0/8"), - }, - }, - }, - want: []netip.Prefix{ - pp("10.0.0.0/8"), - pp("10.0.0.0/16"), - pp("100.64.0.1/32"), - pp("100.64.0.2/32"), - }, - }, - { - name: "skip-unmasked-prefixes", - peers: []wgcfg.Peer{ - { - PublicKey: key.NewNode().Public(), - AllowedIPs: []netip.Prefix{ - pp("100.64.0.2/32"), - pp("10.0.0.100/16"), - }, - }, - }, - want: []netip.Prefix{ - pp("100.64.0.2/32"), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := peerRoutes(t.Logf, tt.peers, 2) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got = %v; want %v", got, tt.want) - } - }) - } -} - -func TestPeerAPIBase(t *testing.T) { - tests := []struct { - name string - nm *netmap.NetworkMap - peer *tailcfg.Node - want string - }{ - { - name: "nil_netmap", - peer: new(tailcfg.Node), - want: "", - }, - { - name: "nil_peer", - nm: new(netmap.NetworkMap), - want: "", - }, - { - name: "self_only_4_them_both", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - }, - }).View(), - }, - peer: &tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.2/32"), - netip.MustParsePrefix("fe70::2/128"), - }, - Hostinfo: (&tailcfg.Hostinfo{ - Services: []tailcfg.Service{ - {Proto: "peerapi4", Port: 444}, - {Proto: "peerapi6", Port: 666}, - }, - }).View(), - }, - want: "http://100.64.1.2:444", - }, - { - name: "self_only_6_them_both", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("fe70::1/128"), - }, - }).View(), - }, - peer: &tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.2/32"), - netip.MustParsePrefix("fe70::2/128"), - }, - Hostinfo: (&tailcfg.Hostinfo{ - Services: []tailcfg.Service{ - {Proto: "peerapi4", Port: 444}, - {Proto: "peerapi6", Port: 666}, - }, - }).View(), - }, - want: "http://[fe70::2]:666", - }, - { - name: "self_both_them_only_4", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - netip.MustParsePrefix("fe70::1/128"), - }, - }).View(), - }, - peer: &tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.2/32"), - netip.MustParsePrefix("fe70::2/128"), - }, - Hostinfo: (&tailcfg.Hostinfo{ - Services: []tailcfg.Service{ - {Proto: "peerapi4", Port: 444}, - }, - }).View(), - }, - want: "http://100.64.1.2:444", - }, - { - name: "self_both_them_only_6", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - netip.MustParsePrefix("fe70::1/128"), - }, - }).View(), - }, - peer: &tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.2/32"), - netip.MustParsePrefix("fe70::2/128"), - }, - Hostinfo: (&tailcfg.Hostinfo{ - Services: []tailcfg.Service{ - {Proto: "peerapi6", Port: 666}, - }, - }).View(), - }, - want: "http://[fe70::2]:666", - }, - { - name: "self_both_them_no_peerapi_service", - nm: &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - netip.MustParsePrefix("fe70::1/128"), - }, - }).View(), - }, - peer: &tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.2/32"), - netip.MustParsePrefix("fe70::2/128"), - }, - }, - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := peerAPIBase(tt.nm, tt.peer.View()) - if got != tt.want { - t.Errorf("got %q; want %q", got, tt.want) - } - }) - } -} - -type panicOnUseTransport struct{} - -func (panicOnUseTransport) RoundTrip(*http.Request) (*http.Response, error) { - panic("unexpected HTTP request") -} - -func newTestLocalBackend(t testing.TB) *LocalBackend { - return newTestLocalBackendWithSys(t, new(tsd.System)) -} - -// newTestLocalBackendWithSys creates a new LocalBackend with the given tsd.System. -// If the state store or engine are not set in sys, they will be set to a new -// in-memory store and fake userspace engine, respectively. -func newTestLocalBackendWithSys(t testing.TB, sys *tsd.System) *LocalBackend { - var logf logger.Logf = logger.Discard - if _, ok := sys.StateStore.GetOK(); !ok { - sys.Set(new(mem.Store)) - } - if _, ok := sys.Engine.GetOK(); !ok { - eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) - if err != nil { - t.Fatalf("NewFakeUserspaceEngine: %v", err) - } - t.Cleanup(eng.Close) - sys.Set(eng) - } - lb, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatalf("NewLocalBackend: %v", err) - } - return lb -} - -// Issue 1573: don't generate a machine key if we don't want to be running. -func TestLazyMachineKeyGeneration(t *testing.T) { - tstest.Replace(t, &panicOnMachineKeyGeneration, func() bool { return true }) - - lb := newTestLocalBackend(t) - lb.SetHTTPTestClient(&http.Client{ - Transport: panicOnUseTransport{}, // validate we don't send HTTP requests - }) - - if err := lb.Start(ipn.Options{}); err != nil { - t.Fatalf("Start: %v", err) - } - - // Give the controlclient package goroutines (if they're - // accidentally started) extra time to schedule and run (and thus - // hit panicOnUseTransport). - time.Sleep(500 * time.Millisecond) -} - -func TestZeroExitNodeViaLocalAPI(t *testing.T) { - lb := newTestLocalBackend(t) - - // Give it an initial exit node in use. - if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ - ExitNodeIDSet: true, - Prefs: ipn.Prefs{ - ExitNodeID: "foo", - }, - }); err != nil { - t.Fatalf("enabling first exit node: %v", err) - } - - // SetUseExitNodeEnabled(false) "remembers" the prior exit node. - if _, err := lb.SetUseExitNodeEnabled(false); err != nil { - t.Fatal("expected failure") - } - - // Zero the exit node - pv, err := lb.EditPrefs(&ipn.MaskedPrefs{ - ExitNodeIDSet: true, - Prefs: ipn.Prefs{ - ExitNodeID: "", - }, - }) - - if err != nil { - t.Fatalf("enabling first exit node: %v", err) - } - - // We just set the internal exit node to the empty string, so InternalExitNodePrior should - // also be zero'd - if got, want := pv.InternalExitNodePrior(), tailcfg.StableNodeID(""); got != want { - t.Fatalf("unexpected InternalExitNodePrior %q, want: %q", got, want) - } - -} - -func TestSetUseExitNodeEnabled(t *testing.T) { - lb := newTestLocalBackend(t) - - // Can't turn it on if it never had an old value. - if _, err := lb.SetUseExitNodeEnabled(true); err == nil { - t.Fatal("expected success") - } - - // But we can turn it off when it's already off. - if _, err := lb.SetUseExitNodeEnabled(false); err != nil { - t.Fatal("expected failure") - } - - // Give it an initial exit node in use. - if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ - ExitNodeIDSet: true, - Prefs: ipn.Prefs{ - ExitNodeID: "foo", - }, - }); err != nil { - t.Fatalf("enabling first exit node: %v", err) - } - - // Now turn off that exit node. - if prefs, err := lb.SetUseExitNodeEnabled(false); err != nil { - t.Fatal("expected failure") - } else { - if g, w := prefs.ExitNodeID(), tailcfg.StableNodeID(""); g != w { - t.Fatalf("unexpected exit node ID %q; want %q", g, w) - } - if g, w := prefs.InternalExitNodePrior(), tailcfg.StableNodeID("foo"); g != w { - t.Fatalf("unexpected exit node prior %q; want %q", g, w) - } - } - - // And turn it back on. - if prefs, err := lb.SetUseExitNodeEnabled(true); err != nil { - t.Fatal("expected failure") - } else { - if g, w := prefs.ExitNodeID(), tailcfg.StableNodeID("foo"); g != w { - t.Fatalf("unexpected exit node ID %q; want %q", g, w) - } - if g, w := prefs.InternalExitNodePrior(), tailcfg.StableNodeID("foo"); g != w { - t.Fatalf("unexpected exit node prior %q; want %q", g, w) - } - } - - // Verify we block setting an Internal field. - if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ - InternalExitNodePriorSet: true, - }); err == nil { - t.Fatalf("unexpected success; want an error trying to set an internal field") - } -} - -func TestFileTargets(t *testing.T) { - b := new(LocalBackend) - _, err := b.FileTargets() - if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { - t.Errorf("before connect: got %q; want %q", got, want) - } - - b.netMap = new(netmap.NetworkMap) - _, err = b.FileTargets() - if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want { - t.Errorf("non-running netmap: got %q; want %q", got, want) - } - - b.state = ipn.Running - _, err = b.FileTargets() - if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want { - t.Errorf("without cap: got %q; want %q", got, want) - } - - b.capFileSharing = true - got, err := b.FileTargets() - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("unexpected %d peers", len(got)) - } - - var peerMap map[tailcfg.NodeID]tailcfg.NodeView - mak.NonNil(&peerMap) - var nodeID tailcfg.NodeID - nodeID = 1234 - peer := &tailcfg.Node{ - ID: 1234, - Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(), - } - peerMap[nodeID] = peer.View() - b.peers = peerMap - got, err = b.FileTargets() - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("unexpected %d peers", len(got)) - } - // (other cases handled by TestPeerAPIBase above) -} - -func TestInternalAndExternalInterfaces(t *testing.T) { - type interfacePrefix struct { - i netmon.Interface - pfx netip.Prefix - } - - masked := func(ips ...interfacePrefix) (pfxs []netip.Prefix) { - for _, ip := range ips { - pfxs = append(pfxs, ip.pfx.Masked()) - } - return pfxs - } - iList := func(ips ...interfacePrefix) (il netmon.InterfaceList) { - for _, ip := range ips { - il = append(il, ip.i) - } - return il - } - newInterface := func(name, pfx string, wsl2, loopback bool) interfacePrefix { - ippfx := netip.MustParsePrefix(pfx) - ip := netmon.Interface{ - Interface: &net.Interface{}, - AltAddrs: []net.Addr{ - netipx.PrefixIPNet(ippfx), - }, - } - if loopback { - ip.Flags = net.FlagLoopback - } - if wsl2 { - ip.HardwareAddr = []byte{0x00, 0x15, 0x5d, 0x00, 0x00, 0x00} - } - return interfacePrefix{i: ip, pfx: ippfx} - } - var ( - en0 = newInterface("en0", "10.20.2.5/16", false, false) - en1 = newInterface("en1", "192.168.1.237/24", false, false) - wsl = newInterface("wsl", "192.168.5.34/24", true, false) - loopback = newInterface("lo0", "127.0.0.1/8", false, true) - ) - - tests := []struct { - name string - goos string - il netmon.InterfaceList - wantInt []netip.Prefix - wantExt []netip.Prefix - }{ - { - name: "single-interface", - goos: "linux", - il: iList( - en0, - loopback, - ), - wantInt: masked(loopback), - wantExt: masked(en0), - }, - { - name: "multiple-interfaces", - goos: "linux", - il: iList( - en0, - en1, - wsl, - loopback, - ), - wantInt: masked(loopback), - wantExt: masked(en0, en1, wsl), - }, - { - name: "wsl2", - goos: "windows", - il: iList( - en0, - en1, - wsl, - loopback, - ), - wantInt: masked(loopback, wsl), - wantExt: masked(en0, en1), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - gotInt, gotExt, err := internalAndExternalInterfacesFrom(tc.il, tc.goos) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(gotInt, tc.wantInt) { - t.Errorf("unexpected internal prefixes\ngot %v\nwant %v", gotInt, tc.wantInt) - } - if !reflect.DeepEqual(gotExt, tc.wantExt) { - t.Errorf("unexpected external prefixes\ngot %v\nwant %v", gotExt, tc.wantExt) - } - }) - } -} - -func TestPacketFilterPermitsUnlockedNodes(t *testing.T) { - tests := []struct { - name string - peers []*tailcfg.Node - filter []filter.Match - want bool - }{ - { - name: "empty", - want: false, - }, - { - name: "no-unsigned", - peers: []*tailcfg.Node{ - {ID: 1}, - }, - want: false, - }, - { - name: "unsigned-good", - peers: []*tailcfg.Node{ - {ID: 1, UnsignedPeerAPIOnly: true}, - }, - want: false, - }, - { - name: "unsigned-bad", - peers: []*tailcfg.Node{ - { - ID: 1, - UnsignedPeerAPIOnly: true, - AllowedIPs: []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/32"), - }, - }, - }, - filter: []filter.Match{ - { - Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/32")}, - Dsts: []filter.NetPortRange{ - { - Net: netip.MustParsePrefix("100.99.0.0/32"), - }, - }, - }, - }, - want: true, - }, - { - name: "unsigned-bad-src-is-superset", - peers: []*tailcfg.Node{ - { - ID: 1, - UnsignedPeerAPIOnly: true, - AllowedIPs: []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/32"), - }, - }, - }, - filter: []filter.Match{ - { - Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/24")}, - Dsts: []filter.NetPortRange{ - { - Net: netip.MustParsePrefix("100.99.0.0/32"), - }, - }, - }, - }, - want: true, - }, - { - name: "unsigned-okay-because-no-dsts", - peers: []*tailcfg.Node{ - { - ID: 1, - UnsignedPeerAPIOnly: true, - AllowedIPs: []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/32"), - }, - }, - }, - filter: []filter.Match{ - { - Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/32")}, - Caps: []filter.CapMatch{ - { - Dst: netip.MustParsePrefix("100.99.0.0/32"), - Cap: "foo", - }, - }, - }, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := packetFilterPermitsUnlockedNodes(peersMap(nodeViews(tt.peers)), tt.filter); got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestStatusPeerCapabilities(t *testing.T) { - tests := []struct { - name string - peers []tailcfg.NodeView - expectedPeerCapabilities map[tailcfg.StableNodeID][]tailcfg.NodeCapability - expectedPeerCapMap map[tailcfg.StableNodeID]tailcfg.NodeCapMap - }{ - { - name: "peers-with-capabilities", - peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - ID: 1, - StableID: "foo", - IsWireGuardOnly: true, - Hostinfo: (&tailcfg.Hostinfo{}).View(), - Capabilities: []tailcfg.NodeCapability{tailcfg.CapabilitySSH}, - CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ - tailcfg.CapabilitySSH: nil, - }), - }).View(), - (&tailcfg.Node{ - ID: 2, - StableID: "bar", - Hostinfo: (&tailcfg.Hostinfo{}).View(), - Capabilities: []tailcfg.NodeCapability{tailcfg.CapabilityAdmin}, - CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ - tailcfg.CapabilityAdmin: {`{"test": "true}`}, - }), - }).View(), - }, - expectedPeerCapabilities: map[tailcfg.StableNodeID][]tailcfg.NodeCapability{ - tailcfg.StableNodeID("foo"): {tailcfg.CapabilitySSH}, - tailcfg.StableNodeID("bar"): {tailcfg.CapabilityAdmin}, - }, - expectedPeerCapMap: map[tailcfg.StableNodeID]tailcfg.NodeCapMap{ - tailcfg.StableNodeID("foo"): (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ - tailcfg.CapabilitySSH: nil, - }), - tailcfg.StableNodeID("bar"): (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ - tailcfg.CapabilityAdmin: {`{"test": "true}`}, - }), - }, - }, - { - name: "peers-without-capabilities", - peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - ID: 1, - StableID: "foo", - IsWireGuardOnly: true, - Hostinfo: (&tailcfg.Hostinfo{}).View(), - }).View(), - (&tailcfg.Node{ - ID: 2, - StableID: "bar", - Hostinfo: (&tailcfg.Hostinfo{}).View(), - }).View(), - }, - }, - } - b := newTestLocalBackend(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b.setNetMapLocked(&netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - MachineAuthorized: true, - Addresses: ipps("100.101.101.101"), - }).View(), - Peers: tt.peers, - }) - got := b.Status() - for _, peer := range got.Peer { - if !reflect.DeepEqual(peer.Capabilities, tt.expectedPeerCapabilities[peer.ID]) { - t.Errorf("peer capabilities: expected %v got %v", tt.expectedPeerCapabilities, peer.Capabilities) - } - if !reflect.DeepEqual(peer.CapMap, tt.expectedPeerCapMap[peer.ID]) { - t.Errorf("peer capmap: expected %v got %v", tt.expectedPeerCapMap, peer.CapMap) - } - } - }) - } -} - -// legacyBackend was the interface between Tailscale frontends -// (e.g. cmd/tailscale, iOS/MacOS/Windows GUIs) and the tailscale -// backend (e.g. cmd/tailscaled) running on the same machine. -// (It has nothing to do with the interface between the backends -// and the cloud control plane.) -type legacyBackend interface { - // SetNotifyCallback sets the callback to be called on updates - // from the backend to the client. - SetNotifyCallback(func(ipn.Notify)) - // Start starts or restarts the backend, typically when a - // frontend client connects. - Start(ipn.Options) error -} - -// Verify that LocalBackend still implements the legacyBackend interface -// for now, at least until the macOS and iOS clients move off of it. -var _ legacyBackend = (*LocalBackend)(nil) - -func TestWatchNotificationsCallbacks(t *testing.T) { - b := new(LocalBackend) - n := new(ipn.Notify) - b.WatchNotifications(context.Background(), 0, func() { - b.mu.Lock() - defer b.mu.Unlock() - - // Ensure a watcher has been installed. - if len(b.notifyWatchers) != 1 { - t.Fatalf("unexpected number of watchers in new LocalBackend, want: 1 got: %v", len(b.notifyWatchers)) - } - // Send a notification. Range over notifyWatchers to get the channel - // because WatchNotifications doesn't expose the handle for it. - for _, sess := range b.notifyWatchers { - select { - case sess.ch <- n: - default: - t.Fatalf("could not send notification") - } - } - }, func(roNotify *ipn.Notify) bool { - if roNotify != n { - t.Fatalf("unexpected notification received. want: %v got: %v", n, roNotify) - } - return false - }) - - // Ensure watchers have been cleaned up. - b.mu.Lock() - defer b.mu.Unlock() - if len(b.notifyWatchers) != 0 { - t.Fatalf("unexpected number of watchers in new LocalBackend, want: 0 got: %v", len(b.notifyWatchers)) - } -} - -// tests LocalBackend.updateNetmapDeltaLocked -func TestUpdateNetmapDelta(t *testing.T) { - b := newTestLocalBackend(t) - if b.updateNetmapDeltaLocked(nil) { - t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap") - } - - b.netMap = &netmap.NetworkMap{} - for i := range 5 { - b.netMap.Peers = append(b.netMap.Peers, (&tailcfg.Node{ID: (tailcfg.NodeID(i) + 1)}).View()) - } - b.updatePeersFromNetmapLocked(b.netMap) - - someTime := time.Unix(123, 0) - muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{ - PeersChangedPatch: []*tailcfg.PeerChange{ - { - NodeID: 1, - DERPRegion: 1, - }, - { - NodeID: 2, - Online: ptr.To(true), - }, - { - NodeID: 3, - Online: ptr.To(false), - }, - { - NodeID: 4, - LastSeen: ptr.To(someTime), - }, - }, - }, someTime) - if !ok { - t.Fatal("netmap.MutationsFromMapResponse failed") - } - - if !b.updateNetmapDeltaLocked(muts) { - t.Fatalf("updateNetmapDeltaLocked() = false, want true with new netmap") - } - - wants := []*tailcfg.Node{ - { - ID: 1, - DERP: "127.3.3.40:1", - }, - { - ID: 2, - Online: ptr.To(true), - }, - { - ID: 3, - Online: ptr.To(false), - }, - { - ID: 4, - LastSeen: ptr.To(someTime), - }, - } - for _, want := range wants { - gotv, ok := b.peers[want.ID] - if !ok { - t.Errorf("netmap.Peer %v missing from b.peers", want.ID) - continue - } - got := gotv.AsStruct() - if !reflect.DeepEqual(got, want) { - t.Errorf("netmap.Peer %v wrong.\n got: %v\nwant: %v", want.ID, logger.AsJSON(got), logger.AsJSON(want)) - } - } -} - -// tests WhoIs and indirectly that setNetMapLocked updates b.nodeByAddr correctly. -func TestWhoIs(t *testing.T) { - b := newTestLocalBackend(t) - b.setNetMapLocked(&netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - ID: 1, - User: 10, - Addresses: []netip.Prefix{netip.MustParsePrefix("100.101.102.103/32")}, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - ID: 2, - User: 20, - Addresses: []netip.Prefix{netip.MustParsePrefix("100.200.200.200/32")}, - }).View(), - }, - UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{ - 10: { - DisplayName: "Myself", - }, - 20: { - DisplayName: "Peer", - }, - }, - }) - tests := []struct { - q string - want tailcfg.NodeID // 0 means want ok=false - wantName string - }{ - {"100.101.102.103:0", 1, "Myself"}, - {"100.101.102.103:123", 1, "Myself"}, - {"100.200.200.200:0", 2, "Peer"}, - {"100.200.200.200:123", 2, "Peer"}, - {"100.4.0.4:404", 0, ""}, - } - for _, tt := range tests { - t.Run(tt.q, func(t *testing.T) { - nv, up, ok := b.WhoIs("", netip.MustParseAddrPort(tt.q)) - var got tailcfg.NodeID - if ok { - got = nv.ID() - } - if got != tt.want { - t.Errorf("got nodeID %v; want %v", got, tt.want) - } - if up.DisplayName != tt.wantName { - t.Errorf("got name %q; want %q", up.DisplayName, tt.wantName) - } - }) - } -} - -func TestWireguardExitNodeDNSResolvers(t *testing.T) { - type tc struct { - name string - id tailcfg.StableNodeID - peers []*tailcfg.Node - wantOK bool - wantResolvers []*dnstype.Resolver - } - - tests := []tc{ - { - name: "no peers", - id: "1", - wantOK: false, - wantResolvers: nil, - }, - { - name: "non wireguard peer", - id: "1", - peers: []*tailcfg.Node{ - { - ID: 1, - StableID: "1", - IsWireGuardOnly: false, - ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, - }, - }, - wantOK: false, - wantResolvers: nil, - }, - { - name: "no matching IDs", - id: "2", - peers: []*tailcfg.Node{ - { - ID: 1, - StableID: "1", - IsWireGuardOnly: true, - ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, - }, - }, - wantOK: false, - wantResolvers: nil, - }, - { - name: "wireguard peer", - id: "1", - peers: []*tailcfg.Node{ - { - ID: 1, - StableID: "1", - IsWireGuardOnly: true, - ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, - }, - }, - wantOK: true, - wantResolvers: []*dnstype.Resolver{{Addr: "dns.example.com"}}, - }, - } - - for _, tc := range tests { - peers := peersMap(nodeViews(tc.peers)) - nm := &netmap.NetworkMap{} - gotResolvers, gotOK := wireguardExitNodeDNSResolvers(nm, peers, tc.id) - - if gotOK != tc.wantOK || !resolversEqual(t, gotResolvers, tc.wantResolvers) { - t.Errorf("case: %s: got %v, %v, want %v, %v", tc.name, gotOK, gotResolvers, tc.wantOK, tc.wantResolvers) - } - } -} - -func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) { - type tc struct { - name string - exitNode tailcfg.StableNodeID - peers []tailcfg.NodeView - dnsConfig *tailcfg.DNSConfig - wantDefaultResolvers []*dnstype.Resolver - wantRoutes map[dnsname.FQDN][]*dnstype.Resolver - } - - defaultResolvers := []*dnstype.Resolver{{Addr: "default.example.com"}} - wgResolvers := []*dnstype.Resolver{{Addr: "wg.example.com"}} - peers := []tailcfg.NodeView{ - (&tailcfg.Node{ - ID: 1, - StableID: "wg", - IsWireGuardOnly: true, - ExitNodeDNSResolvers: wgResolvers, - Hostinfo: (&tailcfg.Hostinfo{}).View(), - }).View(), - // regular tailscale exit node with DNS capabilities - (&tailcfg.Node{ - Cap: 26, - ID: 2, - StableID: "ts", - Hostinfo: (&tailcfg.Hostinfo{}).View(), - }).View(), - } - exitDOH := peerAPIBase(&netmap.NetworkMap{Peers: peers}, peers[0]) + "/dns-query" - routes := map[dnsname.FQDN][]*dnstype.Resolver{ - "route.example.com.": {{Addr: "route.example.com"}}, - } - stringifyRoutes := func(routes map[dnsname.FQDN][]*dnstype.Resolver) map[string][]*dnstype.Resolver { - if routes == nil { - return nil - } - m := make(map[string][]*dnstype.Resolver) - for k, v := range routes { - m[string(k)] = v - } - return m - } - - tests := []tc{ - { - name: "noExit/noRoutes/noResolver", - exitNode: "", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{}, - wantDefaultResolvers: nil, - wantRoutes: nil, - }, - { - name: "tsExit/noRoutes/noResolver", - exitNode: "ts", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{}, - wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, - wantRoutes: nil, - }, - { - name: "tsExit/noRoutes/defaultResolver", - exitNode: "ts", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{Resolvers: defaultResolvers}, - wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, - wantRoutes: nil, - }, - - // The following two cases may need to be revisited. For a shared-in - // exit node split-DNS may effectively break, furthermore in the future - // if different nodes observe different DNS configurations, even a - // tailnet local exit node may present a different DNS configuration, - // which may not meet expectations in some use cases. - // In the case where a default resolver is set, the default resolver - // should also perhaps take precedence also. - { - name: "tsExit/routes/noResolver", - exitNode: "ts", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, - wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, - wantRoutes: nil, - }, - { - name: "tsExit/routes/defaultResolver", - exitNode: "ts", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, - wantDefaultResolvers: []*dnstype.Resolver{{Addr: exitDOH}}, - wantRoutes: nil, - }, - - // WireGuard exit nodes with DNS capabilities provide a "fallback" type - // behavior, they have a lower precedence than a default resolver, but - // otherwise allow split-DNS to operate as normal, and are used when - // there is no default resolver. - { - name: "wgExit/noRoutes/noResolver", - exitNode: "wg", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{}, - wantDefaultResolvers: wgResolvers, - wantRoutes: nil, - }, - { - name: "wgExit/noRoutes/defaultResolver", - exitNode: "wg", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{Resolvers: defaultResolvers}, - wantDefaultResolvers: defaultResolvers, - wantRoutes: nil, - }, - { - name: "wgExit/routes/defaultResolver", - exitNode: "wg", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes), Resolvers: defaultResolvers}, - wantDefaultResolvers: defaultResolvers, - wantRoutes: routes, - }, - { - name: "wgExit/routes/noResolver", - exitNode: "wg", - peers: peers, - dnsConfig: &tailcfg.DNSConfig{Routes: stringifyRoutes(routes)}, - wantDefaultResolvers: wgResolvers, - wantRoutes: routes, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - nm := &netmap.NetworkMap{ - Peers: tc.peers, - DNS: *tc.dnsConfig, - } - - prefs := &ipn.Prefs{ExitNodeID: tc.exitNode, CorpDNS: true} - got := dnsConfigForNetmap(nm, peersMap(tc.peers), prefs.View(), false, t.Logf, "") - if !resolversEqual(t, got.DefaultResolvers, tc.wantDefaultResolvers) { - t.Errorf("DefaultResolvers: got %#v, want %#v", got.DefaultResolvers, tc.wantDefaultResolvers) - } - if !routesEqual(t, got.Routes, tc.wantRoutes) { - t.Errorf("Routes: got %#v, want %#v", got.Routes, tc.wantRoutes) - } - }) - } -} - -func TestOfferingAppConnector(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - b := newTestBackend(t) - if b.OfferingAppConnector() { - t.Fatal("unexpected offering app connector") - } - if shouldStore { - b.appConnector = appc.NewAppConnector(t.Logf, nil, &appc.RouteInfo{}, fakeStoreRoutes) - } else { - b.appConnector = appc.NewAppConnector(t.Logf, nil, nil, nil) - } - if !b.OfferingAppConnector() { - t.Fatal("unexpected not offering app connector") - } - } -} - -func TestRouteAdvertiser(t *testing.T) { - b := newTestBackend(t) - testPrefix := netip.MustParsePrefix("192.0.0.8/32") - - ra := appc.RouteAdvertiser(b) - must.Do(ra.AdvertiseRoute(testPrefix)) - - routes := b.Prefs().AdvertiseRoutes() - if routes.Len() != 1 || routes.At(0) != testPrefix { - t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix}) - } - - must.Do(ra.UnadvertiseRoute(testPrefix)) - - routes = b.Prefs().AdvertiseRoutes() - if routes.Len() != 0 { - t.Fatalf("got routes %v, want none", routes) - } -} - -func TestRouterAdvertiserIgnoresContainedRoutes(t *testing.T) { - b := newTestBackend(t) - testPrefix := netip.MustParsePrefix("192.0.0.0/24") - ra := appc.RouteAdvertiser(b) - must.Do(ra.AdvertiseRoute(testPrefix)) - - routes := b.Prefs().AdvertiseRoutes() - if routes.Len() != 1 || routes.At(0) != testPrefix { - t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix}) - } - - must.Do(ra.AdvertiseRoute(netip.MustParsePrefix("192.0.0.8/32"))) - - // the above /32 is not added as it is contained within the /24 - routes = b.Prefs().AdvertiseRoutes() - if routes.Len() != 1 || routes.At(0) != testPrefix { - t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix}) - } -} - -func TestObserveDNSResponse(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - b := newTestBackend(t) - - // ensure no error when no app connector is configured - b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) - - rc := &appctest.RouteCollector{} - if shouldStore { - b.appConnector = appc.NewAppConnector(t.Logf, rc, &appc.RouteInfo{}, fakeStoreRoutes) - } else { - b.appConnector = appc.NewAppConnector(t.Logf, rc, nil, nil) - } - b.appConnector.UpdateDomains([]string{"example.com"}) - b.appConnector.Wait(context.Background()) - - b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")) - b.appConnector.Wait(context.Background()) - wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} - if !slices.Equal(rc.Routes(), wantRoutes) { - t.Fatalf("got routes %v, want %v", rc.Routes(), wantRoutes) - } - } -} - -func TestCoveredRouteRangeNoDefault(t *testing.T) { - tests := []struct { - existingRoute netip.Prefix - newRoute netip.Prefix - want bool - }{ - { - existingRoute: netip.MustParsePrefix("192.0.0.1/32"), - newRoute: netip.MustParsePrefix("192.0.0.1/32"), - want: true, - }, - { - existingRoute: netip.MustParsePrefix("192.0.0.1/32"), - newRoute: netip.MustParsePrefix("192.0.0.2/32"), - want: false, - }, - { - existingRoute: netip.MustParsePrefix("192.0.0.0/24"), - newRoute: netip.MustParsePrefix("192.0.0.1/32"), - want: true, - }, - { - existingRoute: netip.MustParsePrefix("192.0.0.0/16"), - newRoute: netip.MustParsePrefix("192.0.0.0/24"), - want: true, - }, - { - existingRoute: netip.MustParsePrefix("0.0.0.0/0"), - newRoute: netip.MustParsePrefix("192.0.0.0/24"), - want: false, - }, - { - existingRoute: netip.MustParsePrefix("::/0"), - newRoute: netip.MustParsePrefix("2001:db8::/32"), - want: false, - }, - } - - for _, tt := range tests { - got := coveredRouteRangeNoDefault([]netip.Prefix{tt.existingRoute}, tt.newRoute) - if got != tt.want { - t.Errorf("coveredRouteRange(%v, %v) = %v, want %v", tt.existingRoute, tt.newRoute, got, tt.want) - } - } -} - -func TestReconfigureAppConnector(t *testing.T) { - b := newTestBackend(t) - b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) - if b.appConnector != nil { - t.Fatal("unexpected app connector") - } - - b.EditPrefs(&ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - AppConnector: ipn.AppConnectorPrefs{ - Advertise: true, - }, - }, - AppConnectorSet: true, - }) - b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) - if b.appConnector == nil { - t.Fatal("expected app connector") - } - - appCfg := `{ - "name": "example", - "domains": ["example.com"], - "connectors": ["tag:example"] - }` - - b.netMap.SelfNode = (&tailcfg.Node{ - Name: "example.ts.net", - Tags: []string{"tag:example"}, - CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ - "tailscale.com/app-connectors": {tailcfg.RawMessage(appCfg)}, - }), - }).View() - - b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) - b.appConnector.Wait(context.Background()) - - want := []string{"example.com"} - if !slices.Equal(b.appConnector.Domains().AsSlice(), want) { - t.Fatalf("got domains %v, want %v", b.appConnector.Domains(), want) - } - if v, _ := b.hostinfo.AppConnector.Get(); !v { - t.Fatalf("expected app connector service") - } - - // disable the connector in order to assert that the service is removed - b.EditPrefs(&ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - AppConnector: ipn.AppConnectorPrefs{ - Advertise: false, - }, - }, - AppConnectorSet: true, - }) - b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs) - if b.appConnector != nil { - t.Fatal("expected no app connector") - } - if v, _ := b.hostinfo.AppConnector.Get(); v { - t.Fatalf("expected no app connector service") - } -} - -func resolversEqual(t *testing.T, a, b []*dnstype.Resolver) bool { - if a == nil && b == nil { - return true - } - if a == nil || b == nil { - t.Errorf("resolversEqual: a == nil || b == nil : %#v != %#v", a, b) - return false - } - if len(a) != len(b) { - t.Errorf("resolversEqual: len(a) != len(b) : %#v != %#v", a, b) - return false - } - for i := range a { - if !a[i].Equal(b[i]) { - t.Errorf("resolversEqual: a != b [%d]: %v != %v", i, *a[i], *b[i]) - return false - } - } - return true -} - -func routesEqual(t *testing.T, a, b map[dnsname.FQDN][]*dnstype.Resolver) bool { - if len(a) != len(b) { - t.Logf("routes: len(a) != len(b): %d != %d", len(a), len(b)) - return false - } - for name := range a { - if !resolversEqual(t, a[name], b[name]) { - t.Logf("routes: a != b [%s]: %v != %v", name, a[name], b[name]) - return false - } - } - return true -} - -// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address -func dnsResponse(domain, address string) []byte { - addr := netip.MustParseAddr(address) - b := dnsmessage.NewBuilder(nil, dnsmessage.Header{}) - b.EnableCompression() - b.StartAnswers() - switch addr.BitLen() { - case 32: - b.AResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AResource{ - A: addr.As4(), - }, - ) - case 128: - b.AAAAResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName(domain), - Type: dnsmessage.TypeAAAA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AAAAResource{ - AAAA: addr.As16(), - }, - ) - default: - panic("invalid address length") - } - return must.Get(b.Finish()) -} - -func TestSetExitNodeIDPolicy(t *testing.T) { - pfx := netip.MustParsePrefix - tests := []struct { - name string - exitNodeIPKey bool - exitNodeIDKey bool - exitNodeID string - exitNodeIP string - prefs *ipn.Prefs - exitNodeIPWant string - exitNodeIDWant string - prefsChanged bool - nm *netmap.NetworkMap - lastSuggestedExitNode tailcfg.StableNodeID - }{ - { - name: "ExitNodeID key is set", - exitNodeIDKey: true, - exitNodeID: "123", - exitNodeIDWant: "123", - prefsChanged: true, - }, - { - name: "ExitNodeID key not set", - exitNodeIDKey: true, - exitNodeIDWant: "", - prefsChanged: false, - }, - { - name: "ExitNodeID key set, ExitNodeIP preference set", - exitNodeIDKey: true, - exitNodeID: "123", - prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, - exitNodeIDWant: "123", - prefsChanged: true, - }, - { - name: "ExitNodeID key not set, ExitNodeIP key set", - exitNodeIPKey: true, - exitNodeIP: "127.0.0.1", - prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, - exitNodeIPWant: "127.0.0.1", - prefsChanged: false, - }, - { - name: "ExitNodeIP key set, existing ExitNodeIP pref", - exitNodeIPKey: true, - exitNodeIP: "127.0.0.1", - prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, - exitNodeIPWant: "127.0.0.1", - prefsChanged: false, - }, - { - name: "existing preferences match policy", - exitNodeIDKey: true, - exitNodeID: "123", - prefs: &ipn.Prefs{ExitNodeID: tailcfg.StableNodeID("123")}, - exitNodeIDWant: "123", - prefsChanged: false, - }, - { - name: "ExitNodeIP set if net map does not have corresponding node", - exitNodeIPKey: true, - prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, - exitNodeIP: "127.0.0.1", - exitNodeIPWant: "127.0.0.1", - prefsChanged: false, - nm: &netmap.NetworkMap{ - Name: "foo.tailnet", - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - Name: "a.tailnet", - Addresses: []netip.Prefix{ - pfx("100.0.0.201/32"), - pfx("100::201/128"), - }, - }).View(), - (&tailcfg.Node{ - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }).View(), - }, - }, - }, - { - name: "ExitNodeIP cleared if net map has corresponding node - policy matches prefs", - prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, - exitNodeIPKey: true, - exitNodeIP: "127.0.0.1", - exitNodeIPWant: "", - exitNodeIDWant: "123", - prefsChanged: true, - nm: &netmap.NetworkMap{ - Name: "foo.tailnet", - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - Name: "a.tailnet", - StableID: tailcfg.StableNodeID("123"), - Addresses: []netip.Prefix{ - pfx("127.0.0.1/32"), - pfx("100::201/128"), - }, - }).View(), - (&tailcfg.Node{ - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }).View(), - }, - }, - }, - { - name: "ExitNodeIP cleared if net map has corresponding node - no policy set", - prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, - exitNodeIPWant: "", - exitNodeIDWant: "123", - prefsChanged: true, - nm: &netmap.NetworkMap{ - Name: "foo.tailnet", - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - Name: "a.tailnet", - StableID: tailcfg.StableNodeID("123"), - Addresses: []netip.Prefix{ - pfx("127.0.0.1/32"), - pfx("100::201/128"), - }, - }).View(), - (&tailcfg.Node{ - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }).View(), - }, - }, - }, - { - name: "ExitNodeIP cleared if net map has corresponding node - different exit node IP in policy", - exitNodeIPKey: true, - prefs: &ipn.Prefs{ExitNodeIP: netip.MustParseAddr("127.0.0.1")}, - exitNodeIP: "100.64.5.6", - exitNodeIPWant: "", - exitNodeIDWant: "123", - prefsChanged: true, - nm: &netmap.NetworkMap{ - Name: "foo.tailnet", - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - Name: "a.tailnet", - StableID: tailcfg.StableNodeID("123"), - Addresses: []netip.Prefix{ - pfx("100.64.5.6/32"), - pfx("100::201/128"), - }, - }).View(), - (&tailcfg.Node{ - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }).View(), - }, - }, - }, - { - name: "ExitNodeID key is set to auto and last suggested exit node is populated", - exitNodeIDKey: true, - exitNodeID: "auto:any", - lastSuggestedExitNode: "123", - exitNodeIDWant: "123", - prefsChanged: true, - }, - { - name: "ExitNodeID key is set to auto and last suggested exit node is not populated", - exitNodeIDKey: true, - exitNodeID: "auto:any", - prefsChanged: true, - exitNodeIDWant: "auto:any", - }, - } - - syspolicy.RegisterWellKnownSettingsForTest(t) - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - b := newTestBackend(t) - - policyStore := source.NewTestStoreOf(t, - source.TestSettingOf(syspolicy.ExitNodeID, test.exitNodeID), - source.TestSettingOf(syspolicy.ExitNodeIP, test.exitNodeIP), - ) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - - if test.nm == nil { - test.nm = new(netmap.NetworkMap) - } - if test.prefs == nil { - test.prefs = ipn.NewPrefs() - } - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - pm.prefs = test.prefs.View() - b.netMap = test.nm - b.pm = pm - b.lastSuggestedExitNode = test.lastSuggestedExitNode - changed := setExitNodeID(b.pm.prefs.AsStruct(), test.nm, tailcfg.StableNodeID(test.lastSuggestedExitNode)) - b.SetPrefsForTest(pm.CurrentPrefs().AsStruct()) - - if got := b.pm.prefs.ExitNodeID(); got != tailcfg.StableNodeID(test.exitNodeIDWant) { - t.Errorf("got %v want %v", got, test.exitNodeIDWant) - } - if got := b.pm.prefs.ExitNodeIP(); test.exitNodeIPWant == "" { - if got.String() != "invalid IP" { - t.Errorf("got %v want invalid IP", got) - } - } else if got.String() != test.exitNodeIPWant { - t.Errorf("got %v want %v", got, test.exitNodeIPWant) - } - - if changed != test.prefsChanged { - t.Errorf("wanted prefs changed %v, got prefs changed %v", test.prefsChanged, changed) - } - }) - } -} - -func TestUpdateNetmapDeltaAutoExitNode(t *testing.T) { - peer1 := makePeer(1, withCap(26), withSuggest(), withExitRoutes()) - peer2 := makePeer(2, withCap(26), withSuggest(), withExitRoutes()) - derpMap := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - Nodes: []*tailcfg.DERPNode{ - { - Name: "t1", - RegionID: 1, - }, - }, - }, - 2: { - Nodes: []*tailcfg.DERPNode{ - { - Name: "t2", - RegionID: 2, - }, - }, - }, - }, - } - report := &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 5 * time.Millisecond, - 3: 30 * time.Millisecond, - }, - PreferredDERP: 2, - } - tests := []struct { - name string - lastSuggestedExitNode tailcfg.StableNodeID - netmap *netmap.NetworkMap - muts []*tailcfg.PeerChange - exitNodeIDWant tailcfg.StableNodeID - updateNetmapDeltaResponse bool - report *netcheck.Report - }{ - { - name: "selected auto exit node goes offline", - lastSuggestedExitNode: peer1.StableID(), - netmap: &netmap.NetworkMap{ - Peers: []tailcfg.NodeView{ - peer1, - peer2, - }, - DERPMap: derpMap, - }, - muts: []*tailcfg.PeerChange{ - { - NodeID: 1, - Online: ptr.To(false), - }, - { - NodeID: 2, - Online: ptr.To(true), - }, - }, - exitNodeIDWant: peer2.StableID(), - updateNetmapDeltaResponse: false, - report: report, - }, - { - name: "other exit node goes offline doesn't change selected auto exit node that's still online", - lastSuggestedExitNode: peer2.StableID(), - netmap: &netmap.NetworkMap{ - Peers: []tailcfg.NodeView{ - peer1, - peer2, - }, - DERPMap: derpMap, - }, - muts: []*tailcfg.PeerChange{ - { - NodeID: 1, - Online: ptr.To(false), - }, - { - NodeID: 2, - Online: ptr.To(true), - }, - }, - exitNodeIDWant: peer2.StableID(), - updateNetmapDeltaResponse: true, - report: report, - }, - } - - syspolicy.RegisterWellKnownSettingsForTest(t) - policyStore := source.NewTestStoreOf(t, source.TestSettingOf( - syspolicy.ExitNodeID, "auto:any", - )) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b := newTestLocalBackend(t) - b.netMap = tt.netmap - b.updatePeersFromNetmapLocked(b.netMap) - b.lastSuggestedExitNode = tt.lastSuggestedExitNode - b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, tt.report) - b.SetPrefsForTest(b.pm.CurrentPrefs().AsStruct()) - someTime := time.Unix(123, 0) - muts, ok := netmap.MutationsFromMapResponse(&tailcfg.MapResponse{ - PeersChangedPatch: tt.muts, - }, someTime) - if !ok { - t.Fatal("netmap.MutationsFromMapResponse failed") - } - if b.pm.prefs.ExitNodeID() != tt.lastSuggestedExitNode { - t.Fatalf("did not set exit node ID to last suggested exit node despite auto policy") - } - - got := b.UpdateNetmapDelta(muts) - if got != tt.updateNetmapDeltaResponse { - t.Fatalf("got %v expected %v from UpdateNetmapDelta", got, tt.updateNetmapDeltaResponse) - } - if b.pm.prefs.ExitNodeID() != tt.exitNodeIDWant { - t.Fatalf("did not get expected exit node id after UpdateNetmapDelta") - } - }) - } -} - -func TestAutoExitNodeSetNetInfoCallback(t *testing.T) { - b := newTestLocalBackend(t) - hi := hostinfo.New() - ni := tailcfg.NetInfo{LinkType: "wired"} - hi.NetInfo = &ni - b.hostinfo = hi - k := key.NewMachine() - var cc *mockControl - opts := controlclient.Options{ - ServerURL: "https://example.com", - GetMachinePrivateKey: func() (key.MachinePrivate, error) { - return k, nil - }, - Dialer: tsdial.NewDialer(netmon.NewStatic()), - Logf: b.logf, - } - cc = newClient(t, opts) - b.cc = cc - syspolicy.RegisterWellKnownSettingsForTest(t) - policyStore := source.NewTestStoreOf(t, source.TestSettingOf( - syspolicy.ExitNodeID, "auto:any", - )) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - peer1 := makePeer(1, withCap(26), withDERP(3), withSuggest(), withExitRoutes()) - peer2 := makePeer(2, withCap(26), withDERP(2), withSuggest(), withExitRoutes()) - selfNode := tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - netip.MustParsePrefix("fe70::1/128"), - }, - DERP: "127.3.3.40:2", - } - defaultDERPMap := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - Nodes: []*tailcfg.DERPNode{ - { - Name: "t1", - RegionID: 1, - }, - }, - }, - 2: { - Nodes: []*tailcfg.DERPNode{ - { - Name: "t2", - RegionID: 2, - }, - }, - }, - 3: { - Nodes: []*tailcfg.DERPNode{ - { - Name: "t3", - RegionID: 3, - }, - }, - }, - }, - } - b.netMap = &netmap.NetworkMap{ - SelfNode: selfNode.View(), - Peers: []tailcfg.NodeView{ - peer1, - peer2, - }, - DERPMap: defaultDERPMap, - } - b.lastSuggestedExitNode = peer1.StableID() - b.SetPrefsForTest(b.pm.CurrentPrefs().AsStruct()) - if eid := b.Prefs().ExitNodeID(); eid != peer1.StableID() { - t.Errorf("got initial exit node %v, want %v", eid, peer1.StableID()) - } - b.refreshAutoExitNode = true - b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 5 * time.Millisecond, - 3: 30 * time.Millisecond, - }, - PreferredDERP: 2, - }) - b.setNetInfo(&ni) - if eid := b.Prefs().ExitNodeID(); eid != peer2.StableID() { - t.Errorf("got final exit node %v, want %v", eid, peer2.StableID()) - } -} - -func TestSetControlClientStatusAutoExitNode(t *testing.T) { - peer1 := makePeer(1, withCap(26), withSuggest(), withExitRoutes(), withNodeKey()) - peer2 := makePeer(2, withCap(26), withSuggest(), withExitRoutes(), withNodeKey()) - derpMap := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - Nodes: []*tailcfg.DERPNode{ - { - Name: "t1", - RegionID: 1, - }, - }, - }, - 2: { - Nodes: []*tailcfg.DERPNode{ - { - Name: "t2", - RegionID: 2, - }, - }, - }, - }, - } - report := &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 5 * time.Millisecond, - 3: 30 * time.Millisecond, - }, - PreferredDERP: 1, - } - nm := &netmap.NetworkMap{ - Peers: []tailcfg.NodeView{ - peer1, - peer2, - }, - DERPMap: derpMap, - } - b := newTestLocalBackend(t) - syspolicy.RegisterWellKnownSettingsForTest(t) - policyStore := source.NewTestStoreOf(t, source.TestSettingOf( - syspolicy.ExitNodeID, "auto:any", - )) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - b.netMap = nm - b.lastSuggestedExitNode = peer1.StableID() - b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, report) - b.SetPrefsForTest(b.pm.CurrentPrefs().AsStruct()) - firstExitNode := b.Prefs().ExitNodeID() - newPeer1 := makePeer(1, withCap(26), withSuggest(), withExitRoutes(), withOnline(false), withNodeKey()) - updatedNetmap := &netmap.NetworkMap{ - Peers: []tailcfg.NodeView{ - newPeer1, - peer2, - }, - DERPMap: derpMap, - } - b.SetControlClientStatus(b.cc, controlclient.Status{NetMap: updatedNetmap}) - lastExitNode := b.Prefs().ExitNodeID() - if firstExitNode == lastExitNode { - t.Errorf("did not switch exit nodes despite auto exit node going offline") - } -} - -func TestApplySysPolicy(t *testing.T) { - tests := []struct { - name string - prefs ipn.Prefs - wantPrefs ipn.Prefs - wantAnyChange bool - stringPolicies map[syspolicy.Key]string - }{ - { - name: "empty prefs without policies", - }, - { - name: "prefs set without policies", - prefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - ExitNodeAllowLANAccess: true, - CorpDNS: true, - RouteAll: true, - }, - wantPrefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - ExitNodeAllowLANAccess: true, - CorpDNS: true, - RouteAll: true, - }, - }, - { - name: "empty prefs with policies", - wantPrefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - ExitNodeAllowLANAccess: true, - CorpDNS: true, - RouteAll: true, - }, - wantAnyChange: true, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.ControlURL: "1", - syspolicy.EnableIncomingConnections: "never", - syspolicy.EnableServerMode: "always", - syspolicy.ExitNodeAllowLANAccess: "always", - syspolicy.EnableTailscaleDNS: "always", - syspolicy.EnableTailscaleSubnets: "always", - }, - }, - { - name: "prefs set with matching policies", - prefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - }, - wantPrefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - }, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.ControlURL: "1", - syspolicy.EnableIncomingConnections: "never", - syspolicy.EnableServerMode: "always", - syspolicy.ExitNodeAllowLANAccess: "never", - syspolicy.EnableTailscaleDNS: "never", - syspolicy.EnableTailscaleSubnets: "never", - }, - }, - { - name: "prefs set with conflicting policies", - prefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - ExitNodeAllowLANAccess: false, - CorpDNS: true, - RouteAll: false, - }, - wantPrefs: ipn.Prefs{ - ControlURL: "2", - ShieldsUp: false, - ForceDaemon: false, - ExitNodeAllowLANAccess: true, - CorpDNS: false, - RouteAll: true, - }, - wantAnyChange: true, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.ControlURL: "2", - syspolicy.EnableIncomingConnections: "always", - syspolicy.EnableServerMode: "never", - syspolicy.ExitNodeAllowLANAccess: "always", - syspolicy.EnableTailscaleDNS: "never", - syspolicy.EnableTailscaleSubnets: "always", - }, - }, - { - name: "prefs set with neutral policies", - prefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - ExitNodeAllowLANAccess: false, - CorpDNS: true, - RouteAll: true, - }, - wantPrefs: ipn.Prefs{ - ControlURL: "1", - ShieldsUp: true, - ForceDaemon: true, - ExitNodeAllowLANAccess: false, - CorpDNS: true, - RouteAll: true, - }, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.EnableIncomingConnections: "user-decides", - syspolicy.EnableServerMode: "user-decides", - syspolicy.ExitNodeAllowLANAccess: "user-decides", - syspolicy.EnableTailscaleDNS: "user-decides", - syspolicy.EnableTailscaleSubnets: "user-decides", - }, - }, - { - name: "ControlURL", - wantPrefs: ipn.Prefs{ - ControlURL: "set", - }, - wantAnyChange: true, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.ControlURL: "set", - }, - }, - { - name: "enable AutoUpdate apply does not unset check", - prefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(false), - }, - }, - wantPrefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(true), - }, - }, - wantAnyChange: true, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.ApplyUpdates: "always", - }, - }, - { - name: "disable AutoUpdate apply does not unset check", - prefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(true), - }, - }, - wantPrefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(false), - }, - }, - wantAnyChange: true, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.ApplyUpdates: "never", - }, - }, - { - name: "enable AutoUpdate check does not unset apply", - prefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: false, - Apply: opt.NewBool(true), - }, - }, - wantPrefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(true), - }, - }, - wantAnyChange: true, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.CheckUpdates: "always", - }, - }, - { - name: "disable AutoUpdate check does not unset apply", - prefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(true), - }, - }, - wantPrefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Check: false, - Apply: opt.NewBool(true), - }, - }, - wantAnyChange: true, - stringPolicies: map[syspolicy.Key]string{ - syspolicy.CheckUpdates: "never", - }, - }, - } - - syspolicy.RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - settings := make([]source.TestSetting[string], 0, len(tt.stringPolicies)) - for p, v := range tt.stringPolicies { - settings = append(settings, source.TestSettingOf(p, v)) - } - policyStore := source.NewTestStoreOf(t, settings...) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - - t.Run("unit", func(t *testing.T) { - prefs := tt.prefs.Clone() - - gotAnyChange := applySysPolicy(prefs) - - if gotAnyChange && prefs.Equals(&tt.prefs) { - t.Errorf("anyChange but prefs is unchanged: %v", prefs.Pretty()) - } - if !gotAnyChange && !prefs.Equals(&tt.prefs) { - t.Errorf("!anyChange but prefs changed from %v to %v", tt.prefs.Pretty(), prefs.Pretty()) - } - if gotAnyChange != tt.wantAnyChange { - t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantAnyChange) - } - if !prefs.Equals(&tt.wantPrefs) { - t.Errorf("prefs=%v, want %v", prefs.Pretty(), tt.wantPrefs.Pretty()) - } - }) - - t.Run("status update", func(t *testing.T) { - // Profile manager fills in blank ControlURL but it's not set - // in most test cases to avoid cluttering them, so adjust for - // that. - usePrefs := tt.prefs.Clone() - if usePrefs.ControlURL == "" { - usePrefs.ControlURL = ipn.DefaultControlURL - } - wantPrefs := tt.wantPrefs.Clone() - if wantPrefs.ControlURL == "" { - wantPrefs.ControlURL = ipn.DefaultControlURL - } - - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - pm.prefs = usePrefs.View() - - b := newTestBackend(t) - b.mu.Lock() - b.pm = pm - b.mu.Unlock() - - b.SetControlClientStatus(b.cc, controlclient.Status{}) - if !b.Prefs().Equals(wantPrefs.View()) { - t.Errorf("prefs=%v, want %v", b.Prefs().Pretty(), wantPrefs.Pretty()) - } - }) - }) - } -} - -func TestPreferencePolicyInfo(t *testing.T) { - tests := []struct { - name string - initialValue bool - wantValue bool - wantChange bool - policyValue string - policyError error - }{ - { - name: "force enable modify", - initialValue: false, - wantValue: true, - wantChange: true, - policyValue: "always", - }, - { - name: "force enable unchanged", - initialValue: true, - wantValue: true, - policyValue: "always", - }, - { - name: "force disable modify", - initialValue: true, - wantValue: false, - wantChange: true, - policyValue: "never", - }, - { - name: "force disable unchanged", - initialValue: false, - wantValue: false, - policyValue: "never", - }, - { - name: "unforced enabled", - initialValue: true, - wantValue: true, - policyValue: "user-decides", - }, - { - name: "unforced disabled", - initialValue: false, - wantValue: false, - policyValue: "user-decides", - }, - { - name: "blank enabled", - initialValue: true, - wantValue: true, - policyValue: "", - }, - { - name: "blank disabled", - initialValue: false, - wantValue: false, - policyValue: "", - }, - { - name: "unset enabled", - initialValue: true, - wantValue: true, - policyError: syspolicy.ErrNoSuchKey, - }, - { - name: "unset disabled", - initialValue: false, - wantValue: false, - policyError: syspolicy.ErrNoSuchKey, - }, - { - name: "error enabled", - initialValue: true, - wantValue: true, - policyError: errors.New("test error"), - }, - { - name: "error disabled", - initialValue: false, - wantValue: false, - policyError: errors.New("test error"), - }, - } - - syspolicy.RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for _, pp := range preferencePolicies { - t.Run(string(pp.key), func(t *testing.T) { - s := source.TestSetting[string]{ - Key: pp.key, - Error: tt.policyError, - Value: tt.policyValue, - } - policyStore := source.NewTestStoreOf(t, s) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - - prefs := defaultPrefs.AsStruct() - pp.set(prefs, tt.initialValue) - - gotAnyChange := applySysPolicy(prefs) - - if gotAnyChange != tt.wantChange { - t.Errorf("anyChange=%v, want %v", gotAnyChange, tt.wantChange) - } - got := pp.get(prefs.View()) - if got != tt.wantValue { - t.Errorf("pref=%v, want %v", got, tt.wantValue) - } - }) - } - }) - } -} - -func TestOnTailnetDefaultAutoUpdate(t *testing.T) { - tests := []struct { - before, after opt.Bool - container opt.Bool - tailnetDefault bool - }{ - { - before: opt.Bool(""), - tailnetDefault: true, - after: opt.NewBool(true), - }, - { - before: opt.Bool(""), - tailnetDefault: false, - after: opt.NewBool(false), - }, - { - before: opt.Bool("unset"), - tailnetDefault: true, - after: opt.NewBool(true), - }, - { - before: opt.Bool("unset"), - tailnetDefault: false, - after: opt.NewBool(false), - }, - { - before: opt.NewBool(false), - tailnetDefault: true, - after: opt.NewBool(false), - }, - { - before: opt.NewBool(true), - tailnetDefault: false, - after: opt.NewBool(true), - }, - { - before: opt.Bool(""), - container: opt.NewBool(true), - tailnetDefault: true, - after: opt.Bool(""), - }, - { - before: opt.NewBool(false), - container: opt.NewBool(true), - tailnetDefault: true, - after: opt.NewBool(false), - }, - { - before: opt.NewBool(true), - container: opt.NewBool(true), - tailnetDefault: false, - after: opt.NewBool(true), - }, - } - for _, tt := range tests { - t.Run(fmt.Sprintf("before=%s,after=%s", tt.before, tt.after), func(t *testing.T) { - b := newTestBackend(t) - b.hostinfo = hostinfo.New() - b.hostinfo.Container = tt.container - p := ipn.NewPrefs() - p.AutoUpdate.Apply = tt.before - if err := b.pm.setPrefsNoPermCheck(p.View()); err != nil { - t.Fatal(err) - } - b.onTailnetDefaultAutoUpdate(tt.tailnetDefault) - want := tt.after - // On platforms that don't support auto-update we can never - // transition to auto-updates being enabled. The value should - // remain unchanged after onTailnetDefaultAutoUpdate. - if !clientupdate.CanAutoUpdate() && want.EqualBool(true) { - want = tt.before - } - if got := b.pm.CurrentPrefs().AutoUpdate().Apply; got != want { - t.Errorf("got: %q, want %q", got, want) - } - }) - } -} - -func TestTCPHandlerForDst(t *testing.T) { - b := newTestBackend(t) - - tests := []struct { - desc string - dst string - intercept bool - }{ - { - desc: "intercept port 80 (Web UI) on quad100 IPv4", - dst: "100.100.100.100:80", - intercept: true, - }, - { - desc: "intercept port 80 (Web UI) on quad100 IPv6", - dst: "[fd7a:115c:a1e0::53]:80", - intercept: true, - }, - { - desc: "don't intercept port 80 on local ip", - dst: "100.100.103.100:80", - intercept: false, - }, - { - desc: "intercept port 8080 (Taildrive) on quad100 IPv4", - dst: "100.100.100.100:8080", - intercept: true, - }, - { - desc: "intercept port 8080 (Taildrive) on quad100 IPv6", - dst: "[fd7a:115c:a1e0::53]:8080", - intercept: true, - }, - { - desc: "don't intercept port 8080 on local ip", - dst: "100.100.103.100:8080", - intercept: false, - }, - { - desc: "don't intercept port 9080 on quad100 IPv4", - dst: "100.100.100.100:9080", - intercept: false, - }, - { - desc: "don't intercept port 9080 on quad100 IPv6", - dst: "[fd7a:115c:a1e0::53]:9080", - intercept: false, - }, - { - desc: "don't intercept port 9080 on local ip", - dst: "100.100.103.100:9080", - intercept: false, - }, - } - - for _, tt := range tests { - t.Run(tt.dst, func(t *testing.T) { - t.Log(tt.desc) - src := netip.MustParseAddrPort("100.100.102.100:51234") - h, _ := b.TCPHandlerForDst(src, netip.MustParseAddrPort(tt.dst)) - if !tt.intercept && h != nil { - t.Error("intercepted traffic we shouldn't have") - } else if tt.intercept && h == nil { - t.Error("failed to intercept traffic we should have") - } - }) - } -} - -func TestDriveManageShares(t *testing.T) { - tests := []struct { - name string - disabled bool - existing []*drive.Share - add *drive.Share - remove string - rename [2]string - expect any - }{ - { - name: "append", - existing: []*drive.Share{ - {Name: "b"}, - {Name: "d"}, - }, - add: &drive.Share{Name: " E "}, - expect: []*drive.Share{ - {Name: "b"}, - {Name: "d"}, - {Name: "e"}, - }, - }, - { - name: "prepend", - existing: []*drive.Share{ - {Name: "b"}, - {Name: "d"}, - }, - add: &drive.Share{Name: " A "}, - expect: []*drive.Share{ - {Name: "a"}, - {Name: "b"}, - {Name: "d"}, - }, - }, - { - name: "insert", - existing: []*drive.Share{ - {Name: "b"}, - {Name: "d"}, - }, - add: &drive.Share{Name: " C "}, - expect: []*drive.Share{ - {Name: "b"}, - {Name: "c"}, - {Name: "d"}, - }, - }, - { - name: "replace", - existing: []*drive.Share{ - {Name: "b", Path: "i"}, - {Name: "d"}, - }, - add: &drive.Share{Name: " B ", Path: "ii"}, - expect: []*drive.Share{ - {Name: "b", Path: "ii"}, - {Name: "d"}, - }, - }, - { - name: "add_bad_name", - add: &drive.Share{Name: "$"}, - expect: drive.ErrInvalidShareName, - }, - { - name: "add_disabled", - disabled: true, - add: &drive.Share{Name: "a"}, - expect: drive.ErrDriveNotEnabled, - }, - { - name: "remove", - existing: []*drive.Share{ - {Name: "a"}, - {Name: "b"}, - {Name: "c"}, - }, - remove: "b", - expect: []*drive.Share{ - {Name: "a"}, - {Name: "c"}, - }, - }, - { - name: "remove_non_existing", - existing: []*drive.Share{ - {Name: "a"}, - {Name: "b"}, - {Name: "c"}, - }, - remove: "D", - expect: os.ErrNotExist, - }, - { - name: "remove_disabled", - disabled: true, - remove: "b", - expect: drive.ErrDriveNotEnabled, - }, - { - name: "rename", - existing: []*drive.Share{ - {Name: "a"}, - {Name: "b"}, - }, - rename: [2]string{"a", " C "}, - expect: []*drive.Share{ - {Name: "b"}, - {Name: "c"}, - }, - }, - { - name: "rename_not_exist", - existing: []*drive.Share{ - {Name: "a"}, - {Name: "b"}, - }, - rename: [2]string{"d", "c"}, - expect: os.ErrNotExist, - }, - { - name: "rename_exists", - existing: []*drive.Share{ - {Name: "a"}, - {Name: "b"}, - }, - rename: [2]string{"a", "b"}, - expect: os.ErrExist, - }, - { - name: "rename_bad_name", - rename: [2]string{"a", "$"}, - expect: drive.ErrInvalidShareName, - }, - { - name: "rename_disabled", - disabled: true, - rename: [2]string{"a", "c"}, - expect: drive.ErrDriveNotEnabled, - }, - } - - drive.DisallowShareAs = true - t.Cleanup(func() { - drive.DisallowShareAs = false - }) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b := newTestBackend(t) - b.mu.Lock() - if tt.existing != nil { - b.driveSetSharesLocked(tt.existing) - } - if !tt.disabled { - self := b.netMap.SelfNode.AsStruct() - self.CapMap = tailcfg.NodeCapMap{tailcfg.NodeAttrsTaildriveShare: nil} - b.netMap.SelfNode = self.View() - b.sys.Set(driveimpl.NewFileSystemForRemote(b.logf)) - } - b.mu.Unlock() - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - t.Cleanup(cancel) - - result := make(chan views.SliceView[*drive.Share, drive.ShareView], 1) - - var wg sync.WaitGroup - wg.Add(1) - go b.WatchNotifications( - ctx, - 0, - func() { wg.Done() }, - func(n *ipn.Notify) bool { - select { - case result <- n.DriveShares: - default: - // - } - return false - }, - ) - wg.Wait() - - var err error - switch { - case tt.add != nil: - err = b.DriveSetShare(tt.add) - case tt.remove != "": - err = b.DriveRemoveShare(tt.remove) - default: - err = b.DriveRenameShare(tt.rename[0], tt.rename[1]) - } - - switch e := tt.expect.(type) { - case error: - if !errors.Is(err, e) { - t.Errorf("expected error, want: %v got: %v", e, err) - } - case []*drive.Share: - if err != nil { - t.Errorf("unexpected error: %v", err) - } else { - r := <-result - - got, err := json.MarshalIndent(r, "", " ") - if err != nil { - t.Fatalf("can't marshal got: %v", err) - } - want, err := json.MarshalIndent(e, "", " ") - if err != nil { - t.Fatalf("can't marshal want: %v", err) - } - if diff := cmp.Diff(string(got), string(want)); diff != "" { - t.Errorf("wrong shares; (-got+want):%v", diff) - } - } - } - }) - } -} - -func TestValidPopBrowserURL(t *testing.T) { - b := newTestBackend(t) - tests := []struct { - desc string - controlURL string - popBrowserURL string - want bool - }{ - {"saas_login", "https://login.tailscale.com", "https://login.tailscale.com/a/foo", true}, - {"saas_controlplane", "https://controlplane.tailscale.com", "https://controlplane.tailscale.com/a/foo", true}, - {"saas_root", "https://login.tailscale.com", "https://tailscale.com/", true}, - {"saas_bad_hostname", "https://login.tailscale.com", "https://example.com/a/foo", false}, - {"localhost", "http://localhost", "http://localhost/a/foo", true}, - {"custom_control_url_https", "https://example.com", "https://example.com/a/foo", true}, - {"custom_control_url_https_diff_domain", "https://example.com", "https://other.com/a/foo", true}, - {"custom_control_url_http", "http://example.com", "http://example.com/a/foo", true}, - {"custom_control_url_http_diff_domain", "http://example.com", "http://other.com/a/foo", true}, - {"bad_scheme", "https://example.com", "http://example.com/a/foo", false}, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - if _, err := b.EditPrefs(&ipn.MaskedPrefs{ - ControlURLSet: true, - Prefs: ipn.Prefs{ - ControlURL: tt.controlURL, - }, - }); err != nil { - t.Fatal(err) - } - - got := b.validPopBrowserURL(tt.popBrowserURL) - if got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestRoundTraffic(t *testing.T) { - tests := []struct { - name string - bytes int64 - want float64 - }{ - {name: "under 5 bytes", bytes: 4, want: 4}, - {name: "under 1000 bytes", bytes: 987, want: 990}, - {name: "under 10_000 bytes", bytes: 8875, want: 8900}, - {name: "under 100_000 bytes", bytes: 77777, want: 78000}, - {name: "under 1_000_000 bytes", bytes: 666523, want: 670000}, - {name: "under 10_000_000 bytes", bytes: 22556677, want: 23000000}, - {name: "under 1_000_000_000 bytes", bytes: 1234234234, want: 1200000000}, - {name: "under 1_000_000_000 bytes", bytes: 123423423499, want: 123400000000}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if result := roundTraffic(tt.bytes); result != tt.want { - t.Errorf("unexpected rounding got %v want %v", result, tt.want) - } - }) - } -} - -func (b *LocalBackend) SetPrefsForTest(newp *ipn.Prefs) { - if newp == nil { - panic("SetPrefsForTest got nil prefs") - } - unlock := b.lockAndGetUnlock() - defer unlock() - b.setPrefsLockedOnEntry(newp, unlock) -} - -type peerOptFunc func(*tailcfg.Node) - -func makePeer(id tailcfg.NodeID, opts ...peerOptFunc) tailcfg.NodeView { - node := &tailcfg.Node{ - ID: id, - StableID: tailcfg.StableNodeID(fmt.Sprintf("stable%d", id)), - Name: fmt.Sprintf("peer%d", id), - DERP: fmt.Sprintf("127.3.3.40:%d", id), - } - for _, opt := range opts { - opt(node) - } - return node.View() -} - -func withName(name string) peerOptFunc { - return func(n *tailcfg.Node) { - n.Name = name - } -} - -func withDERP(region int) peerOptFunc { - return func(n *tailcfg.Node) { - n.DERP = fmt.Sprintf("127.3.3.40:%d", region) - } -} - -func withoutDERP() peerOptFunc { - return func(n *tailcfg.Node) { - n.DERP = "" - } -} - -func withLocation(loc tailcfg.LocationView) peerOptFunc { - return func(n *tailcfg.Node) { - var hi *tailcfg.Hostinfo - if n.Hostinfo.Valid() { - hi = n.Hostinfo.AsStruct() - } else { - hi = new(tailcfg.Hostinfo) - } - hi.Location = loc.AsStruct() - - n.Hostinfo = hi.View() - } -} - -func withExitRoutes() peerOptFunc { - return func(n *tailcfg.Node) { - n.AllowedIPs = append(n.AllowedIPs, tsaddr.ExitRoutes()...) - } -} - -func withSuggest() peerOptFunc { - return func(n *tailcfg.Node) { - mak.Set(&n.CapMap, tailcfg.NodeAttrSuggestExitNode, []tailcfg.RawMessage{}) - } -} - -func withCap(version tailcfg.CapabilityVersion) peerOptFunc { - return func(n *tailcfg.Node) { - n.Cap = version - } -} - -func withOnline(isOnline bool) peerOptFunc { - return func(n *tailcfg.Node) { - n.Online = &isOnline - } -} - -func withNodeKey() peerOptFunc { - return func(n *tailcfg.Node) { - n.Key = key.NewNode().Public() - } -} - -func deterministicRegionForTest(t testing.TB, want views.Slice[int], use int) selectRegionFunc { - t.Helper() - - if !views.SliceContains(want, use) { - t.Errorf("invalid test: use %v is not in want %v", use, want) - } - - return func(got views.Slice[int]) int { - if !views.SliceEqualAnyOrder(got, want) { - t.Errorf("candidate regions = %v, want %v", got, want) - } - return use - } -} - -func deterministicNodeForTest(t testing.TB, want views.Slice[tailcfg.StableNodeID], wantLast tailcfg.StableNodeID, use tailcfg.StableNodeID) selectNodeFunc { - t.Helper() - - if !views.SliceContains(want, use) { - t.Errorf("invalid test: use %v is not in want %v", use, want) - } - - return func(got views.Slice[tailcfg.NodeView], last tailcfg.StableNodeID) tailcfg.NodeView { - var ret tailcfg.NodeView - - gotIDs := make([]tailcfg.StableNodeID, got.Len()) - for i, nv := range got.All() { - if !nv.Valid() { - t.Fatalf("invalid node at index %v", i) - } - gotIDs[i] = nv.StableID() - if nv.StableID() == use { - ret = nv - } - } - if !views.SliceEqualAnyOrder(views.SliceOf(gotIDs), want) { - t.Errorf("candidate nodes = %v, want %v", gotIDs, want) - } - if last != wantLast { - t.Errorf("last node = %v, want %v", last, wantLast) - } - if !ret.Valid() { - t.Fatalf("did not find matching node in %v, want %v", gotIDs, use) - } - - return ret - } -} - -func TestSuggestExitNode(t *testing.T) { - t.Parallel() - - defaultDERPMap := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - Latitude: 32, - Longitude: -97, - }, - 2: {}, - 3: {}, - }, - } - - preferred1Report := &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - }, - PreferredDERP: 1, - } - noLatency1Report := &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 0, - 2: 0, - 3: 0, - }, - PreferredDERP: 1, - } - preferredNoneReport := &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - }, - PreferredDERP: 0, - } - - dallas := tailcfg.Location{ - Latitude: 32.779167, - Longitude: -96.808889, - Priority: 100, - } - sanJose := tailcfg.Location{ - Latitude: 37.3382082, - Longitude: -121.8863286, - Priority: 20, - } - fortWorth := tailcfg.Location{ - Latitude: 32.756389, - Longitude: -97.3325, - Priority: 150, - } - fortWorthLowPriority := tailcfg.Location{ - Latitude: 32.756389, - Longitude: -97.3325, - Priority: 100, - } - - peer1 := makePeer(1, - withExitRoutes(), - withSuggest()) - peer2DERP1 := makePeer(2, - withDERP(1), - withExitRoutes(), - withSuggest()) - peer3 := makePeer(3, - withExitRoutes(), - withSuggest()) - peer4DERP3 := makePeer(4, - withDERP(3), - withExitRoutes(), - withSuggest()) - dallasPeer5 := makePeer(5, - withName("Dallas"), - withoutDERP(), - withExitRoutes(), - withSuggest(), - withLocation(dallas.View())) - sanJosePeer6 := makePeer(6, - withName("San Jose"), - withoutDERP(), - withExitRoutes(), - withSuggest(), - withLocation(sanJose.View())) - fortWorthPeer7 := makePeer(7, - withName("Fort Worth"), - withoutDERP(), - withExitRoutes(), - withSuggest(), - withLocation(fortWorth.View())) - fortWorthPeer8LowPriority := makePeer(8, - withName("Fort Worth Low"), - withoutDERP(), - withExitRoutes(), - withSuggest(), - withLocation(fortWorthLowPriority.View())) - - selfNode := tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - netip.MustParsePrefix("fe70::1/128"), - }, - } - - defaultNetmap := &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - peer2DERP1, - peer3, - }, - } - locationNetmap := &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - dallasPeer5, - sanJosePeer6, - }, - } - largeNetmap := &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - peer1, - peer2DERP1, - peer3, - peer4DERP3, - dallasPeer5, - sanJosePeer6, - fortWorthPeer7, - }, - } - - tests := []struct { - name string - - lastReport *netcheck.Report - netMap *netmap.NetworkMap - lastSuggestion tailcfg.StableNodeID - - allowPolicy []tailcfg.StableNodeID - - wantRegions []int - useRegion int - - wantNodes []tailcfg.StableNodeID - - wantID tailcfg.StableNodeID - wantName string - wantLocation tailcfg.LocationView - - wantError error - }{ - { - name: "2 exit nodes in same region", - lastReport: preferred1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - peer1, - peer2DERP1, - }, - }, - wantNodes: []tailcfg.StableNodeID{ - "stable1", - "stable2", - }, - wantName: "peer1", - wantID: "stable1", - }, - { - name: "2 exit nodes different regions unknown latency", - lastReport: noLatency1Report, - netMap: defaultNetmap, - wantRegions: []int{1, 3}, // the only regions with peers - useRegion: 1, - wantName: "peer2", - wantID: "stable2", - }, - { - name: "2 derp based exit nodes, different regions, equal latency", - lastReport: &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10, - 2: 20, - 3: 10, - }, - PreferredDERP: 1, - }, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - peer1, - peer3, - }, - }, - wantRegions: []int{1, 2}, - useRegion: 1, - wantName: "peer1", - wantID: "stable1", - }, - { - name: "mullvad nodes, no derp based exit nodes", - lastReport: noLatency1Report, - netMap: locationNetmap, - wantID: "stable5", - wantLocation: dallas.View(), - wantName: "Dallas", - }, - { - name: "nearby mullvad nodes with different priorities", - lastReport: noLatency1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - dallasPeer5, - sanJosePeer6, - fortWorthPeer7, - }, - }, - wantID: "stable7", - wantLocation: fortWorth.View(), - wantName: "Fort Worth", - }, - { - name: "nearby mullvad nodes with same priorities", - lastReport: noLatency1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - dallasPeer5, - sanJosePeer6, - fortWorthPeer8LowPriority, - }, - }, - wantNodes: []tailcfg.StableNodeID{"stable5", "stable8"}, - wantID: "stable5", - wantLocation: dallas.View(), - wantName: "Dallas", - }, - { - name: "mullvad nodes, remaining node is not in preferred derp", - lastReport: noLatency1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - dallasPeer5, - sanJosePeer6, - peer4DERP3, - }, - }, - useRegion: 3, - wantID: "stable4", - wantName: "peer4", - }, - { - name: "no peers", - lastReport: noLatency1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - }, - }, - { - name: "nil report", - lastReport: nil, - netMap: largeNetmap, - wantError: ErrNoPreferredDERP, - }, - { - name: "no preferred derp region", - lastReport: preferredNoneReport, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - }, - wantError: ErrNoPreferredDERP, - }, - { - name: "nil netmap", - lastReport: noLatency1Report, - netMap: nil, - wantError: ErrNoPreferredDERP, - }, - { - name: "nil derpmap", - lastReport: noLatency1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: nil, - Peers: []tailcfg.NodeView{ - dallasPeer5, - }, - }, - wantError: ErrNoPreferredDERP, - }, - { - name: "missing suggestion capability", - lastReport: noLatency1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - makePeer(1, withExitRoutes()), - makePeer(2, withLocation(dallas.View()), withExitRoutes()), - }, - }, - }, - { - name: "prefer last node", - lastReport: preferred1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - peer1, - peer2DERP1, - }, - }, - lastSuggestion: "stable2", - wantNodes: []tailcfg.StableNodeID{ - "stable1", - "stable2", - }, - wantName: "peer2", - wantID: "stable2", - }, - { - name: "found better derp node", - lastSuggestion: "stable3", - lastReport: preferred1Report, - netMap: defaultNetmap, - wantID: "stable2", - wantName: "peer2", - }, - { - name: "prefer last mullvad node", - lastSuggestion: "stable2", - lastReport: preferred1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - dallasPeer5, - sanJosePeer6, - fortWorthPeer8LowPriority, - }, - }, - wantNodes: []tailcfg.StableNodeID{"stable5", "stable8"}, - wantID: "stable5", - wantName: "Dallas", - wantLocation: dallas.View(), - }, - { - name: "prefer better mullvad node", - lastSuggestion: "stable2", - lastReport: preferred1Report, - netMap: &netmap.NetworkMap{ - SelfNode: selfNode.View(), - DERPMap: defaultDERPMap, - Peers: []tailcfg.NodeView{ - dallasPeer5, - sanJosePeer6, - fortWorthPeer7, - }, - }, - wantNodes: []tailcfg.StableNodeID{"stable7"}, - wantID: "stable7", - wantName: "Fort Worth", - wantLocation: fortWorth.View(), - }, - { - name: "large netmap", - lastReport: preferred1Report, - netMap: largeNetmap, - wantNodes: []tailcfg.StableNodeID{"stable1", "stable2"}, - wantID: "stable2", - wantName: "peer2", - }, - { - name: "no allowed suggestions", - lastReport: preferred1Report, - netMap: largeNetmap, - allowPolicy: []tailcfg.StableNodeID{}, - }, - { - name: "only derp suggestions", - lastReport: preferred1Report, - netMap: largeNetmap, - allowPolicy: []tailcfg.StableNodeID{"stable1", "stable2", "stable3"}, - wantNodes: []tailcfg.StableNodeID{"stable1", "stable2"}, - wantID: "stable2", - wantName: "peer2", - }, - { - name: "only mullvad suggestions", - lastReport: preferred1Report, - netMap: largeNetmap, - allowPolicy: []tailcfg.StableNodeID{"stable5", "stable6", "stable7"}, - wantID: "stable7", - wantName: "Fort Worth", - wantLocation: fortWorth.View(), - }, - { - name: "only worst derp", - lastReport: preferred1Report, - netMap: largeNetmap, - allowPolicy: []tailcfg.StableNodeID{"stable3"}, - wantID: "stable3", - wantName: "peer3", - }, - { - name: "only worst mullvad", - lastReport: preferred1Report, - netMap: largeNetmap, - allowPolicy: []tailcfg.StableNodeID{"stable6"}, - wantID: "stable6", - wantName: "San Jose", - wantLocation: sanJose.View(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - wantRegions := tt.wantRegions - if wantRegions == nil { - wantRegions = []int{tt.useRegion} - } - selectRegion := deterministicRegionForTest(t, views.SliceOf(wantRegions), tt.useRegion) - - wantNodes := tt.wantNodes - if wantNodes == nil { - wantNodes = []tailcfg.StableNodeID{tt.wantID} - } - selectNode := deterministicNodeForTest(t, views.SliceOf(wantNodes), tt.lastSuggestion, tt.wantID) - - var allowList set.Set[tailcfg.StableNodeID] - if tt.allowPolicy != nil { - allowList = set.SetOf(tt.allowPolicy) - } - - got, err := suggestExitNode(tt.lastReport, tt.netMap, tt.lastSuggestion, selectRegion, selectNode, allowList) - if got.Name != tt.wantName { - t.Errorf("name=%v, want %v", got.Name, tt.wantName) - } - if got.ID != tt.wantID { - t.Errorf("ID=%v, want %v", got.ID, tt.wantID) - } - if tt.wantError == nil && err != nil { - t.Errorf("err=%v, want no error", err) - } - if tt.wantError != nil && !errors.Is(err, tt.wantError) { - t.Errorf("err=%v, want %v", err, tt.wantError) - } - if !reflect.DeepEqual(got.Location, tt.wantLocation) { - t.Errorf("location=%v, want %v", got.Location, tt.wantLocation) - } - }) - } -} - -func TestSuggestExitNodePickWeighted(t *testing.T) { - location10 := tailcfg.Location{ - Priority: 10, - } - location20 := tailcfg.Location{ - Priority: 20, - } - - tests := []struct { - name string - candidates []tailcfg.NodeView - wantIDs []tailcfg.StableNodeID - }{ - { - name: "different priorities", - candidates: []tailcfg.NodeView{ - makePeer(2, withExitRoutes(), withLocation(location20.View())), - makePeer(3, withExitRoutes(), withLocation(location10.View())), - }, - wantIDs: []tailcfg.StableNodeID{"stable2"}, - }, - { - name: "same priorities", - candidates: []tailcfg.NodeView{ - makePeer(2, withExitRoutes(), withLocation(location10.View())), - makePeer(3, withExitRoutes(), withLocation(location10.View())), - }, - wantIDs: []tailcfg.StableNodeID{"stable2", "stable3"}, - }, - { - name: "<1 candidates", - candidates: []tailcfg.NodeView{}, - }, - { - name: "1 candidate", - candidates: []tailcfg.NodeView{ - makePeer(2, withExitRoutes(), withLocation(location20.View())), - }, - wantIDs: []tailcfg.StableNodeID{"stable2"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := pickWeighted(tt.candidates) - gotIDs := make([]tailcfg.StableNodeID, 0, len(got)) - for _, n := range got { - if !n.Valid() { - gotIDs = append(gotIDs, "") - continue - } - gotIDs = append(gotIDs, n.StableID()) - } - if !views.SliceEqualAnyOrder(views.SliceOf(gotIDs), views.SliceOf(tt.wantIDs)) { - t.Errorf("node IDs = %v, want %v", gotIDs, tt.wantIDs) - } - }) - } -} - -func TestSuggestExitNodeLongLatDistance(t *testing.T) { - tests := []struct { - name string - fromLat float64 - fromLong float64 - toLat float64 - toLong float64 - want float64 - }{ - { - name: "zero values", - fromLat: 0, - fromLong: 0, - toLat: 0, - toLong: 0, - want: 0, - }, - { - name: "valid values", - fromLat: 40.73061, - fromLong: -73.935242, - toLat: 37.3382082, - toLong: -121.8863286, - want: 4117266.873301274, - }, - { - name: "valid values, locations in north and south of equator", - fromLat: 40.73061, - fromLong: -73.935242, - toLat: -33.861481, - toLong: 151.205475, - want: 15994089.144368416, - }, - } - // The wanted values are computed using a more precise algorithm using the WGS84 model but - // longLatDistance uses a spherical approximation for simplicity. To account for this, we allow for - // 10km of error. - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := longLatDistance(tt.fromLat, tt.fromLong, tt.toLat, tt.toLong) - const maxError = 10000 // 10km - if math.Abs(got-tt.want) > maxError { - t.Errorf("distance=%vm, want within %vm of %vm", got, maxError, tt.want) - } - }) - } -} - -func TestMinLatencyDERPregion(t *testing.T) { - tests := []struct { - name string - regions []int - report *netcheck.Report - wantRegion int - }{ - { - name: "regions, no latency values", - regions: []int{1, 2, 3}, - wantRegion: 0, - report: &netcheck.Report{}, - }, - { - name: "regions, different latency values", - regions: []int{1, 2, 3}, - wantRegion: 2, - report: &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 5 * time.Millisecond, - 3: 30 * time.Millisecond, - }, - }, - }, - { - name: "regions, same values", - regions: []int{1, 2, 3}, - wantRegion: 1, - report: &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 10 * time.Millisecond, - 3: 10 * time.Millisecond, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := minLatencyDERPRegion(tt.regions, tt.report) - if got != tt.wantRegion { - t.Errorf("got region %v want region %v", got, tt.wantRegion) - } - }) - } -} - -func TestShouldAutoExitNode(t *testing.T) { - tests := []struct { - name string - exitNodeIDPolicyValue string - expectedBool bool - }{ - { - name: "auto:any", - exitNodeIDPolicyValue: "auto:any", - expectedBool: true, - }, - { - name: "no auto prefix", - exitNodeIDPolicyValue: "foo", - expectedBool: false, - }, - { - name: "auto prefix but empty suffix", - exitNodeIDPolicyValue: "auto:", - expectedBool: false, - }, - { - name: "auto prefix no colon", - exitNodeIDPolicyValue: "auto", - expectedBool: false, - }, - { - name: "auto prefix invalid suffix", - exitNodeIDPolicyValue: "auto:foo", - expectedBool: false, - }, - } - - syspolicy.RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - policyStore := source.NewTestStoreOf(t, source.TestSettingOf( - syspolicy.ExitNodeID, tt.exitNodeIDPolicyValue, - )) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - - got := shouldAutoExitNode() - if got != tt.expectedBool { - t.Fatalf("expected %v got %v for %v policy value", tt.expectedBool, got, tt.exitNodeIDPolicyValue) - } - }) - } -} - -func TestEnableAutoUpdates(t *testing.T) { - lb := newTestLocalBackend(t) - - _, err := lb.EditPrefs(&ipn.MaskedPrefs{ - AutoUpdateSet: ipn.AutoUpdatePrefsMask{ - ApplySet: true, - }, - Prefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Apply: opt.NewBool(true), - }, - }, - }) - // Enabling may fail, depending on which environment we are running this - // test in. - wantErr := !clientupdate.CanAutoUpdate() - gotErr := err != nil - if gotErr != wantErr { - t.Fatalf("enabling auto-updates: got error: %v (%v); want error: %v", gotErr, err, wantErr) - } - - // Disabling should always succeed. - if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ - AutoUpdateSet: ipn.AutoUpdatePrefsMask{ - ApplySet: true, - }, - Prefs: ipn.Prefs{ - AutoUpdate: ipn.AutoUpdatePrefs{ - Apply: opt.NewBool(false), - }, - }, - }); err != nil { - t.Fatalf("disabling auto-updates: got error: %v", err) - } -} - -func TestReadWriteRouteInfo(t *testing.T) { - // set up a backend with more than one profile - b := newTestBackend(t) - prof1 := ipn.LoginProfile{ID: "id1", Key: "key1"} - prof2 := ipn.LoginProfile{ID: "id2", Key: "key2"} - b.pm.knownProfiles["id1"] = &prof1 - b.pm.knownProfiles["id2"] = &prof2 - b.pm.currentProfile = &prof1 - - // set up routeInfo - ri1 := &appc.RouteInfo{} - ri1.Wildcards = []string{"1"} - - ri2 := &appc.RouteInfo{} - ri2.Wildcards = []string{"2"} - - // read before write - readRi, err := b.readRouteInfoLocked() - if readRi != nil { - t.Fatalf("read before writing: want nil, got %v", readRi) - } - if err != ipn.ErrStateNotExist { - t.Fatalf("read before writing: want %v, got %v", ipn.ErrStateNotExist, err) - } - - // write the first routeInfo - if err := b.storeRouteInfo(ri1); err != nil { - t.Fatal(err) - } - - // write the other routeInfo as the other profile - if err := b.pm.SwitchProfile("id2"); err != nil { - t.Fatal(err) - } - if err := b.storeRouteInfo(ri2); err != nil { - t.Fatal(err) - } - - // read the routeInfo of the first profile - if err := b.pm.SwitchProfile("id1"); err != nil { - t.Fatal(err) - } - readRi, err = b.readRouteInfoLocked() - if err != nil { - t.Fatal(err) - } - if !slices.Equal(readRi.Wildcards, ri1.Wildcards) { - t.Fatalf("read prof1 routeInfo wildcards: want %v, got %v", ri1.Wildcards, readRi.Wildcards) - } - - // read the routeInfo of the second profile - if err := b.pm.SwitchProfile("id2"); err != nil { - t.Fatal(err) - } - readRi, err = b.readRouteInfoLocked() - if err != nil { - t.Fatal(err) - } - if !slices.Equal(readRi.Wildcards, ri2.Wildcards) { - t.Fatalf("read prof2 routeInfo wildcards: want %v, got %v", ri2.Wildcards, readRi.Wildcards) - } -} - -func TestFillAllowedSuggestions(t *testing.T) { - tests := []struct { - name string - allowPolicy []string - want []tailcfg.StableNodeID - }{ - { - name: "unset", - }, - { - name: "zero", - allowPolicy: []string{}, - want: []tailcfg.StableNodeID{}, - }, - { - name: "one", - allowPolicy: []string{"one"}, - want: []tailcfg.StableNodeID{"one"}, - }, - { - name: "many", - allowPolicy: []string{"one", "two", "three", "four"}, - want: []tailcfg.StableNodeID{"one", "three", "four", "two"}, // order should not matter - }, - { - name: "preserve case", - allowPolicy: []string{"ABC", "def", "gHiJ"}, - want: []tailcfg.StableNodeID{"ABC", "def", "gHiJ"}, - }, - } - syspolicy.RegisterWellKnownSettingsForTest(t) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - policyStore := source.NewTestStoreOf(t, source.TestSettingOf( - syspolicy.AllowedSuggestedExitNodes, tt.allowPolicy, - )) - syspolicy.MustRegisterStoreForTest(t, "TestStore", setting.DeviceScope, policyStore) - - got := fillAllowedSuggestions() - if got == nil { - if tt.want == nil { - return - } - t.Errorf("got nil, want %v", tt.want) - } - if tt.want == nil { - t.Errorf("got %v, want nil", got) - } - - if !got.Equal(set.SetOf(tt.want)) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestNotificationTargetMatch(t *testing.T) { - tests := []struct { - name string - target notificationTarget - actor ipnauth.Actor - wantMatch bool - }{ - { - name: "AllClients/Nil", - target: allClients, - actor: nil, - wantMatch: true, - }, - { - name: "AllClients/NoUID/NoCID", - target: allClients, - actor: &ipnauth.TestActor{}, - wantMatch: true, - }, - { - name: "AllClients/WithUID/NoCID", - target: allClients, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.NoClientID}, - wantMatch: true, - }, - { - name: "AllClients/NoUID/WithCID", - target: allClients, - actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")}, - wantMatch: true, - }, - { - name: "AllClients/WithUID/WithCID", - target: allClients, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")}, - wantMatch: true, - }, - { - name: "FilterByUID/Nil", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: nil, - wantMatch: false, - }, - { - name: "FilterByUID/NoUID/NoCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: &ipnauth.TestActor{}, - wantMatch: false, - }, - { - name: "FilterByUID/NoUID/WithCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")}, - wantMatch: false, - }, - { - name: "FilterByUID/SameUID/NoCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4"}, - wantMatch: true, - }, - { - name: "FilterByUID/DifferentUID/NoCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8"}, - wantMatch: false, - }, - { - name: "FilterByUID/SameUID/WithCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")}, - wantMatch: true, - }, - { - name: "FilterByUID/DifferentUID/WithCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8", CID: ipnauth.ClientIDFrom("A")}, - wantMatch: false, - }, - { - name: "FilterByCID/Nil", - target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")}, - actor: nil, - wantMatch: false, - }, - { - name: "FilterByCID/NoUID/NoCID", - target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{}, - wantMatch: false, - }, - { - name: "FilterByCID/NoUID/SameCID", - target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")}, - wantMatch: true, - }, - { - name: "FilterByCID/NoUID/DifferentCID", - target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("B")}, - wantMatch: false, - }, - { - name: "FilterByCID/WithUID/NoCID", - target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4"}, - wantMatch: false, - }, - { - name: "FilterByCID/WithUID/SameCID", - target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")}, - wantMatch: true, - }, - { - name: "FilterByCID/WithUID/DifferentCID", - target: notificationTarget{clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("B")}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/Nil", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4"}, - actor: nil, - wantMatch: false, - }, - { - name: "FilterByUID+CID/NoUID/NoCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/NoUID/SameCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("A")}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/NoUID/DifferentCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{CID: ipnauth.ClientIDFrom("B")}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/SameUID/NoCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4"}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/SameUID/SameCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("A")}, - wantMatch: true, - }, - { - name: "FilterByUID+CID/SameUID/DifferentCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-1-2-3-4", CID: ipnauth.ClientIDFrom("B")}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/DifferentUID/NoCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8"}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/DifferentUID/SameCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8", CID: ipnauth.ClientIDFrom("A")}, - wantMatch: false, - }, - { - name: "FilterByUID+CID/DifferentUID/DifferentCID", - target: notificationTarget{userID: "S-1-5-21-1-2-3-4", clientID: ipnauth.ClientIDFrom("A")}, - actor: &ipnauth.TestActor{UID: "S-1-5-21-5-6-7-8", CID: ipnauth.ClientIDFrom("B")}, - wantMatch: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMatch := tt.target.match(tt.actor) - if gotMatch != tt.wantMatch { - t.Errorf("match: got %v; want %v", gotMatch, tt.wantMatch) - } - }) - } -} - -type newTestControlFn func(tb testing.TB, opts controlclient.Options) controlclient.Client - -func newLocalBackendWithTestControl(t *testing.T, enableLogging bool, newControl newTestControlFn) *LocalBackend { - logf := logger.Discard - if enableLogging { - logf = tstest.WhileTestRunningLogger(t) - } - sys := new(tsd.System) - store := new(mem.Store) - sys.Set(store) - e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) - if err != nil { - t.Fatalf("NewFakeUserspaceEngine: %v", err) - } - t.Cleanup(e.Close) - sys.Set(e) - - b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatalf("NewLocalBackend: %v", err) - } - b.DisablePortMapperForTest() - - b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { - return newControl(t, opts), nil - }) - return b -} - -// notificationHandler is any function that can process (e.g., check) a notification. -// It returns whether the notification has been handled or should be passed to the next handler. -// The handler may be called from any goroutine, so it must avoid calling functions -// that are restricted to the goroutine running the test or benchmark function, -// such as [testing.common.FailNow] and [testing.common.Fatalf]. -type notificationHandler func(testing.TB, ipnauth.Actor, *ipn.Notify) bool - -// wantedNotification names a [notificationHandler] that processes a notification -// the test expects and wants to receive. The name is used to report notifications -// that haven't been received within the expected timeout. -type wantedNotification struct { - name string - cond notificationHandler -} - -// notificationWatcher observes [LocalBackend] notifications as the specified actor, -// reporting missing but expected notifications using [testing.common.Error], -// and delegating the handling of unexpected notifications to the [notificationHandler]s. -type notificationWatcher struct { - tb testing.TB - lb *LocalBackend - actor ipnauth.Actor - - mu sync.Mutex - mask ipn.NotifyWatchOpt - want []wantedNotification // notifications we want to receive - unexpected []notificationHandler // funcs that are called to check any other notifications - ctxCancel context.CancelFunc // cancels the outstanding [LocalBackend.WatchNotificationsAs] call - got []*ipn.Notify // all notifications, both wanted and unexpected, we've received so far - gotWanted []*ipn.Notify // only the expected notifications; holds nil for any notification that hasn't been received - gotWantedCh chan struct{} // closed when we have received the last wanted notification - doneCh chan struct{} // closed when [LocalBackend.WatchNotificationsAs] returns -} - -func newNotificationWatcher(tb testing.TB, lb *LocalBackend, actor ipnauth.Actor) *notificationWatcher { - return ¬ificationWatcher{tb: tb, lb: lb, actor: actor} -} - -func (w *notificationWatcher) watch(mask ipn.NotifyWatchOpt, wanted []wantedNotification, unexpected ...notificationHandler) { - w.tb.Helper() - - // Cancel any outstanding [LocalBackend.WatchNotificationsAs] calls. - w.mu.Lock() - ctxCancel := w.ctxCancel - doneCh := w.doneCh - w.mu.Unlock() - if doneCh != nil { - ctxCancel() - <-doneCh - } - - doneCh = make(chan struct{}) - gotWantedCh := make(chan struct{}) - ctx, ctxCancel := context.WithCancel(context.Background()) - w.tb.Cleanup(func() { - ctxCancel() - <-doneCh - }) - - w.mu.Lock() - w.mask = mask - w.want = wanted - w.unexpected = unexpected - w.ctxCancel = ctxCancel - w.got = nil - w.gotWanted = make([]*ipn.Notify, len(wanted)) - w.gotWantedCh = gotWantedCh - w.doneCh = doneCh - w.mu.Unlock() - - watchAddedCh := make(chan struct{}) - go func() { - defer close(doneCh) - if len(wanted) == 0 { - close(gotWantedCh) - if len(unexpected) == 0 { - close(watchAddedCh) - return - } - } - - var nextWantIdx int - w.lb.WatchNotificationsAs(ctx, w.actor, w.mask, func() { close(watchAddedCh) }, func(notify *ipn.Notify) (keepGoing bool) { - w.tb.Helper() - - w.mu.Lock() - defer w.mu.Unlock() - w.got = append(w.got, notify) - - wanted := false - for i := nextWantIdx; i < len(w.want); i++ { - if wanted = w.want[i].cond(w.tb, w.actor, notify); wanted { - w.gotWanted[i] = notify - nextWantIdx = i + 1 - break - } - } - - if wanted && nextWantIdx == len(w.want) { - close(w.gotWantedCh) - if len(w.unexpected) == 0 { - // If we have received the last wanted notification, - // and we don't have any handlers for the unexpected notifications, - // we can stop the watcher right away. - return false - } - - } - - if !wanted { - // If we've received a notification we didn't expect, - // it could either be an unwanted notification caused by a bug - // or just a miscellaneous one that's irrelevant for the current test. - // Call unexpected notification handlers, if any, to - // check and fail the test if necessary. - for _, h := range w.unexpected { - if h(w.tb, w.actor, notify) { - break - } - } - } - - return true - }) - - }() - <-watchAddedCh -} - -func (w *notificationWatcher) check() []*ipn.Notify { - w.tb.Helper() - - w.mu.Lock() - cancel := w.ctxCancel - gotWantedCh := w.gotWantedCh - checkUnexpected := len(w.unexpected) != 0 - doneCh := w.doneCh - w.mu.Unlock() - - // Wait for up to 10 seconds to receive expected notifications. - timeout := 10 * time.Second - for { - select { - case <-gotWantedCh: - if checkUnexpected { - gotWantedCh = nil - // But do not wait longer than 500ms for unexpected notifications after - // the expected notifications have been received. - timeout = 500 * time.Millisecond - continue - } - case <-doneCh: - // [LocalBackend.WatchNotificationsAs] has already returned, so no further - // notifications will be received. There's no reason to wait any longer. - case <-time.After(timeout): - } - cancel() - <-doneCh - break - } - - // Report missing notifications, if any, and log all received notifications, - // including both expected and unexpected ones. - w.mu.Lock() - defer w.mu.Unlock() - if hasMissing := slices.Contains(w.gotWanted, nil); hasMissing { - want := make([]string, len(w.want)) - got := make([]string, 0, len(w.want)) - for i, wn := range w.want { - want[i] = wn.name - if w.gotWanted[i] != nil { - got = append(got, wn.name) - } - } - w.tb.Errorf("Notifications(%s): got %q; want %q", actorDescriptionForTest(w.actor), strings.Join(got, ", "), strings.Join(want, ", ")) - for i, n := range w.got { - w.tb.Logf("%d. %v", i, n) - } - return nil - } - - return w.gotWanted -} - -func actorDescriptionForTest(actor ipnauth.Actor) string { - var parts []string - if actor != nil { - if name, _ := actor.Username(); name != "" { - parts = append(parts, name) - } - if uid := actor.UserID(); uid != "" { - parts = append(parts, string(uid)) - } - if clientID, _ := actor.ClientID(); clientID != ipnauth.NoClientID { - parts = append(parts, clientID.String()) - } - } - return fmt.Sprintf("Actor{%s}", strings.Join(parts, ", ")) -} - -func TestLoginNotifications(t *testing.T) { - const ( - enableLogging = true - controlURL = "https://localhost:1/" - loginURL = "https://localhost:1/1" - ) - - wantBrowseToURL := wantedNotification{ - name: "BrowseToURL", - cond: func(t testing.TB, actor ipnauth.Actor, n *ipn.Notify) bool { - if n.BrowseToURL != nil && *n.BrowseToURL != loginURL { - t.Errorf("BrowseToURL (%s): got %q; want %q", actorDescriptionForTest(actor), *n.BrowseToURL, loginURL) - return false - } - return n.BrowseToURL != nil - }, - } - unexpectedBrowseToURL := func(t testing.TB, actor ipnauth.Actor, n *ipn.Notify) bool { - if n.BrowseToURL != nil { - t.Errorf("Unexpected BrowseToURL(%s): %v", actorDescriptionForTest(actor), n) - return true - } - return false - } - - tests := []struct { - name string - logInAs ipnauth.Actor - urlExpectedBy []ipnauth.Actor - urlUnexpectedBy []ipnauth.Actor - }{ - { - name: "NoObservers", - logInAs: &ipnauth.TestActor{UID: "A"}, - urlExpectedBy: []ipnauth.Actor{}, // ensure that it does not panic if no one is watching - }, - { - name: "SingleUser", - logInAs: &ipnauth.TestActor{UID: "A"}, - urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}}, - }, - { - name: "SameUser/TwoSessions/NoCID", - logInAs: &ipnauth.TestActor{UID: "A"}, - urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}, &ipnauth.TestActor{UID: "A"}}, - }, - { - name: "SameUser/TwoSessions/OneWithCID", - logInAs: &ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}, - urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}}, - urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}}, - }, - { - name: "SameUser/TwoSessions/BothWithCID", - logInAs: &ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}, - urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}}, - urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("456")}}, - }, - { - name: "DifferentUsers/NoCID", - logInAs: &ipnauth.TestActor{UID: "A"}, - urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A"}}, - urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "B"}}, - }, - { - name: "DifferentUsers/SameCID", - logInAs: &ipnauth.TestActor{UID: "A"}, - urlExpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "A", CID: ipnauth.ClientIDFrom("123")}}, - urlUnexpectedBy: []ipnauth.Actor{&ipnauth.TestActor{UID: "B", CID: ipnauth.ClientIDFrom("123")}}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - lb := newLocalBackendWithTestControl(t, enableLogging, func(tb testing.TB, opts controlclient.Options) controlclient.Client { - return newClient(tb, opts) - }) - if _, err := lb.EditPrefs(&ipn.MaskedPrefs{ControlURLSet: true, Prefs: ipn.Prefs{ControlURL: controlURL}}); err != nil { - t.Fatalf("(*EditPrefs).Start(): %v", err) - } - if err := lb.Start(ipn.Options{}); err != nil { - t.Fatalf("(*LocalBackend).Start(): %v", err) - } - - sessions := make([]*notificationWatcher, 0, len(tt.urlExpectedBy)+len(tt.urlUnexpectedBy)) - for _, actor := range tt.urlExpectedBy { - session := newNotificationWatcher(t, lb, actor) - session.watch(0, []wantedNotification{wantBrowseToURL}) - sessions = append(sessions, session) - } - for _, actor := range tt.urlUnexpectedBy { - session := newNotificationWatcher(t, lb, actor) - session.watch(0, nil, unexpectedBrowseToURL) - sessions = append(sessions, session) - } - - if err := lb.StartLoginInteractiveAs(context.Background(), tt.logInAs); err != nil { - t.Fatal(err) - } - - lb.cc.(*mockControl).send(nil, loginURL, false, nil) - - var wg sync.WaitGroup - wg.Add(len(sessions)) - for _, sess := range sessions { - go func() { // check all sessions in parallel - sess.check() - wg.Done() - }() - } - wg.Wait() - }) - } -} - -// TestConfigFileReload tests that the LocalBackend reloads its configuration -// when the configuration file changes. -func TestConfigFileReload(t *testing.T) { - cfg1 := `{"Hostname": "foo", "Version": "alpha0"}` - f := filepath.Join(t.TempDir(), "cfg") - must.Do(os.WriteFile(f, []byte(cfg1), 0600)) - sys := new(tsd.System) - sys.InitialConfig = must.Get(conffile.Load(f)) - lb := newTestLocalBackendWithSys(t, sys) - must.Do(lb.Start(ipn.Options{})) - - lb.mu.Lock() - hn := lb.hostinfo.Hostname - lb.mu.Unlock() - if hn != "foo" { - t.Fatalf("got %q; want %q", hn, "foo") - } - - cfg2 := `{"Hostname": "bar", "Version": "alpha0"}` - must.Do(os.WriteFile(f, []byte(cfg2), 0600)) - if !must.Get(lb.ReloadConfig()) { - t.Fatal("reload failed") - } - - lb.mu.Lock() - hn = lb.hostinfo.Hostname - lb.mu.Unlock() - if hn != "bar" { - t.Fatalf("got %q; want %q", hn, "bar") - } -} - -func TestGetVIPServices(t *testing.T) { - tests := []struct { - name string - advertised []string - mapped []string - want []*tailcfg.VIPService - }{ - { - "advertised-only", - []string{"svc:abc", "svc:def"}, - []string{}, - []*tailcfg.VIPService{ - { - Name: "svc:abc", - Active: true, - }, - { - Name: "svc:def", - Active: true, - }, - }, - }, - { - "mapped-only", - []string{}, - []string{"svc:abc"}, - []*tailcfg.VIPService{ - { - Name: "svc:abc", - Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}}, - }, - }, - }, - { - "mapped-and-advertised", - []string{"svc:abc"}, - []string{"svc:abc"}, - []*tailcfg.VIPService{ - { - Name: "svc:abc", - Active: true, - Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}}, - }, - }, - }, - { - "mapped-and-advertised-separately", - []string{"svc:def"}, - []string{"svc:abc"}, - []*tailcfg.VIPService{ - { - Name: "svc:abc", - Ports: []tailcfg.ProtoPortRange{{Ports: tailcfg.PortRangeAny}}, - }, - { - Name: "svc:def", - Active: true, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - envknob.Setenv("TS_DEBUG_ALLPORTS_SERVICES", strings.Join(tt.mapped, ",")) - prefs := &ipn.Prefs{ - AdvertiseServices: tt.advertised, - } - got := vipServicesFromPrefs(prefs.View()) - slices.SortFunc(got, func(a, b *tailcfg.VIPService) int { - return strings.Compare(a.Name, b.Name) - }) - if !reflect.DeepEqual(tt.want, got) { - t.Logf("want:") - for _, s := range tt.want { - t.Logf("%+v", s) - } - t.Logf("got:") - for _, s := range got { - t.Logf("%+v", s) - } - t.Fail() - return - } - }) - } -} diff --git a/ipn/ipnlocal/loglines_test.go b/ipn/ipnlocal/loglines_test.go deleted file mode 100644 index f70987c0e8ad3..0000000000000 --- a/ipn/ipnlocal/loglines_test.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "reflect" - "testing" - "time" - - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/persist" - "tailscale.com/wgengine" -) - -// TestLocalLogLines tests to make sure that the log lines required for log parsing are -// being logged by the expected functions. Update these tests if moving log lines between -// functions. -func TestLocalLogLines(t *testing.T) { - logListen := tstest.NewLogLineTracker(t.Logf, []string{ - "[v1] peer keys: %s", - "[v1] v%v peers: %v", - }) - defer logListen.Close() - - // Put a rate-limiter with a burst of 0 between the components below. - // This instructs the rate-limiter to eliminate all logging that - // isn't explicitly exempt from rate-limiting. - // This lets the logListen tracker verify that the rate-limiter allows these key lines. - logf := logger.RateLimitedFnWithClock(logListen.Logf, 5*time.Second, 0, 10, time.Now) - - logid := func(hex byte) logid.PublicID { - var ret logid.PublicID - for i := range len(ret) { - ret[i] = hex - } - return ret - } - idA := logid(0xaa) - - // set up a LocalBackend, super bare bones. No functional data. - sys := new(tsd.System) - store := new(mem.Store) - sys.Set(store) - e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(e.Close) - sys.Set(e) - - lb, err := NewLocalBackend(logf, idA, sys, 0) - if err != nil { - t.Fatal(err) - } - defer lb.Shutdown() - - lb.hostinfo = &tailcfg.Hostinfo{} - // hacky manual override of the usual log-on-change behaviour of keylogf - lb.keyLogf = logListen.Logf - - testWantRemain := func(wantRemain ...string) func(t *testing.T) { - return func(t *testing.T) { - if remain := logListen.Check(); !reflect.DeepEqual(remain, wantRemain) { - t.Helper() - t.Errorf("remain %q, want %q", remain, wantRemain) - } - } - } - - // log prefs line - persist := &persist.Persist{} - prefs := ipn.NewPrefs() - prefs.Persist = persist - lb.SetPrefsForTest(prefs) - - t.Run("after_prefs", testWantRemain("[v1] peer keys: %s", "[v1] v%v peers: %v")) - - // log peers, peer keys - lb.mu.Lock() - lb.parseWgStatusLocked(&wgengine.Status{ - Peers: []ipnstate.PeerStatusLite{{ - TxBytes: 10, - RxBytes: 10, - LastHandshake: time.Now(), - NodeKey: key.NewNode().Public(), - }}, - }) - lb.mu.Unlock() - - t.Run("after_peers", testWantRemain()) - - // Log it again with different stats to ensure it's not dup-suppressed. - logListen.Reset() - lb.mu.Lock() - lb.parseWgStatusLocked(&wgengine.Status{ - Peers: []ipnstate.PeerStatusLite{{ - TxBytes: 11, - RxBytes: 12, - LastHandshake: time.Now(), - NodeKey: key.NewNode().Public(), - }}, - }) - lb.mu.Unlock() - t.Run("after_second_peer_status", testWantRemain()) -} diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index bf14d339ed890..7c9945bb04c58 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -21,20 +21,20 @@ import ( "slices" "time" - "tailscale.com/health/healthmsg" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/tsconst" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/persist" - "tailscale.com/types/tkatype" - "tailscale.com/util/mak" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/health/healthmsg" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/tsconst" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" ) // TODO(tom): RPC retry/backoff was broken and has been removed. Fix? diff --git a/ipn/ipnlocal/network-lock_test.go b/ipn/ipnlocal/network-lock_test.go deleted file mode 100644 index 4b79136c81ea9..0000000000000 --- a/ipn/ipnlocal/network-lock_test.go +++ /dev/null @@ -1,1320 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "reflect" - "testing" - - go4mem "go4.org/mem" - - "github.com/google/go-cmp/cmp" - "tailscale.com/control/controlclient" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/types/key" - "tailscale.com/types/netmap" - "tailscale.com/types/persist" - "tailscale.com/types/tkatype" - "tailscale.com/util/must" - "tailscale.com/util/set" -) - -type observerFunc func(controlclient.Status) - -func (f observerFunc) SetControlClientStatus(_ controlclient.Client, s controlclient.Status) { - f(s) -} - -func fakeControlClient(t *testing.T, c *http.Client) *controlclient.Auto { - hi := hostinfo.New() - ni := tailcfg.NetInfo{LinkType: "wired"} - hi.NetInfo = &ni - - k := key.NewMachine() - opts := controlclient.Options{ - ServerURL: "https://example.com", - Hostinfo: hi, - GetMachinePrivateKey: func() (key.MachinePrivate, error) { - return k, nil - }, - HTTPTestClient: c, - NoiseTestClient: c, - Observer: observerFunc(func(controlclient.Status) {}), - Dialer: tsdial.NewDialer(netmon.NewStatic()), - } - - cc, err := controlclient.NewNoStart(opts) - if err != nil { - t.Fatal(err) - } - return cc -} - -func fakeNoiseServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *http.Client) { - ts := httptest.NewUnstartedServer(handler) - ts.StartTLS() - client := ts.Client() - client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true - client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, network, ts.Listener.Addr().String()) - } - return ts, client -} - -func TestTKAEnablementFlow(t *testing.T) { - nodePriv := key.NewNode() - - // Make a fake TKA authority, getting a usable genesis AUM which - // our mock server can communicate. - nlPriv := key.NewNLPrivate() - key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - a1, genesisAUM, err := tka.Create(&tka.Mem{}, tka.State{ - Keys: []tka.Key{key}, - DisablementSecrets: [][]byte{bytes.Repeat([]byte{0xa5}, 32)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/bootstrap": - body := new(tailcfg.TKABootstrapRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - if body.Version != tailcfg.CurrentCapabilityVersion { - t.Errorf("bootstrap CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion) - } - if body.NodeKey != nodePriv.Public() { - t.Errorf("bootstrap nodeKey=%v, want %v", body.NodeKey, nodePriv.Public()) - } - if body.Head != "" { - t.Errorf("bootstrap head=%s, want empty hash", body.Head) - } - - w.WriteHeader(200) - out := tailcfg.TKABootstrapResponse{ - GenesisAUM: genesisAUM.Serialize(), - } - if err := json.NewEncoder(w).Encode(out); err != nil { - t.Fatal(err) - } - - // Sync offer/send endpoints are hit even though the node is up-to-date, - // so we implement enough of a fake that the client doesn't explode. - case "/machine/tka/sync/offer": - head, err := a1.Head().MarshalText() - if err != nil { - t.Fatal(err) - } - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(tailcfg.TKASyncOfferResponse{ - Head: string(head), - }); err != nil { - t.Fatal(err) - } - case "/machine/tka/sync/send": - head, err := a1.Head().MarshalText() - if err != nil { - t.Fatal(err) - } - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(tailcfg.TKASyncSendResponse{ - Head: string(head), - }); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - temp := t.TempDir() - - cc := fakeControlClient(t, client) - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - b := LocalBackend{ - capTailnetLock: true, - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - pm: pm, - store: pm.Store(), - } - - err = b.tkaSyncIfNeeded(&netmap.NetworkMap{ - TKAEnabled: true, - TKAHead: a1.Head(), - }, pm.CurrentPrefs()) - if err != nil { - t.Errorf("tkaSyncIfNeededLocked() failed: %v", err) - } - if b.tka == nil { - t.Fatal("tka was not initialized") - } - if b.tka.authority.Head() != a1.Head() { - t.Errorf("authority.Head() = %x, want %x", b.tka.authority.Head(), a1.Head()) - } -} - -func TestTKADisablementFlow(t *testing.T) { - nodePriv := key.NewNode() - - // Make a fake TKA authority, to seed local state. - disablementSecret := bytes.Repeat([]byte{0xa5}, 32) - nlPriv := key.NewNLPrivate() - key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - - temp := t.TempDir() - tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) - os.Mkdir(tkaPath, 0755) - chonk, err := tka.ChonkDir(tkaPath) - if err != nil { - t.Fatal(err) - } - authority, _, err := tka.Create(chonk, tka.State{ - Keys: []tka.Key{key}, - DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - returnWrongSecret := false - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/bootstrap": - body := new(tailcfg.TKABootstrapRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - if body.Version != tailcfg.CurrentCapabilityVersion { - t.Errorf("bootstrap CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion) - } - if body.NodeKey != nodePriv.Public() { - t.Errorf("nodeKey=%v, want %v", body.NodeKey, nodePriv.Public()) - } - var head tka.AUMHash - if err := head.UnmarshalText([]byte(body.Head)); err != nil { - t.Fatalf("failed unmarshal of body.Head: %v", err) - } - if head != authority.Head() { - t.Errorf("reported head = %x, want %x", head, authority.Head()) - } - - var disablement []byte - if returnWrongSecret { - disablement = bytes.Repeat([]byte{0x42}, 32) // wrong secret - } else { - disablement = disablementSecret - } - - w.WriteHeader(200) - out := tailcfg.TKABootstrapResponse{ - DisablementSecret: disablement, - } - if err := json.NewEncoder(w).Encode(out); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - - cc := fakeControlClient(t, client) - b := LocalBackend{ - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - tka: &tkaState{ - authority: authority, - storage: chonk, - }, - pm: pm, - store: pm.Store(), - } - - // Test that the wrong disablement secret does not shut down the authority. - returnWrongSecret = true - err = b.tkaSyncIfNeeded(&netmap.NetworkMap{ - TKAEnabled: false, - TKAHead: authority.Head(), - }, pm.CurrentPrefs()) - if err != nil { - t.Errorf("tkaSyncIfNeededLocked() failed: %v", err) - } - if b.tka == nil { - t.Error("TKA was disabled despite incorrect disablement secret") - } - - // Test the correct disablement secret shuts down the authority. - returnWrongSecret = false - err = b.tkaSyncIfNeeded(&netmap.NetworkMap{ - TKAEnabled: false, - TKAHead: authority.Head(), - }, pm.CurrentPrefs()) - if err != nil { - t.Errorf("tkaSyncIfNeededLocked() failed: %v", err) - } - - if b.tka != nil { - t.Fatal("tka was not shut down") - } - if _, err := os.Stat(b.chonkPathLocked()); err == nil || !os.IsNotExist(err) { - t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err) - } -} - -func TestTKASync(t *testing.T) { - someKeyPriv := key.NewNLPrivate() - someKey := tka.Key{Kind: tka.Key25519, Public: someKeyPriv.Public().Verifier(), Votes: 1} - - disablementSecret := bytes.Repeat([]byte{0xa5}, 32) - - type tkaSyncScenario struct { - name string - // controlAUMs is called (if non-nil) to get any AUMs which the tka state - // on control should be seeded with. - controlAUMs func(*testing.T, *tka.Authority, tka.Chonk, tka.Signer) []tka.AUM - // controlAUMs is called (if non-nil) to get any AUMs which the tka state - // on the node should be seeded with. - nodeAUMs func(*testing.T, *tka.Authority, tka.Chonk, tka.Signer) []tka.AUM - } - - tcs := []tkaSyncScenario{ - {name: "up to date"}, - { - name: "control has an update", - controlAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM { - b := a.NewUpdater(signer) - if err := b.RemoveKey(someKey.MustID()); err != nil { - t.Fatal(err) - } - aums, err := b.Finalize(storage) - if err != nil { - t.Fatal(err) - } - return aums - }, - }, - { - // AKA 'control data loss' scenario - name: "node has an update", - nodeAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM { - b := a.NewUpdater(signer) - if err := b.RemoveKey(someKey.MustID()); err != nil { - t.Fatal(err) - } - aums, err := b.Finalize(storage) - if err != nil { - t.Fatal(err) - } - return aums - }, - }, - { - // AKA 'control data loss + update in the meantime' scenario - name: "node and control diverge", - controlAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM { - b := a.NewUpdater(signer) - if err := b.SetKeyMeta(someKey.MustID(), map[string]string{"ye": "swiggity"}); err != nil { - t.Fatal(err) - } - aums, err := b.Finalize(storage) - if err != nil { - t.Fatal(err) - } - return aums - }, - nodeAUMs: func(t *testing.T, a *tka.Authority, storage tka.Chonk, signer tka.Signer) []tka.AUM { - b := a.NewUpdater(signer) - if err := b.SetKeyMeta(someKey.MustID(), map[string]string{"ye": "swooty"}); err != nil { - t.Fatal(err) - } - aums, err := b.Finalize(storage) - if err != nil { - t.Fatal(err) - } - return aums - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - nodePriv := key.NewNode() - nlPriv := key.NewNLPrivate() - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - - // Setup the tka authority on the control plane. - key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - controlStorage := &tka.Mem{} - controlAuthority, bootstrap, err := tka.Create(controlStorage, tka.State{ - Keys: []tka.Key{key, someKey}, - DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - if tc.controlAUMs != nil { - if err := controlAuthority.Inform(controlStorage, tc.controlAUMs(t, controlAuthority, controlStorage, nlPriv)); err != nil { - t.Fatalf("controlAuthority.Inform() failed: %v", err) - } - } - - temp := t.TempDir() - tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) - os.Mkdir(tkaPath, 0755) - // Setup the TKA authority on the node. - nodeStorage, err := tka.ChonkDir(tkaPath) - if err != nil { - t.Fatal(err) - } - nodeAuthority, err := tka.Bootstrap(nodeStorage, bootstrap) - if err != nil { - t.Fatalf("tka.Bootstrap() failed: %v", err) - } - if tc.nodeAUMs != nil { - if err := nodeAuthority.Inform(nodeStorage, tc.nodeAUMs(t, nodeAuthority, nodeStorage, nlPriv)); err != nil { - t.Fatalf("nodeAuthority.Inform() failed: %v", err) - } - } - - // Make a mock control server. - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/sync/offer": - body := new(tailcfg.TKASyncOfferRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - t.Logf("got sync offer:\n%+v", body) - nodeOffer, err := toSyncOffer(body.Head, body.Ancestors) - if err != nil { - t.Fatal(err) - } - controlOffer, err := controlAuthority.SyncOffer(controlStorage) - if err != nil { - t.Fatal(err) - } - sendAUMs, err := controlAuthority.MissingAUMs(controlStorage, nodeOffer) - if err != nil { - t.Fatal(err) - } - - head, ancestors, err := fromSyncOffer(controlOffer) - if err != nil { - t.Fatal(err) - } - resp := tailcfg.TKASyncOfferResponse{ - Head: head, - Ancestors: ancestors, - MissingAUMs: make([]tkatype.MarshaledAUM, len(sendAUMs)), - } - for i, a := range sendAUMs { - resp.MissingAUMs[i] = a.Serialize() - } - - t.Logf("responding to sync offer with:\n%+v", resp) - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(resp); err != nil { - t.Fatal(err) - } - - case "/machine/tka/sync/send": - body := new(tailcfg.TKASyncSendRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - t.Logf("got sync send:\n%+v", body) - - var remoteHead tka.AUMHash - if err := remoteHead.UnmarshalText([]byte(body.Head)); err != nil { - t.Fatalf("head unmarshal: %v", err) - } - toApply := make([]tka.AUM, len(body.MissingAUMs)) - for i, a := range body.MissingAUMs { - if err := toApply[i].Unserialize(a); err != nil { - t.Fatalf("decoding missingAUM[%d]: %v", i, err) - } - } - - if len(toApply) > 0 { - if err := controlAuthority.Inform(controlStorage, toApply); err != nil { - t.Fatalf("control.Inform(%+v) failed: %v", toApply, err) - } - } - head, err := controlAuthority.Head().MarshalText() - if err != nil { - t.Fatal(err) - } - - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(tailcfg.TKASyncSendResponse{ - Head: string(head), - }); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - - // Setup the client. - cc := fakeControlClient(t, client) - b := LocalBackend{ - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - pm: pm, - store: pm.Store(), - tka: &tkaState{ - authority: nodeAuthority, - storage: nodeStorage, - }, - } - - // Finally, lets trigger a sync. - err = b.tkaSyncIfNeeded(&netmap.NetworkMap{ - TKAEnabled: true, - TKAHead: controlAuthority.Head(), - }, pm.CurrentPrefs()) - if err != nil { - t.Errorf("tkaSyncIfNeededLocked() failed: %v", err) - } - - // Check that at the end of this ordeal, the node and the control - // plane are in sync. - if nodeHead, controlHead := b.tka.authority.Head(), controlAuthority.Head(); nodeHead != controlHead { - t.Errorf("node head = %v, want %v", nodeHead, controlHead) - } - }) - } -} - -func TestTKAFilterNetmap(t *testing.T) { - nlPriv := key.NewNLPrivate() - nlKey := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - storage := &tka.Mem{} - authority, _, err := tka.Create(storage, tka.State{ - Keys: []tka.Key{nlKey}, - DisablementSecrets: [][]byte{bytes.Repeat([]byte{0xa5}, 32)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - b := &LocalBackend{ - logf: t.Logf, - tka: &tkaState{authority: authority}, - } - - n1, n2, n3, n4, n5 := key.NewNode(), key.NewNode(), key.NewNode(), key.NewNode(), key.NewNode() - n1GoodSig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n1.Public()}, nlPriv) - if err != nil { - t.Fatal(err) - } - n4Sig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n4.Public()}, nlPriv) - if err != nil { - t.Fatal(err) - } - n4Sig.Signature[3] = 42 // mess up the signature - n4Sig.Signature[4] = 42 // mess up the signature - - n5nl := key.NewNLPrivate() - n5InitialSig, err := signNodeKey(tailcfg.TKASignInfo{NodePublic: n5.Public(), RotationPubkey: n5nl.Public().Verifier()}, nlPriv) - if err != nil { - t.Fatal(err) - } - - resign := func(nl key.NLPrivate, currentSig tkatype.MarshaledSignature) (key.NodePrivate, tkatype.MarshaledSignature) { - nk := key.NewNode() - sig, err := tka.ResignNKS(nl, nk.Public(), currentSig) - if err != nil { - t.Fatal(err) - } - return nk, sig - } - - n5Rotated, n5RotatedSig := resign(n5nl, n5InitialSig.Serialize()) - - nodeFromAuthKey := func(authKey string) (key.NodePrivate, tkatype.MarshaledSignature) { - _, isWrapped, sig, priv := tka.DecodeWrappedAuthkey(authKey, t.Logf) - if !isWrapped { - t.Errorf("expected wrapped key") - } - - node := key.NewNode() - nodeSig, err := tka.SignByCredential(priv, sig, node.Public()) - if err != nil { - t.Error(err) - } - return node, nodeSig - } - - preauth, err := b.NetworkLockWrapPreauthKey("tskey-auth-k7UagY1CNTRL-ZZZZZ", nlPriv) - if err != nil { - t.Fatal(err) - } - - // Two nodes created using the same auth key, both should be valid. - n60, n60Sig := nodeFromAuthKey(preauth) - n61, n61Sig := nodeFromAuthKey(preauth) - - nm := &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - {ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()}, - {ID: 2, Key: n2.Public(), KeySignature: nil}, // missing sig - {ID: 3, Key: n3.Public(), KeySignature: n1GoodSig.Serialize()}, // someone elses sig - {ID: 4, Key: n4.Public(), KeySignature: n4Sig.Serialize()}, // messed-up signature - {ID: 50, Key: n5.Public(), KeySignature: n5InitialSig.Serialize()}, // rotated - {ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, - {ID: 60, Key: n60.Public(), KeySignature: n60Sig}, - {ID: 61, Key: n61.Public(), KeySignature: n61Sig}, - }), - } - - b.tkaFilterNetmapLocked(nm) - - want := nodeViews([]*tailcfg.Node{ - {ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()}, - {ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, - {ID: 60, Key: n60.Public(), KeySignature: n60Sig}, - {ID: 61, Key: n61.Public(), KeySignature: n61Sig}, - }) - nodePubComparer := cmp.Comparer(func(x, y key.NodePublic) bool { - return x.Raw32() == y.Raw32() - }) - if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" { - t.Errorf("filtered netmap differs (-want, +got):\n%s", diff) - } - - // Create two more node signatures using the same wrapping key as n5. - // Since they have the same rotation chain, both will be filtered out. - n7, n7Sig := resign(n5nl, n5RotatedSig) - n8, n8Sig := resign(n5nl, n5RotatedSig) - - nm = &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - {ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()}, - {ID: 2, Key: n2.Public(), KeySignature: nil}, // missing sig - {ID: 3, Key: n3.Public(), KeySignature: n1GoodSig.Serialize()}, // someone elses sig - {ID: 4, Key: n4.Public(), KeySignature: n4Sig.Serialize()}, // messed-up signature - {ID: 50, Key: n5.Public(), KeySignature: n5InitialSig.Serialize()}, // rotated - {ID: 51, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, // rotated - {ID: 7, Key: n7.Public(), KeySignature: n7Sig}, // same rotation chain as n8 - {ID: 8, Key: n8.Public(), KeySignature: n8Sig}, // same rotation chain as n7 - }), - } - - b.tkaFilterNetmapLocked(nm) - - want = nodeViews([]*tailcfg.Node{ - {ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()}, - }) - if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" { - t.Errorf("filtered netmap differs (-want, +got):\n%s", diff) - } - - // Confirm that repeated rotation works correctly. - for range 100 { - n5Rotated, n5RotatedSig = resign(n5nl, n5RotatedSig) - } - - n51, n51Sig := resign(n5nl, n5RotatedSig) - - nm = &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - {ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()}, - {ID: 5, Key: n5Rotated.Public(), KeySignature: n5RotatedSig}, // rotated - {ID: 51, Key: n51.Public(), KeySignature: n51Sig}, - }), - } - - b.tkaFilterNetmapLocked(nm) - - want = nodeViews([]*tailcfg.Node{ - {ID: 1, Key: n1.Public(), KeySignature: n1GoodSig.Serialize()}, - {ID: 51, Key: n51.Public(), KeySignature: n51Sig}, - }) - if diff := cmp.Diff(want, nm.Peers, nodePubComparer); diff != "" { - t.Errorf("filtered netmap differs (-want, +got):\n%s", diff) - } -} - -func TestTKADisable(t *testing.T) { - nodePriv := key.NewNode() - - // Make a fake TKA authority, to seed local state. - disablementSecret := bytes.Repeat([]byte{0xa5}, 32) - nlPriv := key.NewNLPrivate() - - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - - temp := t.TempDir() - tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) - os.Mkdir(tkaPath, 0755) - key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - chonk, err := tka.ChonkDir(tkaPath) - if err != nil { - t.Fatal(err) - } - authority, _, err := tka.Create(chonk, tka.State{ - Keys: []tka.Key{key}, - DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/disable": - body := new(tailcfg.TKADisableRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - if body.Version != tailcfg.CurrentCapabilityVersion { - t.Errorf("disable CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion) - } - if body.NodeKey != nodePriv.Public() { - t.Errorf("nodeKey = %v, want %v", body.NodeKey, nodePriv.Public()) - } - if !bytes.Equal(body.DisablementSecret, disablementSecret) { - t.Errorf("disablement secret = %x, want %x", body.DisablementSecret, disablementSecret) - } - - var head tka.AUMHash - if err := head.UnmarshalText([]byte(body.Head)); err != nil { - t.Fatalf("failed unmarshal of body.Head: %v", err) - } - if head != authority.Head() { - t.Errorf("reported head = %x, want %x", head, authority.Head()) - } - - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(tailcfg.TKADisableResponse{}); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - - cc := fakeControlClient(t, client) - b := LocalBackend{ - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - tka: &tkaState{ - profile: pm.CurrentProfile().ID, - authority: authority, - storage: chonk, - }, - pm: pm, - store: pm.Store(), - } - - // Test that we get an error for an incorrect disablement secret. - if err := b.NetworkLockDisable([]byte{1, 2, 3, 4}); err == nil || err.Error() != "incorrect disablement secret" { - t.Errorf("NetworkLockDisable().err = %v, want 'incorrect disablement secret'", err) - } - if err := b.NetworkLockDisable(disablementSecret); err != nil { - t.Errorf("NetworkLockDisable() failed: %v", err) - } -} - -func TestTKASign(t *testing.T) { - nodePriv := key.NewNode() - toSign := key.NewNode() - nlPriv := key.NewNLPrivate() - - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - - // Make a fake TKA authority, to seed local state. - disablementSecret := bytes.Repeat([]byte{0xa5}, 32) - key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - - temp := t.TempDir() - tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) - os.Mkdir(tkaPath, 0755) - chonk, err := tka.ChonkDir(tkaPath) - if err != nil { - t.Fatal(err) - } - authority, _, err := tka.Create(chonk, tka.State{ - Keys: []tka.Key{key}, - DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/sign": - body := new(tailcfg.TKASubmitSignatureRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - if body.Version != tailcfg.CurrentCapabilityVersion { - t.Errorf("sign CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion) - } - if body.NodeKey != nodePriv.Public() { - t.Errorf("nodeKey = %v, want %v", body.NodeKey, nodePriv.Public()) - } - - var sig tka.NodeKeySignature - if err := sig.Unserialize(body.Signature); err != nil { - t.Fatalf("malformed signature: %v", err) - } - - if err := authority.NodeKeyAuthorized(toSign.Public(), body.Signature); err != nil { - t.Errorf("signature does not verify: %v", err) - } - - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(tailcfg.TKASubmitSignatureResponse{}); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - cc := fakeControlClient(t, client) - b := LocalBackend{ - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - tka: &tkaState{ - authority: authority, - storage: chonk, - }, - pm: pm, - store: pm.Store(), - } - - if err := b.NetworkLockSign(toSign.Public(), nil); err != nil { - t.Errorf("NetworkLockSign() failed: %v", err) - } -} - -func TestTKAForceDisable(t *testing.T) { - nodePriv := key.NewNode() - - // Make a fake TKA authority, to seed local state. - disablementSecret := bytes.Repeat([]byte{0xa5}, 32) - nlPriv := key.NewNLPrivate() - key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - - temp := t.TempDir() - tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) - os.Mkdir(tkaPath, 0755) - chonk, err := tka.ChonkDir(tkaPath) - if err != nil { - t.Fatal(err) - } - authority, genesis, err := tka.Create(chonk, tka.State{ - Keys: []tka.Key{key}, - DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/bootstrap": - body := new(tailcfg.TKABootstrapRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - if body.Version != tailcfg.CurrentCapabilityVersion { - t.Errorf("bootstrap CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion) - } - if body.NodeKey != nodePriv.Public() { - t.Errorf("nodeKey=%v, want %v", body.NodeKey, nodePriv.Public()) - } - - w.WriteHeader(200) - out := tailcfg.TKABootstrapResponse{ - GenesisAUM: genesis.Serialize(), - } - if err := json.NewEncoder(w).Encode(out); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - - cc := fakeControlClient(t, client) - b := LocalBackend{ - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - tka: &tkaState{ - authority: authority, - storage: chonk, - }, - pm: pm, - store: pm.Store(), - } - - if err := b.NetworkLockForceLocalDisable(); err != nil { - t.Fatalf("NetworkLockForceLocalDisable() failed: %v", err) - } - if b.tka != nil { - t.Fatal("tka was not shut down") - } - if _, err := os.Stat(b.chonkPathLocked()); err == nil || !os.IsNotExist(err) { - t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err) - } - - err = b.tkaSyncIfNeeded(&netmap.NetworkMap{ - TKAEnabled: true, - TKAHead: authority.Head(), - }, pm.CurrentPrefs()) - if err != nil && err.Error() != "bootstrap: TKA with stateID of \"0:0\" is disallowed on this node" { - t.Errorf("tkaSyncIfNeededLocked() failed: %v", err) - } - - if b.tka != nil { - t.Fatal("tka was re-initialized") - } -} - -func TestTKAAffectedSigs(t *testing.T) { - nodePriv := key.NewNode() - // toSign := key.NewNode() - nlPriv := key.NewNLPrivate() - - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - - // Make a fake TKA authority, to seed local state. - disablementSecret := bytes.Repeat([]byte{0xa5}, 32) - tkaKey := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - - temp := t.TempDir() - tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) - os.Mkdir(tkaPath, 0755) - chonk, err := tka.ChonkDir(tkaPath) - if err != nil { - t.Fatal(err) - } - authority, _, err := tka.Create(chonk, tka.State{ - Keys: []tka.Key{tkaKey}, - DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - untrustedKey := key.NewNLPrivate() - tcs := []struct { - name string - makeSig func() *tka.NodeKeySignature - wantErr string - }{ - { - "no error", - func() *tka.NodeKeySignature { - sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, nlPriv) - return sig - }, - "", - }, - { - "signature for different keyID", - func() *tka.NodeKeySignature { - sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, untrustedKey) - return sig - }, - fmt.Sprintf("got signature with keyID %X from request for %X", untrustedKey.KeyID(), nlPriv.KeyID()), - }, - { - "invalid signature", - func() *tka.NodeKeySignature { - sig, _ := signNodeKey(tailcfg.TKASignInfo{NodePublic: nodePriv.Public()}, nlPriv) - copy(sig.Signature, []byte{1, 2, 3, 4, 5, 6}) // overwrite with trash to invalid signature - return sig - }, - "signature 0 is not valid: invalid signature", - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - s := tc.makeSig() - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/affected-sigs": - body := new(tailcfg.TKASignaturesUsingKeyRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - if body.Version != tailcfg.CurrentCapabilityVersion { - t.Errorf("sign CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion) - } - if body.NodeKey != nodePriv.Public() { - t.Errorf("nodeKey = %v, want %v", body.NodeKey, nodePriv.Public()) - } - - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(tailcfg.TKASignaturesUsingKeyResponse{ - Signatures: []tkatype.MarshaledSignature{s.Serialize()}, - }); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - cc := fakeControlClient(t, client) - b := LocalBackend{ - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - tka: &tkaState{ - authority: authority, - storage: chonk, - }, - pm: pm, - store: pm.Store(), - } - - sigs, err := b.NetworkLockAffectedSigs(nlPriv.KeyID()) - switch { - case tc.wantErr == "" && err != nil: - t.Errorf("NetworkLockAffectedSigs() failed: %v", err) - case tc.wantErr != "" && err == nil: - t.Errorf("NetworkLockAffectedSigs().err = nil, want %q", tc.wantErr) - case tc.wantErr != "" && err.Error() != tc.wantErr: - t.Errorf("NetworkLockAffectedSigs().err = %q, want %q", err.Error(), tc.wantErr) - } - - if tc.wantErr == "" { - if len(sigs) != 1 { - t.Fatalf("len(sigs) = %d, want 1", len(sigs)) - } - if !bytes.Equal(s.Serialize(), sigs[0]) { - t.Errorf("unexpected signature: got %v, want %v", sigs[0], s.Serialize()) - } - } - }) - } -} - -func TestTKARecoverCompromisedKeyFlow(t *testing.T) { - nodePriv := key.NewNode() - nlPriv := key.NewNLPrivate() - cosignPriv := key.NewNLPrivate() - compromisedPriv := key.NewNLPrivate() - - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: nlPriv, - }, - }).View(), ipn.NetworkProfile{})) - - // Make a fake TKA authority, to seed local state. - disablementSecret := bytes.Repeat([]byte{0xa5}, 32) - key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2} - cosignKey := tka.Key{Kind: tka.Key25519, Public: cosignPriv.Public().Verifier(), Votes: 2} - compromisedKey := tka.Key{Kind: tka.Key25519, Public: compromisedPriv.Public().Verifier(), Votes: 1} - - temp := t.TempDir() - tkaPath := filepath.Join(temp, "tka-profile", string(pm.CurrentProfile().ID)) - os.Mkdir(tkaPath, 0755) - chonk, err := tka.ChonkDir(tkaPath) - if err != nil { - t.Fatal(err) - } - authority, _, err := tka.Create(chonk, tka.State{ - Keys: []tka.Key{key, compromisedKey, cosignKey}, - DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)}, - }, nlPriv) - if err != nil { - t.Fatalf("tka.Create() failed: %v", err) - } - - ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - switch r.URL.Path { - case "/machine/tka/sync/send": - body := new(tailcfg.TKASyncSendRequest) - if err := json.NewDecoder(r.Body).Decode(body); err != nil { - t.Fatal(err) - } - t.Logf("got sync send:\n%+v", body) - - var remoteHead tka.AUMHash - if err := remoteHead.UnmarshalText([]byte(body.Head)); err != nil { - t.Fatalf("head unmarshal: %v", err) - } - toApply := make([]tka.AUM, len(body.MissingAUMs)) - for i, a := range body.MissingAUMs { - if err := toApply[i].Unserialize(a); err != nil { - t.Fatalf("decoding missingAUM[%d]: %v", i, err) - } - } - - // Apply the recovery AUM to an authority to make sure it works. - if err := authority.Inform(chonk, toApply); err != nil { - t.Errorf("recovery AUM could not be applied: %v", err) - } - // Make sure the key we removed isn't trusted. - if authority.KeyTrusted(compromisedPriv.KeyID()) { - t.Error("compromised key was not removed from tka") - } - - w.WriteHeader(200) - if err := json.NewEncoder(w).Encode(tailcfg.TKASubmitSignatureResponse{}); err != nil { - t.Fatal(err) - } - - default: - t.Errorf("unhandled endpoint path: %v", r.URL.Path) - w.WriteHeader(404) - } - })) - defer ts.Close() - cc := fakeControlClient(t, client) - b := LocalBackend{ - varRoot: temp, - cc: cc, - ccAuto: cc, - logf: t.Logf, - tka: &tkaState{ - authority: authority, - storage: chonk, - }, - pm: pm, - store: pm.Store(), - } - - aum, err := b.NetworkLockGenerateRecoveryAUM([]tkatype.KeyID{compromisedPriv.KeyID()}, tka.AUMHash{}) - if err != nil { - t.Fatalf("NetworkLockGenerateRecoveryAUM() failed: %v", err) - } - - // Cosign using the cosigning key. - { - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - must.Do(pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: nodePriv, - NetworkLockKey: cosignPriv, - }, - }).View(), ipn.NetworkProfile{})) - b := LocalBackend{ - varRoot: temp, - logf: t.Logf, - tka: &tkaState{ - authority: authority, - storage: chonk, - }, - pm: pm, - store: pm.Store(), - } - if aum, err = b.NetworkLockCosignRecoveryAUM(aum); err != nil { - t.Fatalf("NetworkLockCosignRecoveryAUM() failed: %v", err) - } - } - - // Finally, submit the recovery AUM. Validation is done - // in the fake control handler. - if err := b.NetworkLockSubmitRecoveryAUM(aum); err != nil { - t.Errorf("NetworkLockSubmitRecoveryAUM() failed: %v", err) - } -} - -func TestRotationTracker(t *testing.T) { - newNK := func(idx byte) key.NodePublic { - // single-byte public key to make it human-readable in tests. - raw32 := [32]byte{idx} - return key.NodePublicFromRaw32(go4mem.B(raw32[:])) - } - - rd := func(initialKind tka.SigKind, wrappingKey []byte, prevKeys ...key.NodePublic) *tka.RotationDetails { - return &tka.RotationDetails{ - InitialSig: &tka.NodeKeySignature{SigKind: initialKind, WrappingPubkey: wrappingKey}, - PrevNodeKeys: prevKeys, - } - } - - n1, n2, n3, n4, n5 := newNK(1), newNK(2), newNK(3), newNK(4), newNK(5) - - pk1, pk2, pk3 := []byte{1}, []byte{2}, []byte{3} - type addDetails struct { - np key.NodePublic - details *tka.RotationDetails - } - tests := []struct { - name string - addDetails []addDetails - want set.Set[key.NodePublic] - }{ - { - name: "empty", - want: nil, - }, - { - name: "single_prev_key", - addDetails: []addDetails{ - {np: n1, details: rd(tka.SigDirect, pk1, n2)}, - }, - want: set.SetOf([]key.NodePublic{n2}), - }, - { - name: "several_prev_keys", - addDetails: []addDetails{ - {np: n1, details: rd(tka.SigDirect, pk1, n2)}, - {np: n3, details: rd(tka.SigDirect, pk2, n4)}, - {np: n2, details: rd(tka.SigDirect, pk1, n3, n4)}, - }, - want: set.SetOf([]key.NodePublic{n2, n3, n4}), - }, - { - name: "several_per_pubkey_latest_wins", - addDetails: []addDetails{ - {np: n2, details: rd(tka.SigDirect, pk3, n1)}, - {np: n3, details: rd(tka.SigDirect, pk3, n1, n2)}, - {np: n4, details: rd(tka.SigDirect, pk3, n1, n2, n3)}, - {np: n5, details: rd(tka.SigDirect, pk3, n4)}, - }, - want: set.SetOf([]key.NodePublic{n1, n2, n3, n4}), - }, - { - name: "several_per_pubkey_same_chain_length_all_rejected", - addDetails: []addDetails{ - {np: n2, details: rd(tka.SigDirect, pk3, n1)}, - {np: n3, details: rd(tka.SigDirect, pk3, n1, n2)}, - {np: n4, details: rd(tka.SigDirect, pk3, n1, n2)}, - {np: n5, details: rd(tka.SigDirect, pk3, n1, n2)}, - }, - want: set.SetOf([]key.NodePublic{n1, n2, n3, n4, n5}), - }, - { - name: "several_per_pubkey_longest_wins", - addDetails: []addDetails{ - {np: n2, details: rd(tka.SigDirect, pk3, n1)}, - {np: n3, details: rd(tka.SigDirect, pk3, n1, n2)}, - {np: n4, details: rd(tka.SigDirect, pk3, n1, n2)}, - {np: n5, details: rd(tka.SigDirect, pk3, n1, n2, n3)}, - }, - want: set.SetOf([]key.NodePublic{n1, n2, n3, n4}), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &rotationTracker{logf: t.Logf} - for _, ad := range tt.addDetails { - r.addRotationDetails(ad.np, ad.details) - } - if got := r.obsoleteKeys(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("rotationTracker.obsoleteKeys() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index aa18c35886648..152a94335236b 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -27,24 +27,24 @@ import ( "time" "github.com/kortschak/wol" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/taildrop" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/httphdr" + "github.com/sagernet/tailscale/util/httpm" + "github.com/sagernet/tailscale/wgengine/filter" "golang.org/x/net/dns/dnsmessage" "golang.org/x/net/http/httpguts" - "tailscale.com/drive" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/net/netaddr" - "tailscale.com/net/netmon" - "tailscale.com/net/netutil" - "tailscale.com/net/sockstats" - "tailscale.com/tailcfg" - "tailscale.com/taildrop" - "tailscale.com/types/views" - "tailscale.com/util/clientmetric" - "tailscale.com/util/httphdr" - "tailscale.com/util/httpm" - "tailscale.com/wgengine/filter" ) const ( diff --git a/ipn/ipnlocal/peerapi_macios_ext.go b/ipn/ipnlocal/peerapi_macios_ext.go index 15932dfe212fb..2b31c93567afd 100644 --- a/ipn/ipnlocal/peerapi_macios_ext.go +++ b/ipn/ipnlocal/peerapi_macios_ext.go @@ -10,8 +10,8 @@ import ( "net" "net/netip" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" ) func init() { diff --git a/ipn/ipnlocal/peerapi_test.go b/ipn/ipnlocal/peerapi_test.go deleted file mode 100644 index ff9b627693a8a..0000000000000 --- a/ipn/ipnlocal/peerapi_test.go +++ /dev/null @@ -1,916 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "io/fs" - "math/rand" - "net/http" - "net/http/httptest" - "net/netip" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "go4.org/netipx" - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/appc" - "tailscale.com/appc/appctest" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/health" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/taildrop" - "tailscale.com/tstest" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/util/must" - "tailscale.com/util/usermetric" - "tailscale.com/wgengine" - "tailscale.com/wgengine/filter" -) - -type peerAPITestEnv struct { - ph *peerAPIHandler - rr *httptest.ResponseRecorder - logBuf tstest.MemLogger -} - -type check func(*testing.T, *peerAPITestEnv) - -func checks(vv ...check) []check { return vv } - -func httpStatus(wantStatus int) check { - return func(t *testing.T, e *peerAPITestEnv) { - if res := e.rr.Result(); res.StatusCode != wantStatus { - t.Errorf("HTTP response code = %v; want %v", res.Status, wantStatus) - } - } -} - -func bodyContains(sub string) check { - return func(t *testing.T, e *peerAPITestEnv) { - if body := e.rr.Body.String(); !strings.Contains(body, sub) { - t.Errorf("HTTP response body does not contain %q; got: %s", sub, body) - } - } -} - -func bodyNotContains(sub string) check { - return func(t *testing.T, e *peerAPITestEnv) { - if body := e.rr.Body.String(); strings.Contains(body, sub) { - t.Errorf("HTTP response body unexpectedly contains %q; got: %s", sub, body) - } - } -} - -func fileHasSize(name string, size int) check { - return func(t *testing.T, e *peerAPITestEnv) { - root := e.ph.ps.taildrop.Dir() - if root == "" { - t.Errorf("no rootdir; can't check whether %q has size %v", name, size) - return - } - path := filepath.Join(root, name) - if fi, err := os.Stat(path); err != nil { - t.Errorf("fileHasSize(%q, %v): %v", name, size, err) - } else if fi.Size() != int64(size) { - t.Errorf("file %q has size %v; want %v", name, fi.Size(), size) - } - } -} - -func fileHasContents(name string, want string) check { - return func(t *testing.T, e *peerAPITestEnv) { - root := e.ph.ps.taildrop.Dir() - if root == "" { - t.Errorf("no rootdir; can't check contents of %q", name) - return - } - path := filepath.Join(root, name) - got, err := os.ReadFile(path) - if err != nil { - t.Errorf("fileHasContents: %v", err) - return - } - if string(got) != want { - t.Errorf("file contents = %q; want %q", got, want) - } - } -} - -func hexAll(v string) string { - var sb strings.Builder - for i := range len(v) { - fmt.Fprintf(&sb, "%%%02x", v[i]) - } - return sb.String() -} - -func TestHandlePeerAPI(t *testing.T) { - tests := []struct { - name string - isSelf bool // the peer sending the request is owned by us - capSharing bool // self node has file sharing capability - debugCap bool // self node has debug capability - omitRoot bool // don't configure - reqs []*http.Request - checks []check - }{ - { - name: "not_peer_api", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("GET", "/", nil)}, - checks: checks( - httpStatus(200), - bodyContains("This is my Tailscale device."), - bodyContains("You are the owner of this node."), - ), - }, - { - name: "not_peer_api_not_owner", - isSelf: false, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("GET", "/", nil)}, - checks: checks( - httpStatus(200), - bodyContains("This is my Tailscale device."), - bodyNotContains("You are the owner of this node."), - ), - }, - { - name: "goroutines/deny-self-no-cap", - isSelf: true, - debugCap: false, - reqs: []*http.Request{httptest.NewRequest("GET", "/v0/goroutines", nil)}, - checks: checks(httpStatus(403)), - }, - { - name: "goroutines/deny-nonself", - isSelf: false, - debugCap: true, - reqs: []*http.Request{httptest.NewRequest("GET", "/v0/goroutines", nil)}, - checks: checks(httpStatus(403)), - }, - { - name: "goroutines/accept-self", - isSelf: true, - debugCap: true, - reqs: []*http.Request{httptest.NewRequest("GET", "/v0/goroutines", nil)}, - checks: checks( - httpStatus(200), - bodyContains("ServeHTTP"), - ), - }, - { - name: "reject_non_owner_put", - isSelf: false, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(http.StatusForbidden), - bodyContains("Taildrop disabled"), - ), - }, - { - name: "owner_without_cap", - isSelf: true, - capSharing: false, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(http.StatusForbidden), - bodyContains("Taildrop disabled"), - ), - }, - { - name: "owner_with_cap_no_rootdir", - omitRoot: true, - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(http.StatusForbidden), - bodyContains("Taildrop disabled; no storage directory"), - ), - }, - { - name: "bad_method", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("POST", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(405), - bodyContains("expected method GET or PUT"), - ), - }, - { - name: "put_zero_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasSize("foo", 0), - fileHasContents("foo", ""), - ), - }, - { - name: "put_non_zero_length_content_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents"))}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasSize("foo", len("contents")), - fileHasContents("foo", "contents"), - ), - }, - { - name: "put_non_zero_length_chunked", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", struct{ io.Reader }{strings.NewReader("contents")})}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasSize("foo", len("contents")), - fileHasContents("foo", "contents"), - ), - }, - { - name: "bad_filename_partial", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.partial", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_deleted", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.deleted", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_dot", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/.", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_empty", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_slash", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo/bar", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_dot", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_slash", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_backslash", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_dotdot", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "bad_filename_encoded_dotdot_out", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_spaces_and_caps", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Foo Bar.dat"), strings.NewReader("baz"))}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasContents("Foo Bar.dat", "baz"), - ), - }, - { - name: "put_unicode", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Томас и его друзья.mp3"), strings.NewReader("главный озорник"))}, - checks: checks( - httpStatus(200), - bodyContains("{}"), - fileHasContents("Томас и его друзья.mp3", "главный озорник"), - ), - }, - { - name: "put_invalid_utf8", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_null", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%00", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_non_printable", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%01", nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_colon", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "put_invalid_surrounding_whitespace", - isSelf: true, - capSharing: true, - reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil)}, - checks: checks( - httpStatus(400), - bodyContains("invalid filename"), - ), - }, - { - name: "host-val/bad-ip", - isSelf: true, - debugCap: true, - reqs: []*http.Request{httptest.NewRequest("GET", "http://12.23.45.66:1234/v0/env", nil)}, - checks: checks( - httpStatus(403), - ), - }, - { - name: "host-val/no-port", - isSelf: true, - debugCap: true, - reqs: []*http.Request{httptest.NewRequest("GET", "http://100.100.100.101/v0/env", nil)}, - checks: checks( - httpStatus(403), - ), - }, - { - name: "host-val/peer", - isSelf: true, - debugCap: true, - reqs: []*http.Request{httptest.NewRequest("GET", "http://peer/v0/env", nil)}, - checks: checks( - httpStatus(200), - ), - }, - { - name: "duplicate_zero_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{ - httptest.NewRequest("PUT", "/v0/put/foo", nil), - httptest.NewRequest("PUT", "/v0/put/foo", nil), - }, - checks: checks( - httpStatus(200), - func(t *testing.T, env *peerAPITestEnv) { - got, err := env.ph.ps.taildrop.WaitingFiles() - if err != nil { - t.Fatalf("WaitingFiles error: %v", err) - } - want := []apitype.WaitingFile{{Name: "foo", Size: 0}} - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) - } - }, - ), - }, - { - name: "duplicate_non_zero_length_content_length", - isSelf: true, - capSharing: true, - reqs: []*http.Request{ - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")), - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")), - }, - checks: checks( - httpStatus(200), - func(t *testing.T, env *peerAPITestEnv) { - got, err := env.ph.ps.taildrop.WaitingFiles() - if err != nil { - t.Fatalf("WaitingFiles error: %v", err) - } - want := []apitype.WaitingFile{{Name: "foo", Size: 8}} - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) - } - }, - ), - }, - { - name: "duplicate_different_files", - isSelf: true, - capSharing: true, - reqs: []*http.Request{ - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("fizz")), - httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("buzz")), - }, - checks: checks( - httpStatus(200), - func(t *testing.T, env *peerAPITestEnv) { - got, err := env.ph.ps.taildrop.WaitingFiles() - if err != nil { - t.Fatalf("WaitingFiles error: %v", err) - } - want := []apitype.WaitingFile{{Name: "foo", Size: 4}, {Name: "foo (1)", Size: 4}} - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff) - } - }, - ), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - selfNode := &tailcfg.Node{ - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.100.100.101/32"), - }, - } - if tt.debugCap { - selfNode.CapMap = tailcfg.NodeCapMap{tailcfg.CapabilityDebug: nil} - } - var e peerAPITestEnv - lb := &LocalBackend{ - logf: e.logBuf.Logf, - capFileSharing: tt.capSharing, - netMap: &netmap.NetworkMap{SelfNode: selfNode.View()}, - clock: &tstest.Clock{}, - } - e.ph = &peerAPIHandler{ - isSelf: tt.isSelf, - selfNode: selfNode.View(), - peerNode: (&tailcfg.Node{ - ComputedName: "some-peer-name", - }).View(), - ps: &peerAPIServer{ - b: lb, - }, - } - var rootDir string - if !tt.omitRoot { - rootDir = t.TempDir() - if e.ph.ps.taildrop == nil { - e.ph.ps.taildrop = taildrop.ManagerOptions{ - Logf: e.logBuf.Logf, - Dir: rootDir, - }.New() - } - } - for _, req := range tt.reqs { - e.rr = httptest.NewRecorder() - if req.Host == "example.com" { - req.Host = "100.100.100.101:12345" - } - e.ph.ServeHTTP(e.rr, req) - } - for _, f := range tt.checks { - f(t, &e) - } - if t.Failed() && rootDir != "" { - t.Logf("Contents of %s:", rootDir) - des, _ := fs.ReadDir(os.DirFS(rootDir), ".") - for _, de := range des { - fi, err := de.Info() - if err != nil { - t.Log(err) - } else { - t.Logf(" %v %5d %s", fi.Mode(), fi.Size(), de.Name()) - } - } - } - }) - } -} - -// Windows likes to hold on to file descriptors for some indeterminate -// amount of time after you close them and not let you delete them for -// a bit. So test that we work around that sufficiently. -func TestFileDeleteRace(t *testing.T) { - dir := t.TempDir() - ps := &peerAPIServer{ - b: &LocalBackend{ - logf: t.Logf, - capFileSharing: true, - clock: &tstest.Clock{}, - }, - taildrop: taildrop.ManagerOptions{ - Logf: t.Logf, - Dir: dir, - }.New(), - } - ph := &peerAPIHandler{ - isSelf: true, - peerNode: (&tailcfg.Node{ - ComputedName: "some-peer-name", - }).View(), - selfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.100.101/32")}, - }).View(), - ps: ps, - } - buf := make([]byte, 2<<20) - for range 30 { - rr := httptest.NewRecorder() - ph.ServeHTTP(rr, httptest.NewRequest("PUT", "http://100.100.100.101:123/v0/put/foo.txt", bytes.NewReader(buf[:rand.Intn(len(buf))]))) - if res := rr.Result(); res.StatusCode != 200 { - t.Fatal(res.Status) - } - wfs, err := ps.taildrop.WaitingFiles() - if err != nil { - t.Fatal(err) - } - if len(wfs) != 1 { - t.Fatalf("waiting files = %d; want 1", len(wfs)) - } - - if err := ps.taildrop.DeleteFile("foo.txt"); err != nil { - t.Fatal(err) - } - wfs, err = ps.taildrop.WaitingFiles() - if err != nil { - t.Fatal(err) - } - if len(wfs) != 0 { - t.Fatalf("waiting files = %d; want 0", len(wfs)) - } - } -} - -func TestPeerAPIReplyToDNSQueries(t *testing.T) { - var h peerAPIHandler - - h.isSelf = true - if !h.replyToDNSQueries() { - t.Errorf("for isSelf = false; want true") - } - h.isSelf = false - h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") - - ht := new(health.Tracker) - reg := new(usermetric.Registry) - eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg) - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, ht)) - h.ps = &peerAPIServer{ - b: &LocalBackend{ - e: eng, - pm: pm, - store: pm.Store(), - }, - } - if h.ps.b.OfferingExitNode() { - t.Fatal("unexpectedly offering exit node") - } - h.ps.b.pm.SetPrefs((&ipn.Prefs{ - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/0"), - netip.MustParsePrefix("::/0"), - }, - }).View(), ipn.NetworkProfile{}) - if !h.ps.b.OfferingExitNode() { - t.Fatal("unexpectedly not offering exit node") - } - - if h.replyToDNSQueries() { - t.Errorf("unexpectedly doing DNS without filter") - } - - h.ps.b.setFilter(filter.NewAllowNone(logger.Discard, new(netipx.IPSet))) - if h.replyToDNSQueries() { - t.Errorf("unexpectedly doing DNS without filter") - } - - f := filter.NewAllowAllForTest(logger.Discard) - - h.ps.b.setFilter(f) - if !h.replyToDNSQueries() { - t.Errorf("unexpectedly deny; wanted to be a DNS server") - } - - // Also test IPv6. - h.remoteAddr = netip.MustParseAddrPort("[fe70::1]:12345") - if !h.replyToDNSQueries() { - t.Errorf("unexpectedly IPv6 deny; wanted to be a DNS server") - } -} - -func TestPeerAPIPrettyReplyCNAME(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - var h peerAPIHandler - h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") - - ht := new(health.Tracker) - reg := new(usermetric.Registry) - eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg) - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, ht)) - var a *appc.AppConnector - if shouldStore { - a = appc.NewAppConnector(t.Logf, &appctest.RouteCollector{}, &appc.RouteInfo{}, fakeStoreRoutes) - } else { - a = appc.NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil) - } - h.ps = &peerAPIServer{ - b: &LocalBackend{ - e: eng, - pm: pm, - store: pm.Store(), - // configure as an app connector just to enable the API. - appConnector: a, - }, - } - - h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) { - b.CNAMEResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName("www.example.com."), - Type: dnsmessage.TypeCNAME, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.CNAMEResource{ - CNAME: dnsmessage.MustNewName("example.com."), - }, - ) - b.AResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName("example.com."), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AResource{ - A: [4]byte{192, 0, 0, 8}, - }, - ) - }} - f := filter.NewAllowAllForTest(logger.Discard) - h.ps.b.setFilter(f) - - if !h.replyToDNSQueries() { - t.Errorf("unexpectedly deny; wanted to be a DNS server") - } - - w := httptest.NewRecorder() - h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil)) - if w.Code != http.StatusOK { - t.Errorf("unexpected status code: %v", w.Code) - } - var addrs []string - json.NewDecoder(w.Body).Decode(&addrs) - if len(addrs) == 0 { - t.Fatalf("no addresses returned") - } - for _, addr := range addrs { - netip.MustParseAddr(addr) - } - } -} - -func TestPeerAPIReplyToDNSQueriesAreObserved(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - var h peerAPIHandler - h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") - - rc := &appctest.RouteCollector{} - ht := new(health.Tracker) - reg := new(usermetric.Registry) - eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg) - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, ht)) - var a *appc.AppConnector - if shouldStore { - a = appc.NewAppConnector(t.Logf, rc, &appc.RouteInfo{}, fakeStoreRoutes) - } else { - a = appc.NewAppConnector(t.Logf, rc, nil, nil) - } - h.ps = &peerAPIServer{ - b: &LocalBackend{ - e: eng, - pm: pm, - store: pm.Store(), - appConnector: a, - }, - } - h.ps.b.appConnector.UpdateDomains([]string{"example.com"}) - h.ps.b.appConnector.Wait(ctx) - - h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) { - b.AResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName("example.com."), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AResource{ - A: [4]byte{192, 0, 0, 8}, - }, - ) - }} - f := filter.NewAllowAllForTest(logger.Discard) - h.ps.b.setFilter(f) - - if !h.ps.b.OfferingAppConnector() { - t.Fatal("expecting to be offering app connector") - } - if !h.replyToDNSQueries() { - t.Errorf("unexpectedly deny; wanted to be a DNS server") - } - - w := httptest.NewRecorder() - h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=example.com.", nil)) - if w.Code != http.StatusOK { - t.Errorf("unexpected status code: %v", w.Code) - } - h.ps.b.appConnector.Wait(ctx) - - wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} - if !slices.Equal(rc.Routes(), wantRoutes) { - t.Errorf("got %v; want %v", rc.Routes(), wantRoutes) - } - } -} - -func TestPeerAPIReplyToDNSQueriesAreObservedWithCNAMEFlattening(t *testing.T) { - for _, shouldStore := range []bool{false, true} { - ctx := context.Background() - var h peerAPIHandler - h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345") - - ht := new(health.Tracker) - reg := new(usermetric.Registry) - rc := &appctest.RouteCollector{} - eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0, ht, reg) - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, ht)) - var a *appc.AppConnector - if shouldStore { - a = appc.NewAppConnector(t.Logf, rc, &appc.RouteInfo{}, fakeStoreRoutes) - } else { - a = appc.NewAppConnector(t.Logf, rc, nil, nil) - } - h.ps = &peerAPIServer{ - b: &LocalBackend{ - e: eng, - pm: pm, - store: pm.Store(), - appConnector: a, - }, - } - h.ps.b.appConnector.UpdateDomains([]string{"www.example.com"}) - h.ps.b.appConnector.Wait(ctx) - - h.ps.resolver = &fakeResolver{build: func(b *dnsmessage.Builder) { - b.CNAMEResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName("www.example.com."), - Type: dnsmessage.TypeCNAME, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.CNAMEResource{ - CNAME: dnsmessage.MustNewName("example.com."), - }, - ) - b.AResource( - dnsmessage.ResourceHeader{ - Name: dnsmessage.MustNewName("example.com."), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - TTL: 0, - }, - dnsmessage.AResource{ - A: [4]byte{192, 0, 0, 8}, - }, - ) - }} - f := filter.NewAllowAllForTest(logger.Discard) - h.ps.b.setFilter(f) - - if !h.ps.b.OfferingAppConnector() { - t.Fatal("expecting to be offering app connector") - } - if !h.replyToDNSQueries() { - t.Errorf("unexpectedly deny; wanted to be a DNS server") - } - - w := httptest.NewRecorder() - h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=www.example.com.", nil)) - if w.Code != http.StatusOK { - t.Errorf("unexpected status code: %v", w.Code) - } - h.ps.b.appConnector.Wait(ctx) - - wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")} - if !slices.Equal(rc.Routes(), wantRoutes) { - t.Errorf("got %v; want %v", rc.Routes(), wantRoutes) - } - } -} - -type fakeResolver struct { - build func(*dnsmessage.Builder) -} - -func (f *fakeResolver) HandlePeerDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) { - b := dnsmessage.NewBuilder(nil, dnsmessage.Header{}) - b.EnableCompression() - b.StartAnswers() - f.build(&b) - return b.Finish() -} diff --git a/ipn/ipnlocal/profiles.go b/ipn/ipnlocal/profiles.go index b13f921d66095..8057645eb8da8 100644 --- a/ipn/ipnlocal/profiles.go +++ b/ipn/ipnlocal/profiles.go @@ -13,13 +13,13 @@ import ( "slices" "strings" - "tailscale.com/clientupdate" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/ipn" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" + "github.com/sagernet/tailscale/clientupdate" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" ) var debug = envknob.RegisterBool("TS_DEBUG_PROFILES") diff --git a/ipn/ipnlocal/profiles_notwindows.go b/ipn/ipnlocal/profiles_notwindows.go index 0ca8f439cf9f4..4617187019e2e 100644 --- a/ipn/ipnlocal/profiles_notwindows.go +++ b/ipn/ipnlocal/profiles_notwindows.go @@ -9,8 +9,8 @@ import ( "fmt" "runtime" - "tailscale.com/ipn" - "tailscale.com/version" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/version" ) func (pm *profileManager) loadLegacyPrefs(ipn.WindowsUserID) (string, ipn.PrefsView, error) { diff --git a/ipn/ipnlocal/profiles_test.go b/ipn/ipnlocal/profiles_test.go deleted file mode 100644 index 73e4f6535387e..0000000000000 --- a/ipn/ipnlocal/profiles_test.go +++ /dev/null @@ -1,611 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "fmt" - "os/user" - "strconv" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/clientupdate" - "tailscale.com/health" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/persist" - "tailscale.com/util/must" -) - -func TestProfileCurrentUserSwitch(t *testing.T) { - store := new(mem.Store) - - pm, err := newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - id := 0 - newProfile := func(t *testing.T, loginName string) ipn.PrefsView { - id++ - t.Helper() - pm.NewProfile() - p := pm.CurrentPrefs().AsStruct() - p.Persist = &persist.Persist{ - NodeID: tailcfg.StableNodeID(fmt.Sprint(id)), - PrivateNodeKey: key.NewNode(), - UserProfile: tailcfg.UserProfile{ - ID: tailcfg.UserID(id), - LoginName: loginName, - }, - } - if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil { - t.Fatal(err) - } - return p.View() - } - - pm.SetCurrentUserID("user1") - newProfile(t, "user1") - cp := pm.currentProfile - pm.DeleteProfile(cp.ID) - if pm.currentProfile == nil { - t.Fatal("currentProfile is nil") - } else if pm.currentProfile.ID != "" { - t.Fatalf("currentProfile.ID = %q, want empty", pm.currentProfile.ID) - } - if !pm.CurrentPrefs().Equals(defaultPrefs) { - t.Fatalf("CurrentPrefs() = %v, want emptyPrefs", pm.CurrentPrefs().Pretty()) - } - - pm, err = newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - pm.SetCurrentUserID("user1") - if pm.currentProfile == nil { - t.Fatal("currentProfile is nil") - } else if pm.currentProfile.ID != "" { - t.Fatalf("currentProfile.ID = %q, want empty", pm.currentProfile.ID) - } - if !pm.CurrentPrefs().Equals(defaultPrefs) { - t.Fatalf("CurrentPrefs() = %v, want emptyPrefs", pm.CurrentPrefs().Pretty()) - } -} - -func TestProfileList(t *testing.T) { - store := new(mem.Store) - - pm, err := newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - id := 0 - newProfile := func(t *testing.T, loginName string) ipn.PrefsView { - id++ - t.Helper() - pm.NewProfile() - p := pm.CurrentPrefs().AsStruct() - p.Persist = &persist.Persist{ - NodeID: tailcfg.StableNodeID(fmt.Sprint(id)), - PrivateNodeKey: key.NewNode(), - UserProfile: tailcfg.UserProfile{ - ID: tailcfg.UserID(id), - LoginName: loginName, - }, - } - if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil { - t.Fatal(err) - } - return p.View() - } - checkProfiles := func(t *testing.T, want ...string) { - t.Helper() - got := pm.Profiles() - if len(got) != len(want) { - t.Fatalf("got %d profiles, want %d", len(got), len(want)) - } - for i, w := range want { - if got[i].Name != w { - t.Errorf("got profile %d name %q, want %q", i, got[i].Name, w) - } - } - } - - pm.SetCurrentUserID("user1") - newProfile(t, "alice") - newProfile(t, "bob") - checkProfiles(t, "alice", "bob") - - pm.SetCurrentUserID("user2") - checkProfiles(t) - newProfile(t, "carol") - carol := pm.currentProfile - checkProfiles(t, "carol") - - pm.SetCurrentUserID("user1") - checkProfiles(t, "alice", "bob") - if lp := pm.findProfileByKey(carol.Key); lp != nil { - t.Fatalf("found profile for user2 in user1's profile list") - } - if lp := pm.findProfileByName(carol.Name); lp != nil { - t.Fatalf("found profile for user2 in user1's profile list") - } - - pm.SetCurrentUserID("user2") - checkProfiles(t, "carol") -} - -func TestProfileDupe(t *testing.T) { - newPersist := func(user, node int) *persist.Persist { - return &persist.Persist{ - NodeID: tailcfg.StableNodeID(fmt.Sprintf("node%d", node)), - UserProfile: tailcfg.UserProfile{ - ID: tailcfg.UserID(user), - LoginName: fmt.Sprintf("user%d@example.com", user), - }, - } - } - user1Node1 := newPersist(1, 1) - user1Node2 := newPersist(1, 2) - user2Node1 := newPersist(2, 1) - user2Node2 := newPersist(2, 2) - user3Node3 := newPersist(3, 3) - - reauth := func(pm *profileManager, p *persist.Persist) { - prefs := ipn.NewPrefs() - prefs.Persist = p - must.Do(pm.SetPrefs(prefs.View(), ipn.NetworkProfile{})) - } - login := func(pm *profileManager, p *persist.Persist) { - pm.NewProfile() - reauth(pm, p) - } - - type step struct { - fn func(pm *profileManager, p *persist.Persist) - p *persist.Persist - } - - tests := []struct { - name string - steps []step - profs []*persist.Persist - }{ - { - name: "reauth-new-node", - steps: []step{ - {login, user1Node1}, - {reauth, user3Node3}, - }, - profs: []*persist.Persist{ - user3Node3, - }, - }, - { - name: "reauth-same-node", - steps: []step{ - {login, user1Node1}, - {reauth, user1Node1}, - }, - profs: []*persist.Persist{ - user1Node1, - }, - }, - { - name: "reauth-other-profile", - steps: []step{ - {login, user1Node1}, - {login, user2Node2}, - {reauth, user1Node1}, - }, - profs: []*persist.Persist{ - user1Node1, - user2Node2, - }, - }, - { - name: "reauth-replace-user", - steps: []step{ - {login, user1Node1}, - {login, user3Node3}, - {reauth, user2Node1}, - }, - profs: []*persist.Persist{ - user2Node1, - user3Node3, - }, - }, - { - name: "reauth-replace-node", - steps: []step{ - {login, user1Node1}, - {login, user3Node3}, - {reauth, user1Node2}, - }, - profs: []*persist.Persist{ - user1Node2, - user3Node3, - }, - }, - { - name: "login-same-node", - steps: []step{ - {login, user1Node1}, - {login, user3Node3}, // random other profile - {login, user1Node1}, - }, - profs: []*persist.Persist{ - user1Node1, - user3Node3, - }, - }, - { - name: "login-replace-user", - steps: []step{ - {login, user1Node1}, - {login, user3Node3}, // random other profile - {login, user2Node1}, - }, - profs: []*persist.Persist{ - user2Node1, - user3Node3, - }, - }, - { - name: "login-replace-node", - steps: []step{ - {login, user1Node1}, - {login, user3Node3}, // random other profile - {login, user1Node2}, - }, - profs: []*persist.Persist{ - user1Node2, - user3Node3, - }, - }, - { - name: "login-new-node", - steps: []step{ - {login, user1Node1}, - {login, user2Node2}, - }, - profs: []*persist.Persist{ - user1Node1, - user2Node2, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - store := new(mem.Store) - pm, err := newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - for _, s := range tc.steps { - s.fn(pm, s.p) - } - profs := pm.Profiles() - var got []*persist.Persist - for _, p := range profs { - prefs, err := pm.loadSavedPrefs(p.Key) - if err != nil { - t.Fatal(err) - } - got = append(got, prefs.Persist().AsStruct()) - } - d := cmp.Diff(tc.profs, got, cmpopts.SortSlices(func(a, b *persist.Persist) bool { - if a.NodeID != b.NodeID { - return a.NodeID < b.NodeID - } - return a.UserProfile.ID < b.UserProfile.ID - })) - if d != "" { - t.Fatal(d) - } - }) - } -} - -// TestProfileManagement tests creating, loading, and switching profiles. -func TestProfileManagement(t *testing.T) { - store := new(mem.Store) - - pm, err := newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - wantCurProfile := "" - wantProfiles := map[string]ipn.PrefsView{ - "": defaultPrefs, - } - checkProfiles := func(t *testing.T) { - t.Helper() - prof := pm.CurrentProfile() - t.Logf("\tCurrentProfile = %q", prof) - if prof.Name != wantCurProfile { - t.Fatalf("CurrentProfile = %q; want %q", prof, wantCurProfile) - } - profiles := pm.Profiles() - wantLen := len(wantProfiles) - if _, ok := wantProfiles[""]; ok { - wantLen-- - } - if len(profiles) != wantLen { - t.Fatalf("Profiles = %v; want %v", profiles, wantProfiles) - } - p := pm.CurrentPrefs() - t.Logf("\tCurrentPrefs = %s", p.Pretty()) - if !p.Valid() { - t.Fatalf("CurrentPrefs = %v; want valid", p) - } - if !p.Equals(wantProfiles[wantCurProfile]) { - t.Fatalf("CurrentPrefs = %v; want %v", p.Pretty(), wantProfiles[wantCurProfile].Pretty()) - } - for _, p := range profiles { - got, err := pm.loadSavedPrefs(p.Key) - if err != nil { - t.Fatal(err) - } - // Use Hostname as a proxy for all prefs. - if !got.Equals(wantProfiles[p.Name]) { - t.Fatalf("Prefs for profile %q =\n got=%+v\nwant=%v", p, got.Pretty(), wantProfiles[p.Name].Pretty()) - } - } - } - logins := make(map[string]tailcfg.UserID) - nodeIDs := make(map[string]tailcfg.StableNodeID) - setPrefs := func(t *testing.T, loginName string) ipn.PrefsView { - t.Helper() - p := pm.CurrentPrefs().AsStruct() - uid := logins[loginName] - if uid.IsZero() { - uid = tailcfg.UserID(len(logins) + 1) - logins[loginName] = uid - } - nid := nodeIDs[loginName] - if nid.IsZero() { - nid = tailcfg.StableNodeID(fmt.Sprint(len(nodeIDs) + 1)) - nodeIDs[loginName] = nid - } - p.Persist = &persist.Persist{ - PrivateNodeKey: key.NewNode(), - UserProfile: tailcfg.UserProfile{ - ID: uid, - LoginName: loginName, - }, - NodeID: nid, - } - if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil { - t.Fatal(err) - } - return p.View() - } - t.Logf("Check initial state from empty store") - checkProfiles(t) - - { - t.Logf("Set prefs for default profile") - wantProfiles["user@1.example.com"] = setPrefs(t, "user@1.example.com") - wantCurProfile = "user@1.example.com" - delete(wantProfiles, "") - } - checkProfiles(t) - - t.Logf("Create new profile") - pm.NewProfile() - wantCurProfile = "" - wantProfiles[""] = defaultPrefs - checkProfiles(t) - - { - t.Logf("Set prefs for test profile") - wantProfiles["user@2.example.com"] = setPrefs(t, "user@2.example.com") - wantCurProfile = "user@2.example.com" - delete(wantProfiles, "") - } - checkProfiles(t) - - t.Logf("Recreate profile manager from store") - // Recreate the profile manager to ensure that it can load the profiles - // from the store at startup. - pm, err = newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - checkProfiles(t) - - t.Logf("Delete default profile") - if err := pm.DeleteProfile(pm.findProfileByName("user@1.example.com").ID); err != nil { - t.Fatal(err) - } - delete(wantProfiles, "user@1.example.com") - checkProfiles(t) - - t.Logf("Recreate profile manager from store after deleting default profile") - // Recreate the profile manager to ensure that it can load the profiles - // from the store at startup. - pm, err = newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - checkProfiles(t) - - t.Logf("Create new profile - 2") - pm.NewProfile() - wantCurProfile = "" - wantProfiles[""] = defaultPrefs - checkProfiles(t) - - t.Logf("Login with the existing profile") - wantProfiles["user@2.example.com"] = setPrefs(t, "user@2.example.com") - delete(wantProfiles, "") - wantCurProfile = "user@2.example.com" - checkProfiles(t) - - t.Logf("Tag the current the profile") - nodeIDs["tagged-node.2.ts.net"] = nodeIDs["user@2.example.com"] - wantProfiles["tagged-node.2.ts.net"] = setPrefs(t, "tagged-node.2.ts.net") - delete(wantProfiles, "user@2.example.com") - wantCurProfile = "tagged-node.2.ts.net" - checkProfiles(t) - - t.Logf("Relogin") - wantProfiles["user@2.example.com"] = setPrefs(t, "user@2.example.com") - delete(wantProfiles, "tagged-node.2.ts.net") - wantCurProfile = "user@2.example.com" - checkProfiles(t) - - if !clientupdate.CanAutoUpdate() { - t.Logf("Save an invalid AutoUpdate pref value") - prefs := pm.CurrentPrefs().AsStruct() - prefs.AutoUpdate.Apply.Set(true) - if err := pm.SetPrefs(prefs.View(), ipn.NetworkProfile{}); err != nil { - t.Fatal(err) - } - if !pm.CurrentPrefs().AutoUpdate().Apply.EqualBool(true) { - t.Fatal("SetPrefs failed to save auto-update setting") - } - // Re-load profiles to trigger migration for invalid auto-update value. - pm, err = newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "linux") - if err != nil { - t.Fatal(err) - } - checkProfiles(t) - if pm.CurrentPrefs().AutoUpdate().Apply.EqualBool(true) { - t.Fatal("invalid auto-update setting persisted after reload") - } - } -} - -// TestProfileManagementWindows tests going into and out of Unattended mode on -// Windows. -func TestProfileManagementWindows(t *testing.T) { - u, err := user.Current() - if err != nil { - t.Fatal(err) - } - uid := ipn.WindowsUserID(u.Uid) - - store := new(mem.Store) - - pm, err := newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "windows") - if err != nil { - t.Fatal(err) - } - wantCurProfile := "" - wantProfiles := map[string]ipn.PrefsView{ - "": defaultPrefs, - } - checkProfiles := func(t *testing.T) { - t.Helper() - prof := pm.CurrentProfile() - t.Logf("\tCurrentProfile = %q", prof) - if prof.Name != wantCurProfile { - t.Fatalf("CurrentProfile = %q; want %q", prof, wantCurProfile) - } - if p := pm.CurrentPrefs(); !p.Equals(wantProfiles[wantCurProfile]) { - t.Fatalf("CurrentPrefs = %+v; want %+v", p.Pretty(), wantProfiles[wantCurProfile].Pretty()) - } - } - logins := make(map[string]tailcfg.UserID) - setPrefs := func(t *testing.T, loginName string, forceDaemon bool) ipn.PrefsView { - id := logins[loginName] - if id.IsZero() { - id = tailcfg.UserID(len(logins) + 1) - logins[loginName] = id - } - p := pm.CurrentPrefs().AsStruct() - p.ForceDaemon = forceDaemon - p.Persist = &persist.Persist{ - UserProfile: tailcfg.UserProfile{ - ID: id, - LoginName: loginName, - }, - NodeID: tailcfg.StableNodeID(strconv.Itoa(int(id))), - } - if err := pm.SetPrefs(p.View(), ipn.NetworkProfile{}); err != nil { - t.Fatal(err) - } - return p.View() - } - t.Logf("Check initial state from empty store") - checkProfiles(t) - - { - t.Logf("Set user1 as logged in user") - pm.SetCurrentUserID(uid) - checkProfiles(t) - t.Logf("Save prefs for user1") - wantProfiles["default"] = setPrefs(t, "default", false) - wantCurProfile = "default" - } - checkProfiles(t) - - { - t.Logf("Create new profile") - pm.NewProfile() - wantCurProfile = "" - wantProfiles[""] = defaultPrefs - checkProfiles(t) - - t.Logf("Save as test profile") - wantProfiles["test"] = setPrefs(t, "test", false) - wantCurProfile = "test" - checkProfiles(t) - } - - t.Logf("Recreate profile manager from store, should reset prefs") - // Recreate the profile manager to ensure that it can load the profiles - // from the store at startup. - pm, err = newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "windows") - if err != nil { - t.Fatal(err) - } - wantCurProfile = "" - wantProfiles[""] = defaultPrefs - checkProfiles(t) - - { - t.Logf("Set user1 as current user") - pm.SetCurrentUserID(uid) - wantCurProfile = "test" - } - checkProfiles(t) - { - t.Logf("set unattended mode") - wantProfiles["test"] = setPrefs(t, "test", true) - } - if pm.CurrentUserID() != uid { - t.Fatalf("CurrentUserID = %q; want %q", pm.CurrentUserID(), uid) - } - - // Recreate the profile manager to ensure that it starts with test profile. - pm, err = newProfileManagerWithGOOS(store, logger.Discard, new(health.Tracker), "windows") - if err != nil { - t.Fatal(err) - } - checkProfiles(t) - if pm.CurrentUserID() != uid { - t.Fatalf("CurrentUserID = %q; want %q", pm.CurrentUserID(), uid) - } -} - -// TestDefaultPrefs tests that defaultPrefs is just NewPrefs with -// LoggedOut=true (the Prefs we use before connecting to control). We shouldn't -// be putting any defaulting there, and instead put all defaults in NewPrefs. -func TestDefaultPrefs(t *testing.T) { - p1 := ipn.NewPrefs() - p1.LoggedOut = true - p1.WantRunning = false - p2 := defaultPrefs - if !p1.View().Equals(p2) { - t.Errorf("defaultPrefs is %s, want %s; defaultPrefs should only modify WantRunning and LoggedOut, all other defaults should be in ipn.NewPrefs.", p2.Pretty(), p1.Pretty()) - } -} diff --git a/ipn/ipnlocal/profiles_windows.go b/ipn/ipnlocal/profiles_windows.go index c4beb22f9d42f..a57cc37db5577 100644 --- a/ipn/ipnlocal/profiles_windows.go +++ b/ipn/ipnlocal/profiles_windows.go @@ -11,9 +11,9 @@ import ( "os/user" "path/filepath" - "tailscale.com/atomicfile" - "tailscale.com/ipn" - "tailscale.com/util/winutil/policy" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/util/winutil/policy" ) const ( diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 61bed05527167..db8d38ae4d4c0 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -28,17 +28,17 @@ import ( "time" "unicode/utf8" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/logtail/backoff" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/ctxkey" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/version" "golang.org/x/net/http2" - "tailscale.com/ipn" - "tailscale.com/logtail/backoff" - "tailscale.com/net/netutil" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/types/lazy" - "tailscale.com/types/logger" - "tailscale.com/util/ctxkey" - "tailscale.com/util/mak" - "tailscale.com/version" ) const ( diff --git a/ipn/ipnlocal/serve_test.go b/ipn/ipnlocal/serve_test.go deleted file mode 100644 index 73e66c2b9db16..0000000000000 --- a/ipn/ipnlocal/serve_test.go +++ /dev/null @@ -1,865 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "bytes" - "cmp" - "context" - "crypto/sha256" - "crypto/tls" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "net/netip" - "net/url" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "tailscale.com/health" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/netmap" - "tailscale.com/util/mak" - "tailscale.com/util/must" - "tailscale.com/wgengine" -) - -func TestExpandProxyArg(t *testing.T) { - type res struct { - target string - insecure bool - } - tests := []struct { - in string - want res - }{ - {"", res{}}, - {"3030", res{"http://127.0.0.1:3030", false}}, - {"localhost:3030", res{"http://localhost:3030", false}}, - {"10.2.3.5:3030", res{"http://10.2.3.5:3030", false}}, - {"http://foo.com", res{"http://foo.com", false}}, - {"https://foo.com", res{"https://foo.com", false}}, - {"https+insecure://10.2.3.4", res{"https://10.2.3.4", true}}, - } - for _, tt := range tests { - target, insecure := expandProxyArg(tt.in) - got := res{target, insecure} - if got != tt.want { - t.Errorf("expandProxyArg(%q) = %v, want %v", tt.in, got, tt.want) - } - } -} - -func TestGetServeHandler(t *testing.T) { - const serverName = "example.ts.net" - conf1 := &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - serverName + ":443": { - Handlers: map[string]*ipn.HTTPHandler{ - "/": {}, - "/bar": {}, - "/foo/": {}, - "/foo/bar": {}, - "/foo/bar/": {}, - }, - }, - }, - } - - tests := []struct { - name string - port uint16 // or 443 is zero - path string // http.Request.URL.Path - conf *ipn.ServeConfig - want string // mountPoint - }{ - { - name: "nothing", - path: "/", - conf: nil, - want: "", - }, - { - name: "root", - conf: conf1, - path: "/", - want: "/", - }, - { - name: "root-other", - conf: conf1, - path: "/other", - want: "/", - }, - { - name: "bar", - conf: conf1, - path: "/bar", - want: "/bar", - }, - { - name: "foo-bar", - conf: conf1, - path: "/foo/bar", - want: "/foo/bar", - }, - { - name: "foo-bar-slash", - conf: conf1, - path: "/foo/bar/", - want: "/foo/bar/", - }, - { - name: "foo-bar-other", - conf: conf1, - path: "/foo/bar/other", - want: "/foo/bar/", - }, - { - name: "foo-other", - conf: conf1, - path: "/foo/other", - want: "/foo/", - }, - { - name: "foo-no-trailing-slash", - conf: conf1, - path: "/foo", - want: "/foo/", - }, - { - name: "dot-dots", - conf: conf1, - path: "/foo/../../../../../../../../etc/passwd", - want: "/", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b := &LocalBackend{ - serveConfig: tt.conf.View(), - logf: t.Logf, - } - req := &http.Request{ - URL: &url.URL{ - Path: tt.path, - }, - TLS: &tls.ConnectionState{ServerName: serverName}, - } - port := cmp.Or(tt.port, 443) - req = req.WithContext(serveHTTPContextKey.WithValue(req.Context(), &serveHTTPContext{ - DestPort: port, - })) - - h, got, ok := b.getServeHandler(req) - if (got != "") != ok { - t.Fatalf("got ok=%v, but got mountPoint=%q", ok, got) - } - if h.Valid() != ok { - t.Fatalf("got ok=%v, but valid=%v", ok, h.Valid()) - } - if got != tt.want { - t.Errorf("got handler at mount %q, want %q", got, tt.want) - } - }) - } -} - -func getEtag(t *testing.T, b any) string { - t.Helper() - bts, err := json.Marshal(b) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(bts) - return hex.EncodeToString(sum[:]) -} - -// TestServeConfigForeground tests the inter-dependency -// between a ServeConfig and a WatchIPNBus: -// 1. Creating a WatchIPNBus returns a sessionID, that -// 2. ServeConfig sets it as the key of the Foreground field. -// 3. ServeConfig expects the WatchIPNBus to clean up the Foreground -// config when the session is done. -// 4. WatchIPNBus expects the ServeConfig to send a signal (close the channel) -// if an incoming SetServeConfig removes previous foregrounds. -func TestServeConfigForeground(t *testing.T) { - b := newTestBackend(t) - - ch1 := make(chan string, 1) - go func() { - defer close(ch1) - b.WatchNotifications(context.Background(), ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) { - if roNotify.SessionID != "" { - ch1 <- roNotify.SessionID - } - return true - }) - }() - - ch2 := make(chan string, 1) - go func() { - b.WatchNotifications(context.Background(), ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) { - if roNotify.SessionID != "" { - ch2 <- roNotify.SessionID - return true - } - ch2 <- "again" // let channel know fn was called again - return true - }) - }() - - var session1 string - select { - case session1 = <-ch1: - case <-time.After(time.Second): - t.Fatal("timed out waiting on watch notifications session id") - } - - var session2 string - select { - case session2 = <-ch2: - case <-time.After(time.Second): - t.Fatal("timed out waiting on watch notifications session id") - } - - err := b.SetServeConfig(&ipn.ServeConfig{ - Foreground: map[string]*ipn.ServeConfig{ - session1: {TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "http://localhost:3000"}}, - }, - session2: {TCP: map[uint16]*ipn.TCPPortHandler{ - 999: {TCPForward: "http://localhost:4000"}}, - }, - }, - }, "") - if err != nil { - t.Fatal(err) - } - - // Introduce a race between [LocalBackend] sending notifications - // and [LocalBackend.WatchNotifications] shutting down due to - // setting the serve config below. - const N = 1000 - for range N { - go b.send(ipn.Notify{}) - } - - // Setting a new serve config should shut down WatchNotifications - // whose session IDs are no longer found: session1 goes, session2 stays. - err = b.SetServeConfig(&ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{ - 5000: {TCPForward: "http://localhost:5000"}, - }, - Foreground: map[string]*ipn.ServeConfig{ - session2: {TCP: map[uint16]*ipn.TCPPortHandler{ - 999: {TCPForward: "http://localhost:4000"}}, - }, - }, - }, "") - if err != nil { - t.Fatal(err) - } - - select { - case _, ok := <-ch1: - if ok { - t.Fatal("expected channel to be closed") - } - case <-time.After(time.Second): - t.Fatal("timed out waiting on watch notifications closing") - } - - // check that the second session is still running - b.send(ipn.Notify{}) - select { - case _, ok := <-ch2: - if !ok { - t.Fatal("expected second session to remain open") - } - case <-time.After(time.Second): - t.Fatal("timed out waiting on second session") - } -} - -func TestServeConfigETag(t *testing.T) { - b := newTestBackend(t) - - // a nil config with initial etag should succeed - err := b.SetServeConfig(nil, getEtag(t, nil)) - if err != nil { - t.Fatal(err) - } - - // a nil config with an invalid etag should fail - err = b.SetServeConfig(nil, "abc") - if !errors.Is(err, ErrETagMismatch) { - t.Fatal("expected an error but got nil") - } - - // a new config with no etag should succeed - conf := &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - } - err = b.SetServeConfig(conf, getEtag(t, nil)) - if err != nil { - t.Fatal(err) - } - - confView := b.ServeConfig() - etag := getEtag(t, confView) - if etag == "" { - t.Fatal("expected to get an etag but got an empty string") - } - conf = confView.AsStruct() - mak.Set(&conf.AllowFunnel, "example.ts.net:443", true) - - // replacing an existing config with an invalid etag should fail - err = b.SetServeConfig(conf, "invalid etag") - if !errors.Is(err, ErrETagMismatch) { - t.Fatalf("expected an etag mismatch error but got %v", err) - } - - // replacing an existing config with a valid etag should succeed - err = b.SetServeConfig(conf, etag) - if err != nil { - t.Fatal(err) - } - - // replacing an existing config with a previous etag should fail - err = b.SetServeConfig(nil, etag) - if !errors.Is(err, ErrETagMismatch) { - t.Fatalf("expected an etag mismatch error but got %v", err) - } - - // replacing an existing config with the new etag should succeed - newCfg := b.ServeConfig() - etag = getEtag(t, newCfg) - err = b.SetServeConfig(nil, etag) - if err != nil { - t.Fatal(err) - } -} - -func TestServeHTTPProxyPath(t *testing.T) { - b := newTestBackend(t) - // Start test serve endpoint. - testServ := httptest.NewServer(http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - // Set the request URL path to a response header, so the - // requested URL path can be checked in tests. - t.Logf("adding path %s", r.URL.Path) - w.Header().Add("Path", r.URL.Path) - }, - )) - defer testServ.Close() - tests := []struct { - name string - mountPoint string - proxyPath string - requestPath string - wantRequestPath string - }{ - { - name: "/foo -> /foo, with mount point and path /foo", - mountPoint: "/foo", - proxyPath: "/foo", - requestPath: "/foo", - wantRequestPath: "/foo", - }, - { - name: "/foo/ -> /foo/, with mount point and path /foo", - mountPoint: "/foo", - proxyPath: "/foo", - requestPath: "/foo/", - wantRequestPath: "/foo/", - }, - { - name: "/foo -> /foo/, with mount point and path /foo/", - mountPoint: "/foo/", - proxyPath: "/foo/", - requestPath: "/foo", - wantRequestPath: "/foo/", - }, - { - name: "/-> /, with mount point and path /", - mountPoint: "/", - proxyPath: "/", - requestPath: "/", - wantRequestPath: "/", - }, - { - name: "/foo -> /foo, with mount point and path /", - mountPoint: "/", - proxyPath: "/", - requestPath: "/foo", - wantRequestPath: "/foo", - }, - { - name: "/foo/bar -> /foo/bar, with mount point and path /foo", - mountPoint: "/foo", - proxyPath: "/foo", - requestPath: "/foo/bar", - wantRequestPath: "/foo/bar", - }, - { - name: "/foo/bar/baz -> /foo/bar/baz, with mount point and path /foo", - mountPoint: "/foo", - proxyPath: "/foo", - requestPath: "/foo/bar/baz", - wantRequestPath: "/foo/bar/baz", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - conf := &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - tt.mountPoint: {Proxy: testServ.URL + tt.proxyPath}, - }}, - }, - } - if err := b.SetServeConfig(conf, ""); err != nil { - t.Fatal(err) - } - req := &http.Request{ - URL: &url.URL{Path: tt.requestPath}, - TLS: &tls.ConnectionState{ServerName: "example.ts.net"}, - } - req = req.WithContext(serveHTTPContextKey.WithValue(req.Context(), - &serveHTTPContext{ - DestPort: 443, - SrcAddr: netip.MustParseAddrPort("1.2.3.4:1234"), // random src - })) - - w := httptest.NewRecorder() - b.serveWebHandler(w, req) - - // Verify what path was requested - p := w.Result().Header.Get("Path") - if p != tt.wantRequestPath { - t.Errorf("wanted request path %s got %s", tt.wantRequestPath, p) - } - }) - } -} -func TestServeHTTPProxyHeaders(t *testing.T) { - b := newTestBackend(t) - - // Start test serve endpoint. - testServ := httptest.NewServer(http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - // Piping all the headers through the response writer - // so we can check their values in tests below. - for key, val := range r.Header { - w.Header().Add(key, strings.Join(val, ",")) - } - }, - )) - defer testServ.Close() - - conf := &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: testServ.URL}, - }}, - }, - } - if err := b.SetServeConfig(conf, ""); err != nil { - t.Fatal(err) - } - - type headerCheck struct { - header string - want string - } - - tests := []struct { - name string - srcIP string - wantHeaders []headerCheck - }{ - { - name: "request-from-user-within-tailnet", - srcIP: "100.150.151.152", - wantHeaders: []headerCheck{ - {"X-Forwarded-Proto", "https"}, - {"X-Forwarded-For", "100.150.151.152"}, - {"Tailscale-User-Login", "someone@example.com"}, - {"Tailscale-User-Name", "Some One"}, - {"Tailscale-User-Profile-Pic", "https://example.com/photo.jpg"}, - {"Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers"}, - }, - }, - { - name: "request-from-tagged-node-within-tailnet", - srcIP: "100.150.151.153", - wantHeaders: []headerCheck{ - {"X-Forwarded-Proto", "https"}, - {"X-Forwarded-For", "100.150.151.153"}, - {"Tailscale-User-Login", ""}, - {"Tailscale-User-Name", ""}, - {"Tailscale-User-Profile-Pic", ""}, - {"Tailscale-Headers-Info", ""}, - }, - }, - { - name: "request-from-outside-tailnet", - srcIP: "100.160.161.162", - wantHeaders: []headerCheck{ - {"X-Forwarded-Proto", "https"}, - {"X-Forwarded-For", "100.160.161.162"}, - {"Tailscale-User-Login", ""}, - {"Tailscale-User-Name", ""}, - {"Tailscale-User-Profile-Pic", ""}, - {"Tailscale-Headers-Info", ""}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := &http.Request{ - URL: &url.URL{Path: "/"}, - TLS: &tls.ConnectionState{ServerName: "example.ts.net"}, - } - req = req.WithContext(serveHTTPContextKey.WithValue(req.Context(), &serveHTTPContext{ - DestPort: 443, - SrcAddr: netip.MustParseAddrPort(tt.srcIP + ":1234"), // random src port for tests - })) - - w := httptest.NewRecorder() - b.serveWebHandler(w, req) - - // Verify the headers. - h := w.Result().Header - for _, c := range tt.wantHeaders { - if got := h.Get(c.header); got != c.want { - t.Errorf("invalid %q header; want=%q, got=%q", c.header, c.want, got) - } - } - }) - } -} - -func Test_reverseProxyConfiguration(t *testing.T) { - b := newTestBackend(t) - type test struct { - backend string - path string - // set to false to test that a proxy has been removed - shouldExist bool - wantsInsecure bool - wantsURL url.URL - } - runner := func(name string, tests []test) { - t.Logf("running tests for %s", name) - host := ipn.HostPort("http://example.ts.net:80") - conf := &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - host: {Handlers: map[string]*ipn.HTTPHandler{}}, - }, - } - for _, tt := range tests { - if tt.shouldExist { - conf.Web[host].Handlers[tt.path] = &ipn.HTTPHandler{Proxy: tt.backend} - } - } - if err := b.setServeConfigLocked(conf, ""); err != nil { - t.Fatal(err) - } - // test that reverseproxies have been set up as expected - for _, tt := range tests { - rp, ok := b.serveProxyHandlers.Load(tt.backend) - if !tt.shouldExist && ok { - t.Errorf("proxy for backend %s should not exist, but it does", tt.backend) - } - if !tt.shouldExist { - continue - } - parsedRp, ok := rp.(*reverseProxy) - if !ok { - t.Errorf("proxy for backend %q is not a reverseproxy", tt.backend) - } - if parsedRp.insecure != tt.wantsInsecure { - t.Errorf("proxy for backend %q should be insecure: %v got insecure: %v", tt.backend, tt.wantsInsecure, parsedRp.insecure) - } - if !reflect.DeepEqual(*parsedRp.url, tt.wantsURL) { - t.Errorf("proxy for backend %q should have URL %#+v, got URL %+#v", tt.backend, &tt.wantsURL, parsedRp.url) - } - if tt.backend != parsedRp.backend { - t.Errorf("proxy for backend %q should have backend %q got %q", tt.backend, tt.backend, parsedRp.backend) - } - } - } - - // configure local backend with some proxy backends - runner("initial proxy configs", []test{ - { - backend: "http://example.com/docs", - path: "/example", - shouldExist: true, - wantsInsecure: false, - wantsURL: mustCreateURL(t, "http://example.com/docs"), - }, - { - backend: "https://example1.com", - path: "/example1", - shouldExist: true, - wantsInsecure: false, - wantsURL: mustCreateURL(t, "https://example1.com"), - }, - { - backend: "https+insecure://example2.com", - path: "/example2", - shouldExist: true, - wantsInsecure: true, - wantsURL: mustCreateURL(t, "https://example2.com"), - }, - }) - - // reconfigure the local backend with different proxies - runner("reloaded proxy configs", []test{ - { - backend: "http://example.com/docs", - path: "/example", - shouldExist: true, - wantsInsecure: false, - wantsURL: mustCreateURL(t, "http://example.com/docs"), - }, - { - backend: "https://example1.com", - shouldExist: false, - }, - { - backend: "https+insecure://example2.com", - shouldExist: false, - }, - { - backend: "https+insecure://example3.com", - path: "/example3", - shouldExist: true, - wantsInsecure: true, - wantsURL: mustCreateURL(t, "https://example3.com"), - }, - }) - -} - -func mustCreateURL(t *testing.T, u string) url.URL { - t.Helper() - uParsed, err := url.Parse(u) - if err != nil { - t.Fatalf("failed parsing url: %v", err) - } - return *uParsed -} - -func newTestBackend(t *testing.T) *LocalBackend { - var logf logger.Logf = logger.Discard - const debug = true - if debug { - logf = logger.WithPrefix(tstest.WhileTestRunningLogger(t), "... ") - } - - sys := &tsd.System{} - e, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ - SetSubsystem: sys.Set, - HealthTracker: sys.HealthTracker(), - Metrics: sys.UserMetricsRegistry(), - }) - if err != nil { - t.Fatal(err) - } - sys.Set(e) - sys.Set(new(mem.Store)) - - b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatal(err) - } - t.Cleanup(b.Shutdown) - dir := t.TempDir() - b.SetVarRoot(dir) - - pm := must.Get(newProfileManager(new(mem.Store), logf, new(health.Tracker))) - pm.currentProfile = &ipn.LoginProfile{ID: "id0"} - b.pm = pm - - b.netMap = &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - Name: "example.ts.net", - }).View(), - UserProfiles: map[tailcfg.UserID]tailcfg.UserProfile{ - tailcfg.UserID(1): { - LoginName: "someone@example.com", - DisplayName: "Some One", - ProfilePicURL: "https://example.com/photo.jpg", - }, - }, - } - b.peers = map[tailcfg.NodeID]tailcfg.NodeView{ - 152: (&tailcfg.Node{ - ID: 152, - ComputedName: "some-peer", - User: tailcfg.UserID(1), - }).View(), - 153: (&tailcfg.Node{ - ID: 153, - ComputedName: "some-tagged-peer", - Tags: []string{"tag:server", "tag:test"}, - User: tailcfg.UserID(1), - }).View(), - } - b.nodeByAddr = map[netip.Addr]tailcfg.NodeID{ - netip.MustParseAddr("100.150.151.152"): 152, - netip.MustParseAddr("100.150.151.153"): 153, - } - return b -} - -func TestServeFileOrDirectory(t *testing.T) { - td := t.TempDir() - writeFile := func(suffix, contents string) { - if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil { - t.Fatal(err) - } - } - writeFile("foo", "this is foo") - writeFile("bar", "this is bar") - os.MkdirAll(filepath.Join(td, "subdir"), 0700) - writeFile("subdir/file-a", "this is A") - writeFile("subdir/file-b", "this is B") - writeFile("subdir/file-c", "this is C") - - contains := func(subs ...string) func([]byte, *http.Response) error { - return func(resBody []byte, res *http.Response) error { - for _, sub := range subs { - if !bytes.Contains(resBody, []byte(sub)) { - return fmt.Errorf("response body does not contain %q: %s", sub, resBody) - } - } - return nil - } - } - isStatus := func(wantCode int) func([]byte, *http.Response) error { - return func(resBody []byte, res *http.Response) error { - if res.StatusCode != wantCode { - return fmt.Errorf("response status = %d; want %d", res.StatusCode, wantCode) - } - return nil - } - } - isRedirect := func(wantLocation string) func([]byte, *http.Response) error { - return func(resBody []byte, res *http.Response) error { - switch res.StatusCode { - case 301, 302, 303, 307, 308: - if got := res.Header.Get("Location"); got != wantLocation { - return fmt.Errorf("got Location = %q; want %q", got, wantLocation) - } - default: - return fmt.Errorf("response status = %d; want redirect. body: %s", res.StatusCode, resBody) - } - return nil - } - } - - b := &LocalBackend{} - - tests := []struct { - req string - mount string - want func(resBody []byte, res *http.Response) error - }{ - // Mounted at / - - {"/", "/", contains("foo", "bar", "subdir")}, - {"/../../.../../../../../../../etc/passwd", "/", isStatus(404)}, - {"/foo", "/", contains("this is foo")}, - {"/bar", "/", contains("this is bar")}, - {"/bar/inside-file", "/", isStatus(404)}, - {"/subdir", "/", isRedirect("/subdir/")}, - {"/subdir/", "/", contains("file-a", "file-b", "file-c")}, - {"/subdir/file-a", "/", contains("this is A")}, - {"/subdir/file-z", "/", isStatus(404)}, - - {"/doc", "/doc/", isRedirect("/doc/")}, - {"/doc/", "/doc/", contains("foo", "bar", "subdir")}, - {"/doc/../../.../../../../../../../etc/passwd", "/doc/", isStatus(404)}, - {"/doc/foo", "/doc/", contains("this is foo")}, - {"/doc/bar", "/doc/", contains("this is bar")}, - {"/doc/bar/inside-file", "/doc/", isStatus(404)}, - {"/doc/subdir", "/doc/", isRedirect("/doc/subdir/")}, - {"/doc/subdir/", "/doc/", contains("file-a", "file-b", "file-c")}, - {"/doc/subdir/file-a", "/doc/", contains("this is A")}, - {"/doc/subdir/file-z", "/doc/", isStatus(404)}, - } - for _, tt := range tests { - rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", tt.req, nil) - b.serveFileOrDirectory(rec, req, td, tt.mount) - if tt.want == nil { - t.Errorf("no want for path %q", tt.req) - return - } - if err := tt.want(rec.Body.Bytes(), rec.Result()); err != nil { - t.Errorf("error for req %q (mount %v): %v", tt.req, tt.mount, err) - } - } -} - -func Test_isGRPCContentType(t *testing.T) { - tests := []struct { - contentType string - want bool - }{ - {contentType: "application/grpc", want: true}, - {contentType: "application/grpc;", want: true}, - {contentType: "application/grpc+", want: true}, - {contentType: "application/grpcfoobar"}, - {contentType: "application/text"}, - {contentType: "foobar"}, - {contentType: ""}, - } - for _, tt := range tests { - if got := isGRPCContentType(tt.contentType); got != tt.want { - t.Errorf("isGRPCContentType(%q) = %v, want %v", tt.contentType, got, tt.want) - } - } -} - -func TestEncTailscaleHeaderValue(t *testing.T) { - tests := []struct { - in string - want string - }{ - {"", ""}, - {"Alice Smith", "Alice Smith"}, - {"Bad\xffUTF-8", ""}, - {"Krūmiņa", "=?utf-8?q?Kr=C5=ABmi=C5=86a?="}, - } - for _, tt := range tests { - got := encTailscaleHeaderValue(tt.in) - if got != tt.want { - t.Errorf("encTailscaleHeaderValue(%q) = %q, want %q", tt.in, got, tt.want) - } - } -} diff --git a/ipn/ipnlocal/ssh.go b/ipn/ipnlocal/ssh.go index 383d03f5aa9be..b1412339c088c 100644 --- a/ipn/ipnlocal/ssh.go +++ b/ipn/ipnlocal/ssh.go @@ -24,11 +24,11 @@ import ( "strings" "sync" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/util/lineiter" + "github.com/sagernet/tailscale/util/mak" "github.com/tailscale/golang-x-crypto/ssh" "go4.org/mem" - "tailscale.com/tailcfg" - "tailscale.com/util/lineiter" - "tailscale.com/util/mak" ) // keyTypes are the SSH key types that we either try to read from the diff --git a/ipn/ipnlocal/ssh_stub.go b/ipn/ipnlocal/ssh_stub.go index 7875ae3111f58..32c8200bf0e15 100644 --- a/ipn/ipnlocal/ssh_stub.go +++ b/ipn/ipnlocal/ssh_stub.go @@ -8,7 +8,7 @@ package ipnlocal import ( "errors" - "tailscale.com/tailcfg" + "github.com/sagernet/tailscale/tailcfg" ) func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) { diff --git a/ipn/ipnlocal/ssh_test.go b/ipn/ipnlocal/ssh_test.go deleted file mode 100644 index 6e93b34f05019..0000000000000 --- a/ipn/ipnlocal/ssh_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || (darwin && !ios) - -package ipnlocal - -import ( - "encoding/json" - "reflect" - "testing" - - "tailscale.com/health" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/util/must" -) - -func TestSSHKeyGen(t *testing.T) { - dir := t.TempDir() - lb := &LocalBackend{varRoot: dir} - keys, err := lb.getTailscaleSSH_HostKeys(nil) - if err != nil { - t.Fatal(err) - } - got := map[string]bool{} - for _, k := range keys { - got[k.PublicKey().Type()] = true - } - want := map[string]bool{ - "ssh-rsa": true, - "ecdsa-sha2-nistp256": true, - "ssh-ed25519": true, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("keys = %v; want %v", got, want) - } - - keys2, err := lb.getTailscaleSSH_HostKeys(nil) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(keys, keys2) { - t.Errorf("got different keys on second call") - } -} - -type fakeSSHServer struct { - SSHServer -} - -func TestGetSSHUsernames(t *testing.T) { - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, new(health.Tracker))) - b := &LocalBackend{pm: pm, store: pm.Store()} - b.sshServer = fakeSSHServer{} - res, err := b.getSSHUsernames(new(tailcfg.C2NSSHUsernamesRequest)) - if err != nil { - t.Fatal(err) - } - t.Logf("Got: %s", must.Get(json.Marshal(res))) -} diff --git a/ipn/ipnlocal/state_test.go b/ipn/ipnlocal/state_test.go deleted file mode 100644 index bebd0152b5a36..0000000000000 --- a/ipn/ipnlocal/state_test.go +++ /dev/null @@ -1,1077 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" - - qt "github.com/frankban/quicktest" - - "tailscale.com/control/controlclient" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/netmap" - "tailscale.com/types/persist" - "tailscale.com/wgengine" -) - -// notifyThrottler receives notifications from an ipn.Backend, blocking -// (with eventual timeout and t.Fatal) if there are too many and complaining -// (also with t.Fatal) if they are too few. -type notifyThrottler struct { - t *testing.T - - // ch gets replaced frequently. Lock the mutex before getting or - // setting it, but not while waiting on it. - mu sync.Mutex - ch chan ipn.Notify -} - -// expect tells the throttler to expect count upcoming notifications. -func (nt *notifyThrottler) expect(count int) { - nt.mu.Lock() - nt.ch = make(chan ipn.Notify, count) - nt.mu.Unlock() -} - -// put adds one notification into the throttler's queue. -func (nt *notifyThrottler) put(n ipn.Notify) { - nt.t.Helper() - nt.mu.Lock() - ch := nt.ch - nt.mu.Unlock() - - select { - case ch <- n: - return - default: - nt.t.Fatalf("put: channel full: %v", n) - } -} - -// drain pulls the notifications out of the queue, asserting that there are -// exactly count notifications that have been put so far. -func (nt *notifyThrottler) drain(count int) []ipn.Notify { - nt.t.Helper() - nt.mu.Lock() - ch := nt.ch - nt.mu.Unlock() - - nn := []ipn.Notify{} - for i := range count { - select { - case n := <-ch: - nn = append(nn, n) - case <-time.After(6 * time.Second): - nt.t.Fatalf("drain: channel empty after %d/%d", i, count) - } - } - - // no more notifications expected - close(ch) - - nt.t.Log(nn) - return nn -} - -// mockControl is a mock implementation of controlclient.Client. -// Much of the backend state machine depends on callbacks and state -// in the controlclient.Client, so by controlling it, we can check that -// the state machine works as expected. -type mockControl struct { - tb testing.TB - logf logger.Logf - opts controlclient.Options - paused atomic.Bool - - mu sync.Mutex - persist *persist.Persist - calls []string - authBlocked bool - shutdown chan struct{} -} - -func newClient(tb testing.TB, opts controlclient.Options) *mockControl { - return &mockControl{ - tb: tb, - authBlocked: true, - logf: opts.Logf, - opts: opts, - shutdown: make(chan struct{}), - persist: opts.Persist.Clone(), - } -} - -func (cc *mockControl) assertShutdown(wasPaused bool) { - cc.tb.Helper() - select { - case <-cc.shutdown: - // ok - case <-time.After(500 * time.Millisecond): - cc.tb.Fatalf("timed out waiting for shutdown") - } - if wasPaused { - cc.assertCalls("unpause", "Shutdown") - } else { - cc.assertCalls("Shutdown") - } -} - -func (cc *mockControl) populateKeys() (newKeys bool) { - cc.mu.Lock() - defer cc.mu.Unlock() - - if cc.persist == nil { - cc.persist = &persist.Persist{} - } - if cc.persist != nil && cc.persist.PrivateNodeKey.IsZero() { - cc.logf("Generating a new nodekey.") - cc.persist.OldPrivateNodeKey = cc.persist.PrivateNodeKey - cc.persist.PrivateNodeKey = key.NewNode() - newKeys = true - } - - return newKeys -} - -// send publishes a controlclient.Status notification upstream. -// (In our tests here, upstream is the ipnlocal.Local instance.) -func (cc *mockControl) send(err error, url string, loginFinished bool, nm *netmap.NetworkMap) { - if loginFinished { - cc.mu.Lock() - cc.authBlocked = false - cc.mu.Unlock() - } - if cc.opts.Observer != nil { - s := controlclient.Status{ - URL: url, - NetMap: nm, - Persist: cc.persist.View(), - Err: err, - } - if loginFinished { - s.SetStateForTest(controlclient.StateAuthenticated) - } else if url == "" && err == nil && nm == nil { - s.SetStateForTest(controlclient.StateNotAuthenticated) - } - cc.opts.Observer.SetControlClientStatus(cc, s) - } -} - -// called records that a particular function name was called. -func (cc *mockControl) called(s string) { - cc.mu.Lock() - defer cc.mu.Unlock() - - cc.calls = append(cc.calls, s) -} - -// assertCalls fails the test if the list of functions that have been called since the -// last time assertCall was run does not match want. -func (cc *mockControl) assertCalls(want ...string) { - cc.tb.Helper() - cc.mu.Lock() - defer cc.mu.Unlock() - qt.Assert(cc.tb, cc.calls, qt.DeepEquals, want) - cc.calls = nil -} - -// Shutdown disconnects the client. -func (cc *mockControl) Shutdown() { - cc.logf("Shutdown") - cc.called("Shutdown") - close(cc.shutdown) -} - -// Login starts a login process. Note that in this mock, we don't automatically -// generate notifications about the progress of the login operation. You have to -// call send() as required by the test. -func (cc *mockControl) Login(flags controlclient.LoginFlags) { - cc.logf("Login flags=%v", flags) - cc.called("Login") - newKeys := cc.populateKeys() - - interact := (flags & controlclient.LoginInteractive) != 0 - cc.logf("Login: interact=%v newKeys=%v", interact, newKeys) - cc.mu.Lock() - defer cc.mu.Unlock() - cc.authBlocked = interact || newKeys -} - -func (cc *mockControl) Logout(ctx context.Context) error { - cc.logf("Logout") - cc.called("Logout") - return nil -} - -func (cc *mockControl) SetPaused(paused bool) { - was := cc.paused.Swap(paused) - if was == paused { - return - } - cc.logf("SetPaused=%v", paused) - if paused { - cc.called("pause") - } else { - cc.called("unpause") - } -} - -func (cc *mockControl) AuthCantContinue() bool { - cc.mu.Lock() - defer cc.mu.Unlock() - - return cc.authBlocked -} - -func (cc *mockControl) SetHostinfo(hi *tailcfg.Hostinfo) { - cc.logf("SetHostinfo: %v", *hi) - cc.called("SetHostinfo") -} - -func (cc *mockControl) SetNetInfo(ni *tailcfg.NetInfo) { - cc.called("SetNetinfo") - cc.logf("SetNetInfo: %v", *ni) - cc.called("SetNetInfo") -} - -func (cc *mockControl) SetTKAHead(head string) { - cc.logf("SetTKAHead: %s", head) -} - -func (cc *mockControl) UpdateEndpoints(endpoints []tailcfg.Endpoint) { - // validate endpoint information here? - cc.logf("UpdateEndpoints: ep=%v", endpoints) - cc.called("UpdateEndpoints") -} - -func (b *LocalBackend) nonInteractiveLoginForStateTest() { - b.mu.Lock() - if b.cc == nil { - panic("LocalBackend.assertClient: b.cc == nil") - } - cc := b.cc - b.mu.Unlock() - - cc.Login(b.loginFlags | controlclient.LoginInteractive) -} - -// A very precise test of the sequence of function calls generated by -// ipnlocal.Local into its controlclient instance, and the events it -// produces upstream into the UI. -// -// [apenwarr] Normally I'm not a fan of "mock" style tests, but the precise -// sequence of this state machine is so important for writing our multiple -// frontends, that it's worth validating it all in one place. -// -// Any changes that affect this test will most likely require carefully -// re-testing all our GUIs (and the CLI) to make sure we didn't break -// anything. -// -// Note also that this test doesn't have any timers, goroutines, or duplicate -// detection. It expects messages to be produced in exactly the right order, -// with no duplicates, without doing network activity (other than through -// controlclient, which we fake, so there's no network activity there either). -// -// TODO: A few messages that depend on magicsock (which actually might have -// network delays) are just ignored for now, which makes the test -// predictable, but maybe a bit less thorough. This is more of an overall -// state machine test than a test of the wgengine+magicsock integration. -func TestStateMachine(t *testing.T) { - envknob.Setenv("TAILSCALE_USE_WIP_CODE", "1") - defer envknob.Setenv("TAILSCALE_USE_WIP_CODE", "") - c := qt.New(t) - - logf := tstest.WhileTestRunningLogger(t) - sys := new(tsd.System) - store := new(testStateStorage) - sys.Set(store) - e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) - if err != nil { - t.Fatalf("NewFakeUserspaceEngine: %v", err) - } - t.Cleanup(e.Close) - sys.Set(e) - - b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatalf("NewLocalBackend: %v", err) - } - b.DisablePortMapperForTest() - - var cc, previousCC *mockControl - b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { - previousCC = cc - cc = newClient(t, opts) - - t.Logf("ccGen: new mockControl.") - cc.called("New") - return cc, nil - }) - - notifies := ¬ifyThrottler{t: t} - notifies.expect(0) - - b.SetNotifyCallback(func(n ipn.Notify) { - if n.State != nil || - (n.Prefs != nil && n.Prefs.Valid()) || - n.BrowseToURL != nil || - n.LoginFinished != nil { - logf("%+v\n\n", n) - notifies.put(n) - } else { - logf("(ignored) %v\n\n", n) - } - }) - - // Check that it hasn't called us right away. - // The state machine should be idle until we call Start(). - c.Assert(cc, qt.IsNil) - - // Start the state machine. - // Since !WantRunning by default, it'll create a controlclient, - // but not ask it to do anything yet. - t.Logf("\n\nStart") - notifies.expect(2) - c.Assert(b.Start(ipn.Options{}), qt.IsNil) - { - // BUG: strictly, it should pause, not unpause, here, since !WantRunning. - cc.assertCalls("New") - - nn := notifies.drain(2) - cc.assertCalls() - c.Assert(nn[0].Prefs, qt.IsNotNil) - c.Assert(nn[1].State, qt.IsNotNil) - prefs := nn[0].Prefs - // Note: a totally fresh system has Prefs.LoggedOut=false by - // default. We are logged out, but not because the user asked - // for it, so it doesn't count as Prefs.LoggedOut==true. - c.Assert(prefs.LoggedOut(), qt.IsTrue) - c.Assert(prefs.WantRunning(), qt.IsFalse) - c.Assert(ipn.NeedsLogin, qt.Equals, *nn[1].State) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Restart the state machine. - // It's designed to handle frontends coming and going sporadically. - // Make the sure the restart not only works, but generates the same - // events as the first time, so UIs always know what to expect. - t.Logf("\n\nStart2") - notifies.expect(2) - c.Assert(b.Start(ipn.Options{}), qt.IsNil) - { - previousCC.assertShutdown(false) - cc.assertCalls("New") - - nn := notifies.drain(2) - cc.assertCalls() - c.Assert(nn[0].Prefs, qt.IsNotNil) - c.Assert(nn[1].State, qt.IsNotNil) - c.Assert(nn[0].Prefs.LoggedOut(), qt.IsTrue) - c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse) - c.Assert(ipn.NeedsLogin, qt.Equals, *nn[1].State) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Start non-interactive login with no token. - // This will ask controlclient to start its own Login() process, - // then wait for us to respond. - t.Logf("\n\nLogin (noninteractive)") - notifies.expect(0) - b.nonInteractiveLoginForStateTest() - { - cc.assertCalls("Login") - notifies.drain(0) - // Note: WantRunning isn't true yet. It'll switch to true - // after a successful login finishes. - // (This behaviour is needed so that b.Login() won't - // start connecting to an old account right away, if one - // exists when you launch another login.) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Attempted non-interactive login with no key; indicate that - // the user needs to visit a login URL. - t.Logf("\n\nLogin (url response)") - - notifies.expect(3) - b.EditPrefs(&ipn.MaskedPrefs{ - ControlURLSet: true, - Prefs: ipn.Prefs{ - ControlURL: "https://localhost:1/", - }, - }) - url1 := "https://localhost:1/1" - cc.send(nil, url1, false, nil) - { - cc.assertCalls() - - // ...but backend eats that notification, because the user - // didn't explicitly request interactive login yet, and - // we're already in NeedsLogin state. - nn := notifies.drain(3) - - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(nn[1].Prefs.LoggedOut(), qt.IsTrue) - c.Assert(nn[1].Prefs.WantRunning(), qt.IsFalse) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - c.Assert(nn[2].BrowseToURL, qt.IsNotNil) - c.Assert(url1, qt.Equals, *nn[2].BrowseToURL) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Now we'll try an interactive login. - // Since we provided an interactive URL earlier, this shouldn't - // ask control to do anything. Instead backend will emit an event - // indicating that the UI should browse to the given URL. - t.Logf("\n\nLogin (interactive)") - notifies.expect(1) - b.StartLoginInteractive(context.Background()) - { - nn := notifies.drain(1) - cc.assertCalls() - c.Assert(nn[0].BrowseToURL, qt.IsNotNil) - c.Assert(url1, qt.Equals, *nn[0].BrowseToURL) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Sometimes users press the Login button again, in the middle of - // a login sequence. For example, they might have closed their - // browser window without logging in, or they waited too long and - // the login URL expired. If they start another interactive login, - // we must always get a *new* login URL first. - t.Logf("\n\nLogin2 (interactive)") - b.authURLTime = time.Now().Add(-time.Hour * 24 * 7) // simulate URL expiration - notifies.expect(0) - b.StartLoginInteractive(context.Background()) - { - notifies.drain(0) - // backend asks control for another login sequence - cc.assertCalls("Login") - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Provide a new interactive login URL. - t.Logf("\n\nLogin2 (url response)") - notifies.expect(1) - url2 := "https://localhost:1/2" - cc.send(nil, url2, false, nil) - { - cc.assertCalls() - - // This time, backend should emit it to the UI right away, - // because the UI is anxiously awaiting a new URL to visit. - nn := notifies.drain(1) - c.Assert(nn[0].BrowseToURL, qt.IsNotNil) - c.Assert(url2, qt.Equals, *nn[0].BrowseToURL) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Pretend that the interactive login actually happened. - // Controlclient always sends the netmap and LoginFinished at the - // same time. - // The backend should propagate this upward for the UI. - t.Logf("\n\nLoginFinished") - notifies.expect(3) - cc.persist.UserProfile.LoginName = "user1" - cc.persist.NodeID = "node1" - cc.send(nil, "", true, &netmap.NetworkMap{}) - { - nn := notifies.drain(3) - // Arguably it makes sense to unpause now, since the machine - // authorization status is part of the netmap. - // - // BUG: backend unblocks wgengine at this point, even though - // our machine key is not authorized. It probably should - // wait until it gets into Starting. - // TODO: (Currently this test doesn't detect that bug, but - // it's visible in the logs) - cc.assertCalls() - c.Assert(nn[0].LoginFinished, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(nn[2].State, qt.IsNotNil) - c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName, qt.Equals, "user1") - c.Assert(ipn.NeedsMachineAuth, qt.Equals, *nn[2].State) - c.Assert(ipn.NeedsMachineAuth, qt.Equals, b.State()) - } - - // Pretend that the administrator has authorized our machine. - t.Logf("\n\nMachineAuthorized") - notifies.expect(1) - // BUG: the real controlclient sends LoginFinished with every - // notification while it's in StateAuthenticated, but not StateSynced. - // It should send it exactly once, or every time we're authenticated, - // but the current code is brittle. - // (ie. I suspect it would be better to change false->true in send() - // below, and do the same in the real controlclient.) - cc.send(nil, "", false, &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - { - nn := notifies.drain(1) - cc.assertCalls() - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(ipn.Starting, qt.Equals, *nn[0].State) - } - - // TODO: add a fake DERP server to our fake netmap, so we can - // transition to the Running state here. - - // TODO: test what happens when the admin forcibly deletes our key. - // (ie. unsolicited logout) - - // TODO: test what happens when our key expires, client side. - // (and when it gets close to expiring) - - // The user changes their preference to !WantRunning. - t.Logf("\n\nWantRunning -> false") - notifies.expect(2) - b.EditPrefs(&ipn.MaskedPrefs{ - WantRunningSet: true, - Prefs: ipn.Prefs{WantRunning: false}, - }) - { - nn := notifies.drain(2) - cc.assertCalls("pause") - // BUG: I would expect Prefs to change first, and state after. - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(ipn.Stopped, qt.Equals, *nn[0].State) - } - - // The user changes their preference to WantRunning after all. - t.Logf("\n\nWantRunning -> true") - store.awaitWrite() - notifies.expect(2) - b.EditPrefs(&ipn.MaskedPrefs{ - WantRunningSet: true, - Prefs: ipn.Prefs{WantRunning: true}, - }) - { - nn := notifies.drain(2) - // BUG: Login isn't needed here. We never logged out. - cc.assertCalls("Login", "unpause") - // BUG: I would expect Prefs to change first, and state after. - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(ipn.Starting, qt.Equals, *nn[0].State) - c.Assert(store.sawWrite(), qt.IsTrue) - } - - // undo the state hack above. - b.state = ipn.Starting - - // User wants to logout. - store.awaitWrite() - t.Logf("\n\nLogout") - notifies.expect(5) - b.Logout(context.Background()) - { - nn := notifies.drain(5) - previousCC.assertCalls("pause", "Logout", "unpause", "Shutdown") - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(*nn[0].State, qt.Equals, ipn.Stopped) - - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(nn[1].Prefs.LoggedOut(), qt.IsTrue) - c.Assert(nn[1].Prefs.WantRunning(), qt.IsFalse) - - cc.assertCalls("New") - c.Assert(nn[2].State, qt.IsNotNil) - c.Assert(*nn[2].State, qt.Equals, ipn.NoState) - - c.Assert(nn[3].Prefs, qt.IsNotNil) // emptyPrefs - c.Assert(nn[3].Prefs.LoggedOut(), qt.IsTrue) - c.Assert(nn[3].Prefs.WantRunning(), qt.IsFalse) - - c.Assert(nn[4].State, qt.IsNotNil) - c.Assert(*nn[4].State, qt.Equals, ipn.NeedsLogin) - - c.Assert(b.State(), qt.Equals, ipn.NeedsLogin) - - c.Assert(store.sawWrite(), qt.IsTrue) - } - - // A second logout should be a no-op as we are in the NeedsLogin state. - t.Logf("\n\nLogout2") - notifies.expect(0) - b.Logout(context.Background()) - { - notifies.drain(0) - cc.assertCalls() - c.Assert(b.Prefs().LoggedOut(), qt.IsTrue) - c.Assert(b.Prefs().WantRunning(), qt.IsFalse) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // A third logout should also be a no-op as the cc should be in - // AuthCantContinue state. - t.Logf("\n\nLogout3") - notifies.expect(3) - b.Logout(context.Background()) - { - notifies.drain(0) - cc.assertCalls() - c.Assert(b.Prefs().LoggedOut(), qt.IsTrue) - c.Assert(b.Prefs().WantRunning(), qt.IsFalse) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Oh, you thought we were done? Ha! Now we have to test what - // happens if the user exits and restarts while logged out. - // Note that it's explicitly okay to call b.Start() over and over - // again, every time the frontend reconnects. - - // TODO: test user switching between statekeys. - - // The frontend restarts! - t.Logf("\n\nStart3") - notifies.expect(2) - c.Assert(b.Start(ipn.Options{}), qt.IsNil) - { - previousCC.assertShutdown(false) - // BUG: We already called Shutdown(), no need to do it again. - // BUG: don't unpause because we're not logged in. - cc.assertCalls("New") - - nn := notifies.drain(2) - cc.assertCalls() - c.Assert(nn[0].Prefs, qt.IsNotNil) - c.Assert(nn[1].State, qt.IsNotNil) - c.Assert(nn[0].Prefs.LoggedOut(), qt.IsTrue) - c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse) - c.Assert(ipn.NeedsLogin, qt.Equals, *nn[1].State) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - } - - // Explicitly set the ControlURL to avoid defaulting to [ipn.DefaultControlURL]. - // This prevents [LocalBackend] from using the production control server during tests - // and ensures that [LocalBackend.validPopBrowserURL] returns true for the - // fake interactive login URLs used below. Otherwise, we won't be receiving - // BrowseToURL notifications as expected. - // See tailscale/tailscale#11393. - notifies.expect(1) - b.EditPrefs(&ipn.MaskedPrefs{ - ControlURLSet: true, - Prefs: ipn.Prefs{ - ControlURL: "https://localhost:1/", - }, - }) - notifies.drain(1) - - t.Logf("\n\nStartLoginInteractive3") - b.StartLoginInteractive(context.Background()) - // We've been logged out, and the previously created profile is now deleted. - // We're attempting an interactive login for the first time with the new profile, - // this should result in a call to the control server, which in turn should provide - // an interactive login URL to visit. - notifies.expect(2) - url3 := "https://localhost:1/3" - cc.send(nil, url3, false, nil) - { - nn := notifies.drain(2) - cc.assertCalls("Login") - c.Assert(nn[1].BrowseToURL, qt.IsNotNil) - c.Assert(*nn[1].BrowseToURL, qt.Equals, url3) - } - t.Logf("%q visited", url3) - notifies.expect(3) - cc.persist.UserProfile.LoginName = "user2" - cc.persist.NodeID = "node2" - cc.send(nil, "", true, &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - t.Logf("\n\nLoginFinished3") - { - nn := notifies.drain(3) - c.Assert(nn[0].LoginFinished, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(nn[1].Prefs.Persist(), qt.IsNotNil) - // Prefs after finishing the login, so LoginName updated. - c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName, qt.Equals, "user2") - c.Assert(nn[1].Prefs.LoggedOut(), qt.IsFalse) - // If a user initiates an interactive login, they also expect WantRunning to become true. - c.Assert(nn[1].Prefs.WantRunning(), qt.IsTrue) - c.Assert(nn[2].State, qt.IsNotNil) - c.Assert(ipn.Starting, qt.Equals, *nn[2].State) - } - - // Now we've logged in successfully. Let's disconnect. - t.Logf("\n\nWantRunning -> false") - notifies.expect(2) - b.EditPrefs(&ipn.MaskedPrefs{ - WantRunningSet: true, - Prefs: ipn.Prefs{WantRunning: false}, - }) - { - nn := notifies.drain(2) - cc.assertCalls("pause") - // BUG: I would expect Prefs to change first, and state after. - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(ipn.Stopped, qt.Equals, *nn[0].State) - c.Assert(nn[1].Prefs.LoggedOut(), qt.IsFalse) - } - - // One more restart, this time with a valid key, but WantRunning=false. - t.Logf("\n\nStart4") - notifies.expect(2) - c.Assert(b.Start(ipn.Options{}), qt.IsNil) - { - // NOTE: cc.Shutdown() is correct here, since we didn't call - // b.Shutdown() explicitly ourselves. - previousCC.assertShutdown(false) - - // Note: unpause happens because ipn needs to get at least one netmap - // on startup, otherwise UIs can't show the node list, login - // name, etc when in state ipn.Stopped. - // Arguably they shouldn't try. But they currently do. - nn := notifies.drain(2) - cc.assertCalls("New", "Login") - c.Assert(nn[0].Prefs, qt.IsNotNil) - c.Assert(nn[1].State, qt.IsNotNil) - c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse) - c.Assert(nn[0].Prefs.LoggedOut(), qt.IsFalse) - c.Assert(*nn[1].State, qt.Equals, ipn.Stopped) - } - - // When logged in but !WantRunning, ipn leaves us unpaused to retrieve - // the first netmap. Simulate that netmap being received, after which - // it should pause us, to avoid wasting CPU retrieving unnecessarily - // additional netmap updates. - // - // TODO: really the various GUIs and prefs should be refactored to - // not require the netmap structure at all when starting while - // !WantRunning. That would remove the need for this (or contacting - // the control server at all when stopped). - t.Logf("\n\nStart4 -> netmap") - notifies.expect(0) - cc.send(nil, "", true, &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - { - notifies.drain(0) - cc.assertCalls("pause") - } - - // Request connection. - // The state machine didn't call Login() earlier, so now it needs to. - t.Logf("\n\nWantRunning4 -> true") - notifies.expect(2) - b.EditPrefs(&ipn.MaskedPrefs{ - WantRunningSet: true, - Prefs: ipn.Prefs{WantRunning: true}, - }) - { - nn := notifies.drain(2) - cc.assertCalls("Login", "unpause") - // BUG: I would expect Prefs to change first, and state after. - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(ipn.Starting, qt.Equals, *nn[0].State) - } - - // Disconnect. - t.Logf("\n\nStop") - notifies.expect(2) - b.EditPrefs(&ipn.MaskedPrefs{ - WantRunningSet: true, - Prefs: ipn.Prefs{WantRunning: false}, - }) - { - nn := notifies.drain(2) - cc.assertCalls("pause") - // BUG: I would expect Prefs to change first, and state after. - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(ipn.Stopped, qt.Equals, *nn[0].State) - } - - // We want to try logging in as a different user, while Stopped. - // First, start the login process (without logging out first). - t.Logf("\n\nLoginDifferent") - notifies.expect(1) - b.StartLoginInteractive(context.Background()) - url4 := "https://localhost:1/4" - cc.send(nil, url4, false, nil) - { - nn := notifies.drain(1) - // It might seem like WantRunning should switch to true here, - // but that would be risky since we already have a valid - // user account. It might try to reconnect to the old account - // before the new one is ready. So no change yet. - // - // Because the login hasn't yet completed, the old login - // is still valid, so it's correct that we stay paused. - cc.assertCalls("Login") - c.Assert(nn[0].BrowseToURL, qt.IsNotNil) - c.Assert(*nn[0].BrowseToURL, qt.Equals, url4) - } - - // Now, let's complete the interactive login, using a different - // user account than before. WantRunning changes to true after an - // interactive login, so we end up unpaused. - t.Logf("\n\nLoginDifferent URL visited") - notifies.expect(3) - cc.persist.UserProfile.LoginName = "user3" - cc.persist.NodeID = "node3" - cc.send(nil, "", true, &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - { - nn := notifies.drain(3) - // BUG: pause() being called here is a bad sign. - // It means that either the state machine ran at least once - // with the old netmap, or it ran with the new login+netmap - // and !WantRunning. But since it's a fresh and successful - // new login, WantRunning is true, so there was never a - // reason to pause(). - cc.assertCalls("unpause") - c.Assert(nn[0].LoginFinished, qt.IsNotNil) - c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(nn[2].State, qt.IsNotNil) - // Prefs after finishing the login, so LoginName updated. - c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName, qt.Equals, "user3") - c.Assert(nn[1].Prefs.LoggedOut(), qt.IsFalse) - c.Assert(nn[1].Prefs.WantRunning(), qt.IsTrue) - c.Assert(ipn.Starting, qt.Equals, *nn[2].State) - } - - // The last test case is the most common one: restarting when both - // logged in and WantRunning. - t.Logf("\n\nStart5") - notifies.expect(1) - c.Assert(b.Start(ipn.Options{}), qt.IsNil) - { - // NOTE: cc.Shutdown() is correct here, since we didn't call - // b.Shutdown() ourselves. - previousCC.assertShutdown(false) - cc.assertCalls("New", "Login") - - nn := notifies.drain(1) - cc.assertCalls() - c.Assert(nn[0].Prefs, qt.IsNotNil) - c.Assert(nn[0].Prefs.LoggedOut(), qt.IsFalse) - c.Assert(nn[0].Prefs.WantRunning(), qt.IsTrue) - c.Assert(b.State(), qt.Equals, ipn.NoState) - } - - // Control server accepts our valid key from before. - t.Logf("\n\nLoginFinished5") - notifies.expect(1) - cc.send(nil, "", true, &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - { - nn := notifies.drain(1) - cc.assertCalls() - // NOTE: No LoginFinished message since no interactive - // login was needed. - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(ipn.Starting, qt.Equals, *nn[0].State) - // NOTE: No prefs change this time. WantRunning stays true. - // We were in Starting in the first place, so that doesn't - // change either. - c.Assert(ipn.Starting, qt.Equals, b.State()) - } - t.Logf("\n\nExpireKey") - notifies.expect(1) - cc.send(nil, "", false, &netmap.NetworkMap{ - Expiry: time.Now().Add(-time.Minute), - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - { - nn := notifies.drain(1) - cc.assertCalls() - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(ipn.NeedsLogin, qt.Equals, *nn[0].State) - c.Assert(ipn.NeedsLogin, qt.Equals, b.State()) - c.Assert(b.isEngineBlocked(), qt.IsTrue) - } - - t.Logf("\n\nExtendKey") - notifies.expect(1) - cc.send(nil, "", false, &netmap.NetworkMap{ - Expiry: time.Now().Add(time.Minute), - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - { - nn := notifies.drain(1) - cc.assertCalls() - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(ipn.Starting, qt.Equals, *nn[0].State) - c.Assert(ipn.Starting, qt.Equals, b.State()) - c.Assert(b.isEngineBlocked(), qt.IsFalse) - } - notifies.expect(1) - // Fake a DERP connection. - b.setWgengineStatus(&wgengine.Status{DERPs: 1, AsOf: time.Now()}, nil) - { - nn := notifies.drain(1) - cc.assertCalls() - c.Assert(nn[0].State, qt.IsNotNil) - c.Assert(ipn.Running, qt.Equals, *nn[0].State) - c.Assert(ipn.Running, qt.Equals, b.State()) - } -} - -func TestEditPrefsHasNoKeys(t *testing.T) { - logf := tstest.WhileTestRunningLogger(t) - sys := new(tsd.System) - sys.Set(new(mem.Store)) - e, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) - if err != nil { - t.Fatalf("NewFakeUserspaceEngine: %v", err) - } - t.Cleanup(e.Close) - sys.Set(e) - - b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatalf("NewLocalBackend: %v", err) - } - b.hostinfo = &tailcfg.Hostinfo{OS: "testos"} - b.pm.SetPrefs((&ipn.Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: key.NewNode(), - OldPrivateNodeKey: key.NewNode(), - - LegacyFrontendPrivateMachineKey: key.NewMachine(), - }, - }).View(), ipn.NetworkProfile{}) - if p := b.pm.CurrentPrefs().Persist(); !p.Valid() || p.PrivateNodeKey().IsZero() { - t.Fatalf("PrivateNodeKey not set") - } - p, err := b.EditPrefs(&ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - Hostname: "foo", - }, - HostnameSet: true, - }) - if err != nil { - t.Fatalf("EditPrefs: %v", err) - } - if p.Hostname() != "foo" { - t.Errorf("Hostname = %q; want foo", p.Hostname()) - } - - if !p.Persist().PrivateNodeKey().IsZero() { - t.Errorf("PrivateNodeKey = %v; want zero", p.Persist().PrivateNodeKey()) - } - - if !p.Persist().OldPrivateNodeKey().IsZero() { - t.Errorf("OldPrivateNodeKey = %v; want zero", p.Persist().OldPrivateNodeKey()) - } - - if !p.Persist().LegacyFrontendPrivateMachineKey().IsZero() { - t.Errorf("LegacyFrontendPrivateMachineKey = %v; want zero", p.Persist().LegacyFrontendPrivateMachineKey()) - } - - if !p.Persist().NetworkLockKey().IsZero() { - t.Errorf("NetworkLockKey= %v; want zero", p.Persist().NetworkLockKey()) - } -} - -type testStateStorage struct { - mem mem.Store - written atomic.Bool -} - -func (s *testStateStorage) ReadState(id ipn.StateKey) ([]byte, error) { - return s.mem.ReadState(id) -} - -func (s *testStateStorage) WriteState(id ipn.StateKey, bs []byte) error { - s.written.Store(true) - return s.mem.WriteState(id, bs) -} - -// awaitWrite clears the "I've seen writes" bit, in prep for a future -// call to sawWrite to see if a write arrived. -func (s *testStateStorage) awaitWrite() { s.written.Store(false) } - -// sawWrite reports whether there's been a WriteState call since the most -// recent awaitWrite call. -func (s *testStateStorage) sawWrite() bool { - v := s.written.Load() - s.awaitWrite() - return v -} - -func TestWGEngineStatusRace(t *testing.T) { - t.Skip("test fails") - c := qt.New(t) - logf := tstest.WhileTestRunningLogger(t) - sys := new(tsd.System) - sys.Set(new(mem.Store)) - - eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set) - c.Assert(err, qt.IsNil) - t.Cleanup(eng.Close) - sys.Set(eng) - b, err := NewLocalBackend(logf, logid.PublicID{}, sys, 0) - c.Assert(err, qt.IsNil) - - var cc *mockControl - b.SetControlClientGetterForTesting(func(opts controlclient.Options) (controlclient.Client, error) { - cc = newClient(t, opts) - return cc, nil - }) - - var state ipn.State - b.SetNotifyCallback(func(n ipn.Notify) { - if n.State != nil { - state = *n.State - } - }) - wantState := func(want ipn.State) { - c.Assert(want, qt.Equals, state) - } - - // Start with the zero value. - wantState(ipn.NoState) - - // Start the backend. - err = b.Start(ipn.Options{}) - c.Assert(err, qt.IsNil) - wantState(ipn.NeedsLogin) - - // Assert that we are logged in and authorized. - cc.send(nil, "", true, &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{MachineAuthorized: true}).View(), - }) - wantState(ipn.Starting) - - // Simulate multiple concurrent callbacks from wgengine. - // Any single callback with DERPS > 0 is enough to transition - // from Starting to Running, at which point we stay there. - // Thus if these callbacks occurred serially, in any order, - // we would end up in state ipn.Running. - // The same should thus be true if these callbacks occur concurrently. - var wg sync.WaitGroup - for i := range 100 { - wg.Add(1) - go func(i int) { - defer wg.Done() - n := 0 - if i == 0 { - n = 1 - } - b.setWgengineStatus(&wgengine.Status{AsOf: time.Now(), DERPs: n}, nil) - }(i) - } - wg.Wait() - wantState(ipn.Running) -} diff --git a/ipn/ipnlocal/taildrop.go b/ipn/ipnlocal/taildrop.go index db7d8e12ab46e..d57268d3bf6e3 100644 --- a/ipn/ipnlocal/taildrop.go +++ b/ipn/ipnlocal/taildrop.go @@ -8,7 +8,7 @@ import ( "slices" "strings" - "tailscale.com/ipn" + "github.com/sagernet/tailscale/ipn" ) // UpdateOutgoingFiles updates b.outgoingFiles to reflect the given updates and diff --git a/ipn/ipnlocal/web_client.go b/ipn/ipnlocal/web_client.go index 37fc31819dac4..331d911d6c418 100644 --- a/ipn/ipnlocal/web_client.go +++ b/ipn/ipnlocal/web_client.go @@ -17,13 +17,13 @@ import ( "sync" "time" - "tailscale.com/client/tailscale" - "tailscale.com/client/web" - "tailscale.com/logtail/backoff" - "tailscale.com/net/netutil" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/util/mak" + "github.com/sagernet/tailscale/client/tailscale" + "github.com/sagernet/tailscale/client/web" + "github.com/sagernet/tailscale/logtail/backoff" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" ) const webClientPort = web.ListenPort diff --git a/ipn/ipnlocal/web_client_stub.go b/ipn/ipnlocal/web_client_stub.go index 1dfc8c27c3a09..5932225fa2972 100644 --- a/ipn/ipnlocal/web_client_stub.go +++ b/ipn/ipnlocal/web_client_stub.go @@ -9,7 +9,7 @@ import ( "errors" "net" - "tailscale.com/client/tailscale" + "github.com/sagernet/tailscale/client/tailscale" ) const webClientPort = 5252 diff --git a/ipn/ipnserver/actor.go b/ipn/ipnserver/actor.go index 63d4b183ca11d..5eb14126782cf 100644 --- a/ipn/ipnserver/actor.go +++ b/ipn/ipnserver/actor.go @@ -12,12 +12,12 @@ import ( "runtime" "time" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnauth" - "tailscale.com/types/logger" - "tailscale.com/util/ctxkey" - "tailscale.com/util/osuser" - "tailscale.com/version" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnauth" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/ctxkey" + "github.com/sagernet/tailscale/util/osuser" + "github.com/sagernet/tailscale/version" ) var _ ipnauth.Actor = (*actor)(nil) diff --git a/ipn/ipnserver/proxyconnect.go b/ipn/ipnserver/proxyconnect.go index 1094a79f9daf9..993c6a110205f 100644 --- a/ipn/ipnserver/proxyconnect.go +++ b/ipn/ipnserver/proxyconnect.go @@ -10,7 +10,7 @@ import ( "net" "net/http" - "tailscale.com/logpolicy" + "github.com/sagernet/tailscale/logpolicy" ) // handleProxyConnectConn handles a CONNECT request to diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 73b5e82abee76..e83b6333181e1 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -20,16 +20,16 @@ import ( "sync/atomic" "unicode" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/localapi" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/util/systemd" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnlocal" + "github.com/sagernet/tailscale/ipn/localapi" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/systemd" ) // Server is an IPN backend and its set of 0 or more active localhost diff --git a/ipn/ipnserver/server_test.go b/ipn/ipnserver/server_test.go deleted file mode 100644 index b7d5ea144c408..0000000000000 --- a/ipn/ipnserver/server_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipnserver - -import ( - "context" - "sync" - "testing" -) - -func TestWaiterSet(t *testing.T) { - var s waiterSet - - wantLen := func(want int, when string) { - t.Helper() - if got := len(s); got != want { - t.Errorf("%s: len = %v; want %v", when, got, want) - } - } - wantLen(0, "initial") - var mu sync.Mutex - ctx, cancel := context.WithCancel(context.Background()) - - ready, cleanup := s.add(&mu, ctx) - wantLen(1, "after add") - - select { - case <-ready: - t.Fatal("should not be ready") - default: - } - s.wakeAll() - <-ready - - wantLen(1, "after fire") - cleanup() - wantLen(0, "after cleanup") - - // And again but on an already-expired ctx. - cancel() - ready, cleanup = s.add(&mu, ctx) - <-ready // shouldn't block - cleanup() - wantLen(0, "at end") -} diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index 9f8bd34f61033..668a956bacabc 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -17,13 +17,13 @@ import ( "strings" "time" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/types/key" - "tailscale.com/types/ptr" - "tailscale.com/types/views" - "tailscale.com/util/dnsname" - "tailscale.com/version" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/version" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TKAPeer diff --git a/ipn/ipnstate/ipnstate_clone.go b/ipn/ipnstate/ipnstate_clone.go index 20ae43c5fb73e..c169d90f63e4f 100644 --- a/ipn/ipnstate/ipnstate_clone.go +++ b/ipn/ipnstate/ipnstate_clone.go @@ -8,9 +8,9 @@ package ipnstate import ( "net/netip" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/types/key" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/types/key" ) // Clone makes a deep copy of TKAPeer. diff --git a/ipn/localapi/cert.go b/ipn/localapi/cert.go index 323406f7ba650..d655cb9523451 100644 --- a/ipn/localapi/cert.go +++ b/ipn/localapi/cert.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "tailscale.com/ipn/ipnlocal" + "github.com/sagernet/tailscale/ipn/ipnlocal" ) func (h *Handler) serveCert(w http.ResponseWriter, r *http.Request) { diff --git a/ipn/localapi/debugderp.go b/ipn/localapi/debugderp.go index 85eb031e6fd0a..25ba902c11cde 100644 --- a/ipn/localapi/debugderp.go +++ b/ipn/localapi/debugderp.go @@ -14,14 +14,14 @@ import ( "strconv" "time" - "tailscale.com/derp/derphttp" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/netaddr" - "tailscale.com/net/netns" - "tailscale.com/net/stun" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/nettype" + "github.com/sagernet/tailscale/derp/derphttp" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/nettype" ) func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index dc8c089758371..3511fb8925643 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -31,41 +31,41 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/client/tailscale/apitype" + "github.com/sagernet/tailscale/clientupdate" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnauth" + "github.com/sagernet/tailscale/ipn/ipnlocal" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/logtail" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netutil" + "github.com/sagernet/tailscale/net/portmapper" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/taildrop" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/httphdr" + "github.com/sagernet/tailscale/util/httpm" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/osdiag" + "github.com/sagernet/tailscale/util/progresstracking" + "github.com/sagernet/tailscale/util/rands" + "github.com/sagernet/tailscale/util/syspolicy/rsop" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/wgengine/magicsock" "golang.org/x/net/dns/dnsmessage" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/clientupdate" - "tailscale.com/drive" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnauth" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/ipnstate" - "tailscale.com/logtail" - "tailscale.com/net/netmon" - "tailscale.com/net/netutil" - "tailscale.com/net/portmapper" - "tailscale.com/tailcfg" - "tailscale.com/taildrop" - "tailscale.com/tka" - "tailscale.com/tstime" - "tailscale.com/types/dnstype" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/ptr" - "tailscale.com/types/tkatype" - "tailscale.com/util/clientmetric" - "tailscale.com/util/httphdr" - "tailscale.com/util/httpm" - "tailscale.com/util/mak" - "tailscale.com/util/osdiag" - "tailscale.com/util/progresstracking" - "tailscale.com/util/rands" - "tailscale.com/util/syspolicy/rsop" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/version" - "tailscale.com/wgengine/magicsock" ) type localAPIHandler func(*Handler, http.ResponseWriter, *http.Request) diff --git a/ipn/localapi/localapi_test.go b/ipn/localapi/localapi_test.go deleted file mode 100644 index d89c46261815a..0000000000000 --- a/ipn/localapi/localapi_test.go +++ /dev/null @@ -1,417 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package localapi - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "go/ast" - "go/parser" - "go/token" - "io" - "log" - "net/http" - "net/http/httptest" - "net/netip" - "net/url" - "os" - "slices" - "strconv" - "strings" - "testing" - - "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnauth" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/util/slicesx" - "tailscale.com/wgengine" -) - -func TestValidHost(t *testing.T) { - tests := []struct { - host string - valid bool - }{ - {"", true}, - {apitype.LocalAPIHost, true}, - {"localhost:9109", false}, - {"127.0.0.1:9110", false}, - {"[::1]:9111", false}, - {"100.100.100.100:41112", false}, - {"10.0.0.1:41112", false}, - {"37.16.9.210:41112", false}, - } - - for _, test := range tests { - t.Run(test.host, func(t *testing.T) { - h := &Handler{} - if got := h.validHost(test.host); got != test.valid { - t.Errorf("validHost(%q)=%v, want %v", test.host, got, test.valid) - } - }) - } -} - -func TestSetPushDeviceToken(t *testing.T) { - tstest.Replace(t, &validLocalHostForTesting, true) - - h := &Handler{ - PermitWrite: true, - b: &ipnlocal.LocalBackend{}, - } - s := httptest.NewServer(h) - defer s.Close() - c := s.Client() - - want := "my-test-device-token" - body, err := json.Marshal(apitype.SetPushDeviceTokenRequest{PushDeviceToken: want}) - if err != nil { - t.Fatal(err) - } - req, err := http.NewRequest("POST", s.URL+"/localapi/v0/set-push-device-token", bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - res, err := c.Do(req) - if err != nil { - t.Fatal(err) - } - body, err = io.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - if res.StatusCode != 200 { - t.Errorf("res.StatusCode=%d, want 200. body: %s", res.StatusCode, body) - } - if got := h.b.GetPushDeviceToken(); got != want { - t.Errorf("hostinfo.PushDeviceToken=%q, want %q", got, want) - } -} - -type whoIsBackend struct { - whoIs func(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) - whoIsNodeKey func(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) - peerCaps map[netip.Addr]tailcfg.PeerCapMap -} - -func (b whoIsBackend) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { - return b.whoIs(proto, ipp) -} - -func (b whoIsBackend) WhoIsNodeKey(k key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { - return b.whoIsNodeKey(k) -} - -func (b whoIsBackend) PeerCaps(ip netip.Addr) tailcfg.PeerCapMap { - return b.peerCaps[ip] -} - -// Tests that the WhoIs handler accepts IPs, IP:ports, or nodekeys. -// -// From https://github.com/tailscale/tailscale/pull/9714 (a PR that is effectively a bug report) -// -// And https://github.com/tailscale/tailscale/issues/12465 -func TestWhoIsArgTypes(t *testing.T) { - h := &Handler{ - PermitRead: true, - } - - match := func() (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { - return (&tailcfg.Node{ - ID: 123, - Addresses: []netip.Prefix{ - netip.MustParsePrefix("100.101.102.103/32"), - }, - }).View(), - tailcfg.UserProfile{ID: 456, DisplayName: "foo"}, - true - } - - const keyStr = "nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261" - for _, input := range []string{"100.101.102.103", "127.0.0.1:123", keyStr} { - rec := httptest.NewRecorder() - t.Run(input, func(t *testing.T) { - b := whoIsBackend{ - whoIs: func(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { - if !strings.Contains(input, ":") { - want := netip.MustParseAddrPort("100.101.102.103:0") - if ipp != want { - t.Fatalf("backend called with %v; want %v", ipp, want) - } - } - return match() - }, - whoIsNodeKey: func(k key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { - if k.String() != keyStr { - t.Fatalf("backend called with %v; want %v", k, keyStr) - } - return match() - - }, - peerCaps: map[netip.Addr]tailcfg.PeerCapMap{ - netip.MustParseAddr("100.101.102.103"): map[tailcfg.PeerCapability][]tailcfg.RawMessage{ - "foo": {`"bar"`}, - }, - }, - } - h.serveWhoIsWithBackend(rec, httptest.NewRequest("GET", "/v0/whois?addr="+url.QueryEscape(input), nil), b) - - if rec.Code != 200 { - t.Fatalf("response code %d", rec.Code) - } - var res apitype.WhoIsResponse - if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { - t.Fatalf("parsing response %#q: %v", rec.Body.Bytes(), err) - } - if got, want := res.Node.ID, tailcfg.NodeID(123); got != want { - t.Errorf("res.Node.ID=%v, want %v", got, want) - } - if got, want := res.UserProfile.DisplayName, "foo"; got != want { - t.Errorf("res.UserProfile.DisplayName=%q, want %q", got, want) - } - if got, want := len(res.CapMap), 1; got != want { - t.Errorf("capmap size=%v, want %v", got, want) - } - }) - } -} - -func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) { - newHandler := func(connIsLocalAdmin bool) *Handler { - return &Handler{Actor: &ipnauth.TestActor{LocalAdmin: connIsLocalAdmin}, b: newTestLocalBackend(t)} - } - tests := []struct { - name string - configIn *ipn.ServeConfig - h *Handler - wantErr bool - }{ - { - name: "not-path-handler", - configIn: &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - }, - h: newHandler(false), - wantErr: false, - }, - { - name: "path-handler-admin", - configIn: &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Path: "/tmp"}, - }}, - }, - }, - h: newHandler(true), - wantErr: false, - }, - { - name: "path-handler-not-admin", - configIn: &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Path: "/tmp"}, - }}, - }, - }, - h: newHandler(false), - wantErr: true, - }, - } - - for _, tt := range tests { - for _, goos := range []string{"linux", "windows", "darwin"} { - t.Run(goos+"-"+tt.name, func(t *testing.T) { - err := authorizeServeConfigForGOOSAndUserContext(goos, tt.configIn, tt.h) - gotErr := err != nil - if gotErr != tt.wantErr { - t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want error %v", err, tt.wantErr) - } - }) - } - } - t.Run("other-goos", func(t *testing.T) { - configIn := &ipn.ServeConfig{ - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/": {Path: "/tmp"}, - }}, - }, - } - h := newHandler(false) - err := authorizeServeConfigForGOOSAndUserContext("dos", configIn, h) - if err != nil { - t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want nil", err) - } - }) -} - -func TestServeWatchIPNBus(t *testing.T) { - tstest.Replace(t, &validLocalHostForTesting, true) - - tests := []struct { - desc string - permitRead, permitWrite bool - mask ipn.NotifyWatchOpt // extra bits in addition to ipn.NotifyInitialState - wantStatus int - }{ - { - desc: "no-permission", - permitRead: false, - permitWrite: false, - wantStatus: http.StatusForbidden, - }, - { - desc: "read-initial-state", - permitRead: true, - permitWrite: false, - wantStatus: http.StatusForbidden, - }, - { - desc: "read-initial-state-no-private-keys", - permitRead: true, - permitWrite: false, - mask: ipn.NotifyNoPrivateKeys, - wantStatus: http.StatusOK, - }, - { - desc: "read-initial-state-with-private-keys", - permitRead: true, - permitWrite: true, - wantStatus: http.StatusOK, - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - h := &Handler{ - PermitRead: tt.permitRead, - PermitWrite: tt.permitWrite, - b: newTestLocalBackend(t), - } - s := httptest.NewServer(h) - defer s.Close() - c := s.Client() - - ctx, cancel := context.WithCancel(context.Background()) - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, ipn.NotifyInitialState|tt.mask), nil) - if err != nil { - t.Fatal(err) - } - res, err := c.Do(req) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - // Cancel the context so that localapi stops streaming IPN bus - // updates. - cancel() - body, err := io.ReadAll(res.Body) - if err != nil && !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - if res.StatusCode != tt.wantStatus { - t.Errorf("res.StatusCode=%d, want %d. body: %s", res.StatusCode, tt.wantStatus, body) - } - }) - } -} - -func newTestLocalBackend(t testing.TB) *ipnlocal.LocalBackend { - var logf logger.Logf = logger.Discard - sys := new(tsd.System) - store := new(mem.Store) - sys.Set(store) - eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) - if err != nil { - t.Fatalf("NewFakeUserspaceEngine: %v", err) - } - t.Cleanup(eng.Close) - sys.Set(eng) - lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatalf("NewLocalBackend: %v", err) - } - return lb -} - -func TestKeepItSorted(t *testing.T) { - // Parse the localapi.go file into an AST. - fset := token.NewFileSet() // positions are relative to fset - src, err := os.ReadFile("localapi.go") - if err != nil { - log.Fatal(err) - } - f, err := parser.ParseFile(fset, "localapi.go", src, 0) - if err != nil { - log.Fatal(err) - } - getHandler := func() *ast.ValueSpec { - for _, d := range f.Decls { - if g, ok := d.(*ast.GenDecl); ok && g.Tok == token.VAR { - for _, s := range g.Specs { - if vs, ok := s.(*ast.ValueSpec); ok { - if len(vs.Names) == 1 && vs.Names[0].Name == "handler" { - return vs - } - } - } - } - } - return nil - } - keys := func() (ret []string) { - h := getHandler() - if h == nil { - t.Fatal("no handler var found") - } - cl, ok := h.Values[0].(*ast.CompositeLit) - if !ok { - t.Fatalf("handler[0] is %T, want *ast.CompositeLit", h.Values[0]) - } - for _, e := range cl.Elts { - kv := e.(*ast.KeyValueExpr) - strLt := kv.Key.(*ast.BasicLit) - if strLt.Kind != token.STRING { - t.Fatalf("got: %T, %q", kv.Key, kv.Key) - } - k, err := strconv.Unquote(strLt.Value) - if err != nil { - t.Fatalf("unquote: %v", err) - } - ret = append(ret, k) - } - return - } - gotKeys := keys() - endSlash, noSlash := slicesx.Partition(keys(), func(s string) bool { return strings.HasSuffix(s, "/") }) - if !slices.IsSorted(endSlash) { - t.Errorf("the items ending in a slash aren't sorted") - } - if !slices.IsSorted(noSlash) { - t.Errorf("the items ending in a slash aren't sorted") - } - if !t.Failed() { - want := append(endSlash, noSlash...) - if !slices.Equal(gotKeys, want) { - t.Errorf("items with trailing slashes should precede those without") - } - } -} diff --git a/ipn/policy/policy.go b/ipn/policy/policy.go index 494a0dc408819..2eecb71e75df4 100644 --- a/ipn/policy/policy.go +++ b/ipn/policy/policy.go @@ -6,7 +6,7 @@ package policy import ( - "tailscale.com/tailcfg" + "github.com/sagernet/tailscale/tailcfg" ) // IsInterestingService reports whether service s on the given operating diff --git a/ipn/prefs.go b/ipn/prefs.go index f5406f3b732e0..81307cc142a11 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -17,18 +17,18 @@ import ( "slices" "strings" - "tailscale.com/atomicfile" - "tailscale.com/drive" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/netaddr" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/preftype" - "tailscale.com/types/views" - "tailscale.com/util/dnsname" - "tailscale.com/util/syspolicy" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/preftype" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/syspolicy" ) // DefaultControlURL is the URL base of the control plane diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go deleted file mode 100644 index 31671c0f8e4ef..0000000000000 --- a/ipn/prefs_test.go +++ /dev/null @@ -1,1112 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipn - -import ( - "encoding/json" - "errors" - "fmt" - "net/netip" - "os" - "reflect" - "strings" - "testing" - "time" - - "go4.org/mem" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/netaddr" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/types/key" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/preftype" -) - -func fieldsOf(t reflect.Type) (fields []string) { - for i := range t.NumField() { - fields = append(fields, t.Field(i).Name) - } - return -} - -func TestPrefsEqual(t *testing.T) { - tstest.PanicOnLog() - - prefsHandles := []string{ - "ControlURL", - "RouteAll", - "ExitNodeID", - "ExitNodeIP", - "InternalExitNodePrior", - "ExitNodeAllowLANAccess", - "CorpDNS", - "RunSSH", - "RunWebClient", - "WantRunning", - "LoggedOut", - "ShieldsUp", - "AdvertiseTags", - "Hostname", - "NotepadURLs", - "ForceDaemon", - "Egg", - "AdvertiseRoutes", - "AdvertiseServices", - "NoSNAT", - "NoStatefulFiltering", - "NetfilterMode", - "OperatorUser", - "ProfileName", - "AutoUpdate", - "AppConnector", - "PostureChecking", - "NetfilterKind", - "DriveShares", - "AllowSingleHosts", - "Persist", - } - if have := fieldsOf(reflect.TypeFor[Prefs]()); !reflect.DeepEqual(have, prefsHandles) { - t.Errorf("Prefs.Equal check might be out of sync\nfields: %q\nhandled: %q\n", - have, prefsHandles) - } - - nets := func(strs ...string) (ns []netip.Prefix) { - for _, s := range strs { - n, err := netip.ParsePrefix(s) - if err != nil { - panic(err) - } - ns = append(ns, n) - } - return ns - } - tests := []struct { - a, b *Prefs - want bool - }{ - { - &Prefs{}, - nil, - false, - }, - { - nil, - &Prefs{}, - false, - }, - { - &Prefs{}, - &Prefs{}, - true, - }, - - { - &Prefs{ControlURL: "https://controlplane.tailscale.com"}, - &Prefs{ControlURL: "https://login.private.co"}, - false, - }, - { - &Prefs{ControlURL: "https://controlplane.tailscale.com"}, - &Prefs{ControlURL: "https://controlplane.tailscale.com"}, - true, - }, - - { - &Prefs{RouteAll: true}, - &Prefs{RouteAll: false}, - false, - }, - { - &Prefs{RouteAll: true}, - &Prefs{RouteAll: true}, - true, - }, - { - &Prefs{ExitNodeID: "n1234"}, - &Prefs{}, - false, - }, - { - &Prefs{ExitNodeID: "n1234"}, - &Prefs{ExitNodeID: "n1234"}, - true, - }, - - { - &Prefs{ExitNodeIP: netip.MustParseAddr("1.2.3.4")}, - &Prefs{}, - false, - }, - { - &Prefs{ExitNodeIP: netip.MustParseAddr("1.2.3.4")}, - &Prefs{ExitNodeIP: netip.MustParseAddr("1.2.3.4")}, - true, - }, - - { - &Prefs{}, - &Prefs{ExitNodeAllowLANAccess: true}, - false, - }, - { - &Prefs{ExitNodeAllowLANAccess: true}, - &Prefs{ExitNodeAllowLANAccess: true}, - true, - }, - - { - &Prefs{CorpDNS: true}, - &Prefs{CorpDNS: false}, - false, - }, - { - &Prefs{CorpDNS: true}, - &Prefs{CorpDNS: true}, - true, - }, - - { - &Prefs{WantRunning: true}, - &Prefs{WantRunning: false}, - false, - }, - { - &Prefs{WantRunning: true}, - &Prefs{WantRunning: true}, - true, - }, - - { - &Prefs{NoSNAT: true}, - &Prefs{NoSNAT: false}, - false, - }, - { - &Prefs{NoSNAT: true}, - &Prefs{NoSNAT: true}, - true, - }, - - { - &Prefs{Hostname: "android-host01"}, - &Prefs{Hostname: "android-host02"}, - false, - }, - { - &Prefs{Hostname: ""}, - &Prefs{Hostname: ""}, - true, - }, - - { - &Prefs{NotepadURLs: true}, - &Prefs{NotepadURLs: false}, - false, - }, - { - &Prefs{NotepadURLs: true}, - &Prefs{NotepadURLs: true}, - true, - }, - - { - &Prefs{ShieldsUp: true}, - &Prefs{ShieldsUp: false}, - false, - }, - { - &Prefs{ShieldsUp: true}, - &Prefs{ShieldsUp: true}, - true, - }, - - { - &Prefs{AdvertiseRoutes: nil}, - &Prefs{AdvertiseRoutes: []netip.Prefix{}}, - true, - }, - { - &Prefs{AdvertiseRoutes: []netip.Prefix{}}, - &Prefs{AdvertiseRoutes: []netip.Prefix{}}, - true, - }, - { - &Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")}, - &Prefs{AdvertiseRoutes: nets("192.168.1.0/24", "10.2.0.0/16")}, - false, - }, - { - &Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")}, - &Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.2.0.0/16")}, - false, - }, - { - &Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")}, - &Prefs{AdvertiseRoutes: nets("192.168.0.0/24", "10.1.0.0/16")}, - true, - }, - - { - &Prefs{NetfilterMode: preftype.NetfilterOff}, - &Prefs{NetfilterMode: preftype.NetfilterOn}, - false, - }, - { - &Prefs{NetfilterMode: preftype.NetfilterOn}, - &Prefs{NetfilterMode: preftype.NetfilterOn}, - true, - }, - - { - &Prefs{Persist: &persist.Persist{}}, - &Prefs{Persist: &persist.Persist{ - UserProfile: tailcfg.UserProfile{LoginName: "dave"}, - }}, - false, - }, - { - &Prefs{Persist: &persist.Persist{ - UserProfile: tailcfg.UserProfile{LoginName: "dave"}, - }}, - &Prefs{Persist: &persist.Persist{ - UserProfile: tailcfg.UserProfile{LoginName: "dave"}, - }}, - true, - }, - { - &Prefs{ProfileName: "work"}, - &Prefs{ProfileName: "work"}, - true, - }, - { - &Prefs{ProfileName: "work"}, - &Prefs{ProfileName: "home"}, - false, - }, - { - &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}}, - &Prefs{AutoUpdate: AutoUpdatePrefs{Check: false, Apply: opt.NewBool(false)}}, - false, - }, - { - &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(true)}}, - &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}}, - false, - }, - { - &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}}, - &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}}, - true, - }, - { - &Prefs{AppConnector: AppConnectorPrefs{Advertise: true}}, - &Prefs{AppConnector: AppConnectorPrefs{Advertise: true}}, - true, - }, - { - &Prefs{AppConnector: AppConnectorPrefs{Advertise: true}}, - &Prefs{AppConnector: AppConnectorPrefs{Advertise: false}}, - false, - }, - { - &Prefs{PostureChecking: true}, - &Prefs{PostureChecking: true}, - true, - }, - { - &Prefs{PostureChecking: true}, - &Prefs{PostureChecking: false}, - false, - }, - { - &Prefs{NetfilterKind: "iptables"}, - &Prefs{NetfilterKind: "iptables"}, - true, - }, - { - &Prefs{NetfilterKind: "nftables"}, - &Prefs{NetfilterKind: ""}, - false, - }, - { - &Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}}, - &Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}}, - true, - }, - { - &Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}}, - &Prefs{AdvertiseServices: []string{"svc:tux", "svc:amelie"}}, - false, - }, - } - for i, tt := range tests { - got := tt.a.Equals(tt.b) - if got != tt.want { - t.Errorf("%d. Equal = %v; want %v", i, got, tt.want) - } - } -} - -func checkPrefs(t *testing.T, p Prefs) { - var err error - var p2, p2c *Prefs - var p2b *Prefs - - pp := p.Pretty() - if pp == "" { - t.Fatalf("default p.Pretty() failed\n") - } - t.Logf("\npp: %#v\n", pp) - b := p.ToBytes() - if len(b) == 0 { - t.Fatalf("default p.ToBytes() failed\n") - } - if !p.Equals(&p) { - t.Fatalf("p != p\n") - } - p2 = p.Clone() - p2.RouteAll = true - if p.Equals(p2) { - t.Fatalf("p == p2\n") - } - p2b = new(Prefs) - err = PrefsFromBytes(p2.ToBytes(), p2b) - if err != nil { - t.Fatalf("PrefsFromBytes(p2) failed: bytes=%q; err=%v\n", p2.ToBytes(), err) - } - p2p := p2.Pretty() - p2bp := p2b.Pretty() - t.Logf("\np2p: %#v\np2bp: %#v\n", p2p, p2bp) - if p2p != p2bp { - t.Fatalf("p2p != p2bp\n%#v\n%#v\n", p2p, p2bp) - } - if !p2.Equals(p2b) { - t.Fatalf("p2 != p2b\n%#v\n%#v\n", p2, p2b) - } - p2c = p2.Clone() - if !p2b.Equals(p2c) { - t.Fatalf("p2b != p2c\n") - } -} - -func TestBasicPrefs(t *testing.T) { - tstest.PanicOnLog() - - p := Prefs{ - ControlURL: "https://controlplane.tailscale.com", - } - checkPrefs(t, p) -} - -func TestPrefsPersist(t *testing.T) { - tstest.PanicOnLog() - - c := persist.Persist{ - UserProfile: tailcfg.UserProfile{ - LoginName: "test@example.com", - }, - } - p := Prefs{ - ControlURL: "https://controlplane.tailscale.com", - CorpDNS: true, - Persist: &c, - } - checkPrefs(t, p) -} - -func TestPrefsPretty(t *testing.T) { - tests := []struct { - p Prefs - os string - want string - }{ - { - Prefs{}, - "linux", - "Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}", - }, - { - Prefs{}, - "windows", - "Prefs{ra=false dns=false want=false update=off Persist=nil}", - }, - { - Prefs{ShieldsUp: true}, - "windows", - "Prefs{ra=false dns=false want=false shields=true update=off Persist=nil}", - }, - { - Prefs{}, - "windows", - "Prefs{ra=false dns=false want=false update=off Persist=nil}", - }, - { - Prefs{ - NotepadURLs: true, - }, - "windows", - "Prefs{ra=false dns=false want=false notepad=true update=off Persist=nil}", - }, - { - Prefs{ - WantRunning: true, - ForceDaemon: true, // server mode - }, - "windows", - "Prefs{ra=false dns=false want=true server=true update=off Persist=nil}", - }, - { - Prefs{ - WantRunning: true, - ControlURL: "http://localhost:1234", - AdvertiseTags: []string{"tag:foo", "tag:bar"}, - }, - "darwin", - `Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" update=off Persist=nil}`, - }, - { - Prefs{ - Persist: &persist.Persist{}, - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n= u=""}}`, - }, - { - Prefs{ - Persist: &persist.Persist{ - PrivateNodeKey: key.NodePrivateFromRaw32(mem.B([]byte{1: 1, 31: 0})), - }, - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n=[B1VKl] u=""}}`, - }, - { - Prefs{ - ExitNodeIP: netip.MustParseAddr("1.2.3.4"), - }, - "linux", - `Prefs{ra=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off Persist=nil}`, - }, - { - Prefs{ - ExitNodeID: tailcfg.StableNodeID("myNodeABC"), - }, - "linux", - `Prefs{ra=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off Persist=nil}`, - }, - { - Prefs{ - ExitNodeID: tailcfg.StableNodeID("myNodeABC"), - ExitNodeAllowLANAccess: true, - }, - "linux", - `Prefs{ra=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off Persist=nil}`, - }, - { - Prefs{ - ExitNodeAllowLANAccess: true, - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, - }, - { - Prefs{ - Hostname: "foo", - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off host="foo" update=off Persist=nil}`, - }, - { - Prefs{ - AutoUpdate: AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(false), - }, - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=check Persist=nil}`, - }, - { - Prefs{ - AutoUpdate: AutoUpdatePrefs{ - Check: true, - Apply: opt.NewBool(true), - }, - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=on Persist=nil}`, - }, - { - Prefs{ - AppConnector: AppConnectorPrefs{ - Advertise: true, - }, - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`, - }, - { - Prefs{ - AppConnector: AppConnectorPrefs{ - Advertise: false, - }, - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, - }, - { - Prefs{ - NetfilterKind: "iptables", - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off netfilterKind=iptables update=off Persist=nil}`, - }, - { - Prefs{ - NetfilterKind: "", - }, - "linux", - `Prefs{ra=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, - }, - } - for i, tt := range tests { - got := tt.p.pretty(tt.os) - if got != tt.want { - t.Errorf("%d. wrong String:\n got: %s\nwant: %s\n", i, got, tt.want) - } - } -} - -func TestLoadPrefsNotExist(t *testing.T) { - bogusFile := fmt.Sprintf("/tmp/not-exist-%d", time.Now().UnixNano()) - - p, err := LoadPrefsWindows(bogusFile) - if errors.Is(err, os.ErrNotExist) { - // expected. - return - } - t.Fatalf("unexpected prefs=%#v, err=%v", p, err) -} - -// TestLoadPrefsFileWithZeroInIt verifies that LoadPrefs handles corrupted input files. -// See issue #954 for details. -func TestLoadPrefsFileWithZeroInIt(t *testing.T) { - f, err := os.CreateTemp("", "TestLoadPrefsFileWithZeroInIt") - if err != nil { - t.Fatal(err) - } - path := f.Name() - if _, err := f.Write(jsonEscapedZero); err != nil { - t.Fatal(err) - } - f.Close() - defer os.Remove(path) - - p, err := LoadPrefsWindows(path) - if errors.Is(err, os.ErrNotExist) { - // expected. - return - } - t.Fatalf("unexpected prefs=%#v, err=%v", p, err) -} - -func TestMaskedPrefsSetsInternal(t *testing.T) { - for _, f := range fieldsOf(reflect.TypeFor[MaskedPrefs]()) { - if !strings.HasSuffix(f, "Set") || !strings.HasPrefix(f, "Internal") { - continue - } - mp := new(MaskedPrefs) - reflect.ValueOf(mp).Elem().FieldByName(f).SetBool(true) - if !mp.SetsInternal() { - t.Errorf("MaskedPrefs.%sSet=true but SetsInternal=false", f) - } - } -} - -func TestMaskedPrefsFields(t *testing.T) { - have := map[string]bool{} - for _, f := range fieldsOf(reflect.TypeFor[Prefs]()) { - switch f { - case "Persist", "AllowSingleHosts": - // These can't be edited. - continue - } - have[f] = true - } - for _, f := range fieldsOf(reflect.TypeFor[MaskedPrefs]()) { - if f == "Prefs" { - continue - } - if !strings.HasSuffix(f, "Set") { - t.Errorf("unexpected non-/Set$/ field %q", f) - continue - } - bare := strings.TrimSuffix(f, "Set") - _, ok := have[bare] - if !ok { - t.Errorf("no corresponding Prefs.%s field for MaskedPrefs.%s", bare, f) - continue - } - delete(have, bare) - } - for f := range have { - t.Errorf("missing MaskedPrefs.%sSet for Prefs.%s", f, f) - } - - // And also make sure they line up in the right order, which - // ApplyEdits assumes. - pt := reflect.TypeFor[Prefs]() - mt := reflect.TypeFor[MaskedPrefs]() - for i := range mt.NumField() { - name := mt.Field(i).Name - if i == 0 { - if name != "Prefs" { - t.Errorf("first field of MaskedPrefs should be Prefs") - } - continue - } - prefName := pt.Field(i - 1).Name - if prefName+"Set" != name { - t.Errorf("MaskedField[%d] = %s; want %sSet", i-1, name, prefName) - } - } -} - -func TestPrefsApplyEdits(t *testing.T) { - tests := []struct { - name string - prefs *Prefs - edit *MaskedPrefs - want *Prefs - }{ - { - name: "no_change", - prefs: &Prefs{ - Hostname: "foo", - }, - edit: &MaskedPrefs{}, - want: &Prefs{ - Hostname: "foo", - }, - }, - { - name: "set1_decoy1", - prefs: &Prefs{ - Hostname: "foo", - }, - edit: &MaskedPrefs{ - Prefs: Prefs{ - Hostname: "bar", - OperatorUser: "ignore-this", // not set - }, - HostnameSet: true, - }, - want: &Prefs{ - Hostname: "bar", - }, - }, - { - name: "set_several", - prefs: &Prefs{}, - edit: &MaskedPrefs{ - Prefs: Prefs{ - Hostname: "bar", - OperatorUser: "galaxybrain", - }, - HostnameSet: true, - OperatorUserSet: true, - }, - want: &Prefs{ - Hostname: "bar", - OperatorUser: "galaxybrain", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.prefs.Clone() - got.ApplyEdits(tt.edit) - if !got.Equals(tt.want) { - gotj, _ := json.Marshal(got) - wantj, _ := json.Marshal(tt.want) - t.Errorf("fail.\n got: %s\nwant: %s\n", gotj, wantj) - } - }) - } -} - -func TestMaskedPrefsPretty(t *testing.T) { - tests := []struct { - m *MaskedPrefs - want string - }{ - { - m: &MaskedPrefs{}, - want: "MaskedPrefs{}", - }, - { - m: &MaskedPrefs{ - Prefs: Prefs{ - Hostname: "bar", - OperatorUser: "galaxybrain", - RouteAll: false, - ExitNodeID: "foo", - AdvertiseTags: []string{"tag:foo", "tag:bar"}, - NetfilterMode: preftype.NetfilterNoDivert, - }, - RouteAllSet: true, - HostnameSet: true, - OperatorUserSet: true, - ExitNodeIDSet: true, - AdvertiseTagsSet: true, - NetfilterModeSet: true, - }, - want: `MaskedPrefs{RouteAll=false ExitNodeID="foo" AdvertiseTags=["tag:foo" "tag:bar"] Hostname="bar" NetfilterMode=nodivert OperatorUser="galaxybrain"}`, - }, - { - m: &MaskedPrefs{ - Prefs: Prefs{ - ExitNodeIP: netaddr.IPv4(100, 102, 104, 105), - }, - ExitNodeIPSet: true, - }, - want: `MaskedPrefs{ExitNodeIP=100.102.104.105}`, - }, - { - m: &MaskedPrefs{ - Prefs: Prefs{ - AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}, - }, - AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: true, ApplySet: false}, - }, - want: `MaskedPrefs{AutoUpdate={Check=true}}`, - }, - { - m: &MaskedPrefs{ - Prefs: Prefs{ - AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(true)}, - }, - AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: true, ApplySet: true}, - }, - want: `MaskedPrefs{AutoUpdate={Check=true Apply=true}}`, - }, - { - m: &MaskedPrefs{ - Prefs: Prefs{ - AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(false)}, - }, - AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: false, ApplySet: true}, - }, - want: `MaskedPrefs{AutoUpdate={Apply=false}}`, - }, - { - m: &MaskedPrefs{ - Prefs: Prefs{ - AutoUpdate: AutoUpdatePrefs{Check: true, Apply: opt.NewBool(true)}, - }, - AutoUpdateSet: AutoUpdatePrefsMask{CheckSet: false, ApplySet: false}, - }, - want: `MaskedPrefs{}`, - }, - } - for i, tt := range tests { - got := tt.m.Pretty() - if got != tt.want { - t.Errorf("%d.\n got: %#q\nwant: %#q\n", i, got, tt.want) - } - } -} - -func TestPrefsExitNode(t *testing.T) { - var p *Prefs - if p.AdvertisesExitNode() { - t.Errorf("nil shouldn't advertise exit node") - } - p = NewPrefs() - if p.AdvertisesExitNode() { - t.Errorf("default shouldn't advertise exit node") - } - p.AdvertiseRoutes = []netip.Prefix{ - netip.MustParsePrefix("10.0.0.0/16"), - } - p.SetAdvertiseExitNode(true) - if got, want := len(p.AdvertiseRoutes), 3; got != want { - t.Errorf("routes = %d; want %d", got, want) - } - p.SetAdvertiseExitNode(true) - if got, want := len(p.AdvertiseRoutes), 3; got != want { - t.Errorf("routes = %d; want %d", got, want) - } - if !p.AdvertisesExitNode() { - t.Errorf("not advertising after enable") - } - p.SetAdvertiseExitNode(false) - if p.AdvertisesExitNode() { - t.Errorf("advertising after disable") - } - if got, want := len(p.AdvertiseRoutes), 1; got != want { - t.Errorf("routes = %d; want %d", got, want) - } -} - -func TestExitNodeIPOfArg(t *testing.T) { - mustIP := netip.MustParseAddr - tests := []struct { - name string - arg string - st *ipnstate.Status - want netip.Addr - wantErr string - }{ - { - name: "ip_while_stopped_okay", - arg: "1.2.3.4", - st: &ipnstate.Status{ - BackendState: "Stopped", - }, - want: mustIP("1.2.3.4"), - }, - { - name: "ip_not_found", - arg: "1.2.3.4", - st: &ipnstate.Status{ - BackendState: "Running", - }, - wantErr: `no node found in netmap with IP 1.2.3.4`, - }, - { - name: "ip_not_exit", - arg: "1.2.3.4", - st: &ipnstate.Status{ - BackendState: "Running", - Peer: map[key.NodePublic]*ipnstate.PeerStatus{ - key.NewNode().Public(): { - TailscaleIPs: []netip.Addr{mustIP("1.2.3.4")}, - }, - }, - }, - wantErr: `node 1.2.3.4 is not advertising an exit node`, - }, - { - name: "ip", - arg: "1.2.3.4", - st: &ipnstate.Status{ - BackendState: "Running", - Peer: map[key.NodePublic]*ipnstate.PeerStatus{ - key.NewNode().Public(): { - TailscaleIPs: []netip.Addr{mustIP("1.2.3.4")}, - ExitNodeOption: true, - }, - }, - }, - want: mustIP("1.2.3.4"), - }, - { - name: "no_match", - arg: "unknown", - st: &ipnstate.Status{MagicDNSSuffix: ".foo"}, - wantErr: `invalid value "unknown" for --exit-node; must be IP or unique node name`, - }, - { - name: "name", - arg: "skippy", - st: &ipnstate.Status{ - MagicDNSSuffix: ".foo", - Peer: map[key.NodePublic]*ipnstate.PeerStatus{ - key.NewNode().Public(): { - DNSName: "skippy.foo.", - TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")}, - ExitNodeOption: true, - }, - }, - }, - want: mustIP("1.0.0.2"), - }, - { - name: "name_fqdn", - arg: "skippy.foo.", - st: &ipnstate.Status{ - MagicDNSSuffix: ".foo", - Peer: map[key.NodePublic]*ipnstate.PeerStatus{ - key.NewNode().Public(): { - DNSName: "skippy.foo.", - TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")}, - ExitNodeOption: true, - }, - }, - }, - want: mustIP("1.0.0.2"), - }, - { - name: "name_not_exit", - arg: "skippy", - st: &ipnstate.Status{ - MagicDNSSuffix: ".foo", - Peer: map[key.NodePublic]*ipnstate.PeerStatus{ - key.NewNode().Public(): { - DNSName: "skippy.foo.", - TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")}, - }, - }, - }, - wantErr: `node "skippy" is not advertising an exit node`, - }, - { - name: "name_wrong_fqdn", - arg: "skippy.bar.", - st: &ipnstate.Status{ - MagicDNSSuffix: ".foo", - Peer: map[key.NodePublic]*ipnstate.PeerStatus{ - key.NewNode().Public(): { - DNSName: "skippy.foo.", - TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")}, - }, - }, - }, - wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or unique node name`, - }, - { - name: "ambiguous", - arg: "skippy", - st: &ipnstate.Status{ - MagicDNSSuffix: ".foo", - Peer: map[key.NodePublic]*ipnstate.PeerStatus{ - key.NewNode().Public(): { - DNSName: "skippy.foo.", - TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")}, - ExitNodeOption: true, - }, - key.NewNode().Public(): { - DNSName: "SKIPPY.foo.", - TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")}, - ExitNodeOption: true, - }, - }, - }, - wantErr: `ambiguous exit node name "skippy"`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := exitNodeIPOfArg(tt.arg, tt.st) - if err != nil { - if err.Error() == tt.wantErr { - return - } - if tt.wantErr == "" { - t.Fatal(err) - } - t.Fatalf("error = %#q; want %#q", err, tt.wantErr) - } - if tt.wantErr != "" { - t.Fatalf("got %v; want error %#q", got, tt.wantErr) - } - if got != tt.want { - t.Fatalf("got %v; want %v", got, tt.want) - } - }) - } -} - -func TestControlURLOrDefault(t *testing.T) { - var p Prefs - if got, want := p.ControlURLOrDefault(), DefaultControlURL; got != want { - t.Errorf("got %q; want %q", got, want) - } - p.ControlURL = "http://foo.bar" - if got, want := p.ControlURLOrDefault(), "http://foo.bar"; got != want { - t.Errorf("got %q; want %q", got, want) - } - p.ControlURL = "https://login.tailscale.com" - if got, want := p.ControlURLOrDefault(), DefaultControlURL; got != want { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestMaskedPrefsIsEmpty(t *testing.T) { - tests := []struct { - name string - mp *MaskedPrefs - wantEmpty bool - }{ - { - name: "nil", - wantEmpty: true, - }, - { - name: "empty", - wantEmpty: true, - mp: &MaskedPrefs{}, - }, - { - name: "no-masks", - wantEmpty: true, - mp: &MaskedPrefs{ - Prefs: Prefs{ - WantRunning: true, - }, - }, - }, - { - name: "with-mask", - wantEmpty: false, - mp: &MaskedPrefs{ - Prefs: Prefs{ - WantRunning: true, - }, - WantRunningSet: true, - }, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := tc.mp.IsEmpty() - if got != tc.wantEmpty { - t.Fatalf("mp.IsEmpty = %t; want %t", got, tc.wantEmpty) - } - }) - } -} - -func TestNotifyPrefsJSONRoundtrip(t *testing.T) { - var n Notify - if n.Prefs != nil && n.Prefs.Valid() { - t.Fatal("Prefs should not be valid at start") - } - b, err := json.Marshal(n) - if err != nil { - t.Fatal(err) - } - - var n2 Notify - if err := json.Unmarshal(b, &n2); err != nil { - t.Fatal(err) - } - if n2.Prefs != nil && n2.Prefs.Valid() { - t.Fatal("Prefs should not be valid after deserialization") - } -} - -// Verify that our Prefs type writes out an AllowSingleHosts field so we can -// downgrade to older versions that require it. -func TestPrefsDowngrade(t *testing.T) { - var p Prefs - j, err := json.Marshal(p) - if err != nil { - t.Fatal(err) - } - - type oldPrefs struct { - AllowSingleHosts bool - } - var op oldPrefs - if err := json.Unmarshal(j, &op); err != nil { - t.Fatal(err) - } - if !op.AllowSingleHosts { - t.Fatal("AllowSingleHosts should be true") - } -} diff --git a/ipn/serve.go b/ipn/serve.go index 5c0a97ed3ffa9..ed75d0fb52b40 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -13,9 +13,9 @@ import ( "strconv" "strings" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" - "tailscale.com/util/mak" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/util/mak" ) // ServeConfigKey returns a StateKey that stores the diff --git a/ipn/serve_test.go b/ipn/serve_test.go deleted file mode 100644 index e9d8e8f322075..0000000000000 --- a/ipn/serve_test.go +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause -package ipn - -import ( - "testing" - - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" -) - -func TestCheckFunnelAccess(t *testing.T) { - caps := func(c ...tailcfg.NodeCapability) []tailcfg.NodeCapability { return c } - const portAttr tailcfg.NodeCapability = "https://tailscale.com/cap/funnel-ports?ports=443,8080-8090,8443," - tests := []struct { - port uint16 - caps []tailcfg.NodeCapability - wantErr bool - }{ - {443, caps(portAttr), true}, // No "funnel" attribute - {443, caps(portAttr, tailcfg.NodeAttrFunnel), true}, - {443, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), false}, - {8443, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), false}, - {8321, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), true}, - {8083, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), false}, - {8091, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), true}, - {3000, caps(portAttr, tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel), true}, - } - for _, tt := range tests { - cm := tailcfg.NodeCapMap{} - for _, c := range tt.caps { - cm[c] = nil - } - err := CheckFunnelAccess(tt.port, &ipnstate.PeerStatus{CapMap: cm}) - 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 TestHasPathHandler(t *testing.T) { - tests := []struct { - name string - cfg ServeConfig - want bool - }{ - { - name: "empty-config", - cfg: ServeConfig{}, - want: false, - }, - { - name: "with-bg-path-handler", - cfg: ServeConfig{ - TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}}, - Web: map[HostPort]*WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{ - "/": {Path: "/tmp"}, - }}, - }, - }, - want: true, - }, - { - name: "with-fg-path-handler", - cfg: ServeConfig{ - TCP: map[uint16]*TCPPortHandler{ - 443: {HTTPS: true}, - }, - Foreground: map[string]*ServeConfig{ - "abc123": { - TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}}, - Web: map[HostPort]*WebServerConfig{ - "foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{ - "/": {Path: "/tmp"}, - }}, - }, - }, - }, - }, - want: true, - }, - { - name: "with-no-bg-path-handler", - cfg: ServeConfig{ - TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}}, - Web: map[HostPort]*WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true}, - }, - want: false, - }, - { - name: "with-no-fg-path-handler", - cfg: ServeConfig{ - Foreground: map[string]*ServeConfig{ - "abc123": { - TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}}, - Web: map[HostPort]*WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{ - "/": {Proxy: "http://127.0.0.1:3000"}, - }}, - }, - AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true}, - }, - }, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.cfg.HasPathHandler() - if tt.want != got { - t.Errorf("HasPathHandler() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestExpandProxyTargetDev(t *testing.T) { - tests := []struct { - name string - input string - defaultScheme string - supportedSchemes []string - expected string - wantErr bool - }{ - {name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"}, - {name: "hostname+port", input: "localhost:8080", expected: "http://localhost:8080"}, - {name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"}, - {name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"}, - {name: "https-scheme", input: "https://localhost:8080", expected: "https://localhost:8080"}, - {name: "https+insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://localhost:8080"}, - {name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://localhost:8080"}, - {name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://localhost:8080"}, - - // errors - {name: "invalid-port", input: "localhost:9999999", wantErr: true}, - {name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true}, - {name: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true}, - {name: "empty-input", input: "", expected: "", wantErr: true}, - } - - for _, tt := range tests { - defaultScheme := "http" - supportedSchemes := []string{"http", "https", "https+insecure"} - - if tt.supportedSchemes != nil { - supportedSchemes = tt.supportedSchemes - } - if tt.defaultScheme != "" { - defaultScheme = tt.defaultScheme - } - - t.Run(tt.name, func(t *testing.T) { - actual, err := ExpandProxyTargetValue(tt.input, supportedSchemes, defaultScheme) - - if tt.wantErr == true && err == nil { - t.Errorf("Expected an error but got none") - return - } - - if tt.wantErr == false && err != nil { - t.Errorf("Got an error, but didn't expect one: %v", err) - return - } - - if actual != tt.expected { - t.Errorf("Got: %q; expected: %q", actual, tt.expected) - } - }) - } -} diff --git a/ipn/store/awsstore/store_aws.go b/ipn/store/awsstore/store_aws.go deleted file mode 100644 index 0fb78d45a6a53..0000000000000 --- a/ipn/store/awsstore/store_aws.go +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux && !ts_omit_aws - -// Package awsstore contains an ipn.StateStore implementation using AWS SSM. -package awsstore - -import ( - "context" - "errors" - "fmt" - "regexp" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/aws/arn" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/ssm" - ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/types/logger" -) - -const ( - parameterNameRxStr = `^parameter(/.*)` -) - -var parameterNameRx = regexp.MustCompile(parameterNameRxStr) - -// awsSSMClient is an interface allowing us to mock the couple of -// API calls we are leveraging with the AWSStore provider -type awsSSMClient interface { - GetParameter(ctx context.Context, - params *ssm.GetParameterInput, - optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) - - PutParameter(ctx context.Context, - params *ssm.PutParameterInput, - optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) -} - -// store is a store which leverages AWS SSM parameter store -// to persist the state -type awsStore struct { - ssmClient awsSSMClient - ssmARN arn.ARN - - memory mem.Store -} - -// New returns a new ipn.StateStore using the AWS SSM storage -// location given by ssmARN. -// -// Note that we store the entire store in a single parameter -// key, therefore if the state is above 8kb, it can cause -// Tailscaled to only only store new state in-memory and -// restarting Tailscaled can fail until you delete your state -// from the AWS Parameter Store. -func New(_ logger.Logf, ssmARN string) (ipn.StateStore, error) { - return newStore(ssmARN, nil) -} - -// newStore is NewStore, but for tests. If client is non-nil, it's -// used instead of making one. -func newStore(ssmARN string, client awsSSMClient) (ipn.StateStore, error) { - s := &awsStore{ - ssmClient: client, - } - - var err error - - // Parse the ARN - if s.ssmARN, err = arn.Parse(ssmARN); err != nil { - return nil, fmt.Errorf("unable to parse the ARN correctly: %v", err) - } - - // Validate the ARN corresponds to the SSM service - if s.ssmARN.Service != "ssm" { - return nil, fmt.Errorf("invalid service %q, expected 'ssm'", s.ssmARN.Service) - } - - // Validate the ARN corresponds to a parameter store resource - if !parameterNameRx.MatchString(s.ssmARN.Resource) { - return nil, fmt.Errorf("invalid resource %q, expected to match %v", s.ssmARN.Resource, parameterNameRxStr) - } - - if s.ssmClient == nil { - var cfg aws.Config - if cfg, err = config.LoadDefaultConfig( - context.TODO(), - config.WithRegion(s.ssmARN.Region), - ); err != nil { - return nil, err - } - s.ssmClient = ssm.NewFromConfig(cfg) - } - - // Hydrate cache with the potentially current state - if err := s.LoadState(); err != nil { - return nil, err - } - return s, nil - -} - -// LoadState attempts to read the state from AWS SSM parameter store key. -func (s *awsStore) LoadState() error { - param, err := s.ssmClient.GetParameter( - context.TODO(), - &ssm.GetParameterInput{ - Name: aws.String(s.ParameterName()), - WithDecryption: aws.Bool(true), - }, - ) - - if err != nil { - var pnf *ssmTypes.ParameterNotFound - if errors.As(err, &pnf) { - // Create the parameter as it does not exist yet - // and return directly as it is defacto empty - return s.persistState() - } - return err - } - - // Load the content in-memory - return s.memory.LoadFromJSON([]byte(*param.Parameter.Value)) -} - -// ParameterName returns the parameter name extracted from -// the provided ARN -func (s *awsStore) ParameterName() (name string) { - values := parameterNameRx.FindStringSubmatch(s.ssmARN.Resource) - if len(values) == 2 { - name = values[1] - } - return -} - -// String returns the awsStore and the ARN of the SSM parameter store -// configured to store the state -func (s *awsStore) String() string { return fmt.Sprintf("awsStore(%q)", s.ssmARN.String()) } - -// ReadState implements the Store interface. -func (s *awsStore) ReadState(id ipn.StateKey) (bs []byte, err error) { - return s.memory.ReadState(id) -} - -// WriteState implements the Store interface. -func (s *awsStore) WriteState(id ipn.StateKey, bs []byte) (err error) { - // Write the state in-memory - if err = s.memory.WriteState(id, bs); err != nil { - return - } - - // Persist the state in AWS SSM parameter store - return s.persistState() -} - -// PersistState saves the states into the AWS SSM parameter store -func (s *awsStore) persistState() error { - // Generate JSON from in-memory cache - bs, err := s.memory.ExportToJSON() - if err != nil { - return err - } - - // Store in AWS SSM parameter store. - // - // We use intelligent tiering so that when the state is below 4kb, it uses Standard tiering - // which is free. However, if it exceeds 4kb it switches the parameter to advanced tiering - // doubling the capacity to 8kb per the following docs: - // https://aws.amazon.com/about-aws/whats-new/2019/08/aws-systems-manager-parameter-store-announces-intelligent-tiering-to-enable-automatic-parameter-tier-selection/ - _, err = s.ssmClient.PutParameter( - context.TODO(), - &ssm.PutParameterInput{ - Name: aws.String(s.ParameterName()), - Value: aws.String(string(bs)), - Overwrite: aws.Bool(true), - Tier: ssmTypes.ParameterTierIntelligentTiering, - Type: ssmTypes.ParameterTypeSecureString, - }, - ) - return err -} diff --git a/ipn/store/awsstore/store_aws_stub.go b/ipn/store/awsstore/store_aws_stub.go deleted file mode 100644 index 8d2156ce948d5..0000000000000 --- a/ipn/store/awsstore/store_aws_stub.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !linux || ts_omit_aws - -package awsstore - -import ( - "fmt" - "runtime" - - "tailscale.com/ipn" - "tailscale.com/types/logger" -) - -func New(logger.Logf, string) (ipn.StateStore, error) { - return nil, fmt.Errorf("AWS store is not supported on %v", runtime.GOOS) -} diff --git a/ipn/store/awsstore/store_aws_test.go b/ipn/store/awsstore/store_aws_test.go deleted file mode 100644 index f6c8fedb32dc9..0000000000000 --- a/ipn/store/awsstore/store_aws_test.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package awsstore - -import ( - "context" - "testing" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/aws/arn" - "github.com/aws/aws-sdk-go-v2/service/ssm" - ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" - "tailscale.com/ipn" - "tailscale.com/tstest" -) - -type mockedAWSSSMClient struct { - value string -} - -func (sp *mockedAWSSSMClient) GetParameter(_ context.Context, input *ssm.GetParameterInput, _ ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { - output := new(ssm.GetParameterOutput) - if sp.value == "" { - return output, &ssmTypes.ParameterNotFound{} - } - - output.Parameter = &ssmTypes.Parameter{ - Value: aws.String(sp.value), - } - - return output, nil -} - -func (sp *mockedAWSSSMClient) PutParameter(_ context.Context, input *ssm.PutParameterInput, _ ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) { - sp.value = *input.Value - return new(ssm.PutParameterOutput), nil -} - -func TestAWSStoreString(t *testing.T) { - store := &awsStore{ - ssmARN: arn.ARN{ - Service: "ssm", - Region: "eu-west-1", - AccountID: "123456789", - Resource: "parameter/foo", - }, - } - want := "awsStore(\"arn::ssm:eu-west-1:123456789:parameter/foo\")" - if got := store.String(); got != want { - t.Errorf("AWSStore.String = %q; want %q", got, want) - } -} - -func TestNewAWSStore(t *testing.T) { - tstest.PanicOnLog() - - mc := &mockedAWSSSMClient{} - storeParameterARN := arn.ARN{ - Service: "ssm", - Region: "eu-west-1", - AccountID: "123456789", - Resource: "parameter/foo", - } - - s, err := newStore(storeParameterARN.String(), mc) - if err != nil { - t.Fatalf("creating aws store failed: %v", err) - } - testStoreSemantics(t, s) - - // Build a brand new file store and check that both IDs written - // above are still there. - s2, err := newStore(storeParameterARN.String(), mc) - if err != nil { - t.Fatalf("creating second aws store failed: %v", err) - } - store2 := s.(*awsStore) - - // This is specific to the test, with the non-mocked API, LoadState() should - // have been already called and successful as no err is returned from NewAWSStore() - s2.(*awsStore).LoadState() - - expected := map[ipn.StateKey]string{ - "foo": "bar", - "baz": "quux", - } - for id, want := range expected { - bs, err := store2.ReadState(id) - if err != nil { - t.Errorf("reading %q (2nd store): %v", id, err) - } - if string(bs) != want { - t.Errorf("reading %q (2nd store): got %q, want %q", id, string(bs), want) - } - } -} - -func testStoreSemantics(t *testing.T, store ipn.StateStore) { - t.Helper() - - tests := []struct { - // if true, data is data to write. If false, data is expected - // output of read. - write bool - id ipn.StateKey - data string - // If write=false, true if we expect a not-exist error. - notExists bool - }{ - { - id: "foo", - notExists: true, - }, - { - write: true, - id: "foo", - data: "bar", - }, - { - id: "foo", - data: "bar", - }, - { - id: "baz", - notExists: true, - }, - { - write: true, - id: "baz", - data: "quux", - }, - { - id: "foo", - data: "bar", - }, - { - id: "baz", - data: "quux", - }, - } - - for _, test := range tests { - if test.write { - if err := store.WriteState(test.id, []byte(test.data)); err != nil { - t.Errorf("writing %q to %q: %v", test.data, test.id, err) - } - } else { - bs, err := store.ReadState(test.id) - if err != nil { - if test.notExists && err == ipn.ErrStateNotExist { - continue - } - t.Errorf("reading %q: %v", test.id, err) - continue - } - if string(bs) != test.data { - t.Errorf("reading %q: got %q, want %q", test.id, string(bs), test.data) - } - } - } -} diff --git a/ipn/store/kubestore/store_kube.go b/ipn/store/kubestore/store_kube.go index 462e6d43425ff..39bb27f893e87 100644 --- a/ipn/store/kubestore/store_kube.go +++ b/ipn/store/kubestore/store_kube.go @@ -13,11 +13,11 @@ import ( "strings" "time" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/kube/kubeapi" - "tailscale.com/kube/kubeclient" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/store/mem" + "github.com/sagernet/tailscale/kube/kubeapi" + "github.com/sagernet/tailscale/kube/kubeclient" + "github.com/sagernet/tailscale/types/logger" ) const ( diff --git a/ipn/store/mem/store_mem.go b/ipn/store/mem/store_mem.go index 6f474ce993b43..b2a720ee23398 100644 --- a/ipn/store/mem/store_mem.go +++ b/ipn/store/mem/store_mem.go @@ -9,10 +9,10 @@ import ( "encoding/json" "sync" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" xmaps "golang.org/x/exp/maps" - "tailscale.com/ipn" - "tailscale.com/types/logger" - "tailscale.com/util/mak" ) // New returns a new Store. diff --git a/ipn/store/store_aws.go b/ipn/store/store_aws.go deleted file mode 100644 index e164f9de741b0..0000000000000 --- a/ipn/store/store_aws.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build (ts_aws || (linux && (arm64 || amd64))) && !ts_omit_aws - -package store - -import ( - "tailscale.com/ipn/store/awsstore" -) - -func init() { - registerAvailableExternalStores = append(registerAvailableExternalStores, registerAWSStore) -} - -func registerAWSStore() { - Register("arn:", awsstore.New) -} diff --git a/ipn/store/store_kube.go b/ipn/store/store_kube.go index 8941620f6649d..55a39a0922c41 100644 --- a/ipn/store/store_kube.go +++ b/ipn/store/store_kube.go @@ -8,9 +8,9 @@ package store import ( "strings" - "tailscale.com/ipn" - "tailscale.com/ipn/store/kubestore" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/store/kubestore" + "github.com/sagernet/tailscale/types/logger" ) func init() { diff --git a/ipn/store/stores.go b/ipn/store/stores.go index 1a87fc548026a..2ea8709b38807 100644 --- a/ipn/store/stores.go +++ b/ipn/store/stores.go @@ -14,12 +14,12 @@ import ( "strings" "sync" - "tailscale.com/atomicfile" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/paths" - "tailscale.com/types/logger" - "tailscale.com/util/mak" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/store/mem" + "github.com/sagernet/tailscale/paths" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" ) // Provider returns a StateStore for the provided path. diff --git a/ipn/store/stores_test.go b/ipn/store/stores_test.go deleted file mode 100644 index ea09e6ea63ae4..0000000000000 --- a/ipn/store/stores_test.go +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package store - -import ( - "path/filepath" - "testing" - - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/tstest" - "tailscale.com/types/logger" -) - -func TestNewStore(t *testing.T) { - regOnce.Do(registerDefaultStores) - t.Cleanup(func() { - knownStores = map[string]Provider{} - registerDefaultStores() - }) - knownStores = map[string]Provider{} - - type store1 struct { - ipn.StateStore - path string - } - - type store2 struct { - ipn.StateStore - path string - } - - Register("arn:", func(_ logger.Logf, path string) (ipn.StateStore, error) { - return &store1{new(mem.Store), path}, nil - }) - Register("kube:", func(_ logger.Logf, path string) (ipn.StateStore, error) { - return &store2{new(mem.Store), path}, nil - }) - Register("mem:", func(_ logger.Logf, path string) (ipn.StateStore, error) { - return new(mem.Store), nil - }) - - path := "mem:abcd" - if s, err := New(t.Logf, path); err != nil { - t.Fatalf("%q: %v", path, err) - } else if _, ok := s.(*mem.Store); !ok { - t.Fatalf("%q: got: %T, want: %T", path, s, new(mem.Store)) - } - - path = "arn:foo" - if s, err := New(t.Logf, path); err != nil { - t.Fatalf("%q: %v", path, err) - } else if _, ok := s.(*store1); !ok { - t.Fatalf("%q: got: %T, want: %T", path, s, new(store1)) - } - - path = "kube:abcd" - if s, err := New(t.Logf, path); err != nil { - t.Fatalf("%q: %v", path, err) - } else if _, ok := s.(*store2); !ok { - t.Fatalf("%q: got: %T, want: %T", path, s, new(store2)) - } - - path = filepath.Join(t.TempDir(), "state") - if s, err := New(t.Logf, path); err != nil { - t.Fatalf("%q: %v", path, err) - } else if _, ok := s.(*FileStore); !ok { - t.Fatalf("%q: got: %T, want: %T", path, s, new(FileStore)) - } -} - -func testStoreSemantics(t *testing.T, store ipn.StateStore) { - t.Helper() - - tests := []struct { - // if true, data is data to write. If false, data is expected - // output of read. - write bool - id ipn.StateKey - data string - // If write=false, true if we expect a not-exist error. - notExists bool - }{ - { - id: "foo", - notExists: true, - }, - { - write: true, - id: "foo", - data: "bar", - }, - { - id: "foo", - data: "bar", - }, - { - id: "baz", - notExists: true, - }, - { - write: true, - id: "baz", - data: "quux", - }, - { - id: "foo", - data: "bar", - }, - { - id: "baz", - data: "quux", - }, - } - - for _, test := range tests { - if test.write { - if err := store.WriteState(test.id, []byte(test.data)); err != nil { - t.Errorf("writing %q to %q: %v", test.data, test.id, err) - } - } else { - bs, err := store.ReadState(test.id) - if err != nil { - if test.notExists && err == ipn.ErrStateNotExist { - continue - } - t.Errorf("reading %q: %v", test.id, err) - continue - } - if string(bs) != test.data { - t.Errorf("reading %q: got %q, want %q", test.id, string(bs), test.data) - } - } - } -} - -func TestMemoryStore(t *testing.T) { - tstest.PanicOnLog() - - store := new(mem.Store) - testStoreSemantics(t, store) -} - -func TestFileStore(t *testing.T) { - tstest.PanicOnLog() - - dir := t.TempDir() - path := filepath.Join(dir, "test-file-store.conf") - - store, err := NewFileStore(nil, path) - if err != nil { - t.Fatalf("creating file store failed: %v", err) - } - - testStoreSemantics(t, store) - - // Build a brand new file store and check that both IDs written - // above are still there. - store, err = NewFileStore(nil, path) - if err != nil { - t.Fatalf("creating second file store failed: %v", err) - } - - expected := map[ipn.StateKey]string{ - "foo": "bar", - "baz": "quux", - } - for key, want := range expected { - bs, err := store.ReadState(key) - if err != nil { - t.Errorf("reading %q (2nd store): %v", key, err) - continue - } - if string(bs) != want { - t.Errorf("reading %q (2nd store): got %q, want %q", key, bs, want) - } - } -} diff --git a/ipn/store_test.go b/ipn/store_test.go deleted file mode 100644 index fcc082d8a8a87..0000000000000 --- a/ipn/store_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipn - -import ( - "bytes" - "sync" - "testing" - - "tailscale.com/util/mak" -) - -type memStore struct { - mu sync.Mutex - writes int - m map[StateKey][]byte -} - -func (s *memStore) ReadState(k StateKey) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - return bytes.Clone(s.m[k]), nil -} - -func (s *memStore) WriteState(k StateKey, v []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - mak.Set(&s.m, k, bytes.Clone(v)) - s.writes++ - return nil -} - -func TestWriteState(t *testing.T) { - var ss StateStore = new(memStore) - WriteState(ss, "foo", []byte("bar")) - WriteState(ss, "foo", []byte("bar")) - got, err := ss.ReadState("foo") - if err != nil { - t.Fatal(err) - } - if want := []byte("bar"); !bytes.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } - if got, want := ss.(*memStore).writes, 1; got != want { - t.Errorf("got %d writes; want %d", got, want) - } -} diff --git a/jsondb/db.go b/jsondb/db.go index 68bb05af45e8e..c0350876c3133 100644 --- a/jsondb/db.go +++ b/jsondb/db.go @@ -11,7 +11,7 @@ import ( "io/fs" "os" - "tailscale.com/atomicfile" + "github.com/sagernet/tailscale/atomicfile" ) // DB is a database backed by a JSON file. diff --git a/jsondb/db_test.go b/jsondb/db_test.go deleted file mode 100644 index 655754f38e1a9..0000000000000 --- a/jsondb/db_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package jsondb - -import ( - "log" - "os" - "path/filepath" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestDB(t *testing.T) { - dir, err := os.MkdirTemp("", "db-test") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) - - path := filepath.Join(dir, "db.json") - db, err := Open[testDB](path) - if err != nil { - t.Fatalf("creating empty DB: %v", err) - } - - if diff := cmp.Diff(db.Data, &testDB{}, cmp.AllowUnexported(testDB{})); diff != "" { - t.Fatalf("unexpected empty DB content (-got+want):\n%s", diff) - } - db.Data.MyString = "test" - db.Data.unexported = "don't keep" - db.Data.AnInt = 42 - if err := db.Save(); err != nil { - t.Fatalf("saving database: %v", err) - } - - db2, err := Open[testDB](path) - if err != nil { - log.Fatalf("opening DB again: %v", err) - } - want := &testDB{ - MyString: "test", - AnInt: 42, - } - if diff := cmp.Diff(db2.Data, want, cmp.AllowUnexported(testDB{})); diff != "" { - t.Fatalf("unexpected saved DB content (-got+want):\n%s", diff) - } -} - -type testDB struct { - MyString string - unexported string - AnInt int64 -} diff --git a/k8s-operator/api-docs-config.yaml b/k8s-operator/api-docs-config.yaml deleted file mode 100644 index 214171ca35c0d..0000000000000 --- a/k8s-operator/api-docs-config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -processor: {} -render: - kubernetesVersion: 1.30 diff --git a/k8s-operator/api.md b/k8s-operator/api.md deleted file mode 100644 index 7b1aca3148e5b..0000000000000 --- a/k8s-operator/api.md +++ /dev/null @@ -1,937 +0,0 @@ -# API Reference - -## Packages -- [tailscale.com/v1alpha1](#tailscalecomv1alpha1) - - -## tailscale.com/v1alpha1 - - -### Resource Types -- [Connector](#connector) -- [ConnectorList](#connectorlist) -- [DNSConfig](#dnsconfig) -- [DNSConfigList](#dnsconfiglist) -- [ProxyClass](#proxyclass) -- [ProxyClassList](#proxyclasslist) -- [ProxyGroup](#proxygroup) -- [ProxyGroupList](#proxygrouplist) -- [Recorder](#recorder) -- [RecorderList](#recorderlist) - - - -#### AppConnector - - - -AppConnector defines a Tailscale app connector node configured via Connector. - - - -_Appears in:_ -- [ConnectorSpec](#connectorspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `routes` _[Routes](#routes)_ | Routes are optional preconfigured routes for the domains routed via the app connector.
If not set, routes for the domains will be discovered dynamically.
If set, the app connector will immediately be able to route traffic using the preconfigured routes, but may
also dynamically discover other routes.
https://tailscale.com/kb/1332/apps-best-practices#preconfiguration | | Format: cidr
MinItems: 1
Type: string
| - - - - -#### Connector - - - -Connector defines a Tailscale node that will be deployed in the cluster. The -node can be configured to act as a Tailscale subnet router and/or a Tailscale -exit node. -Connector is a cluster-scoped resource. -More info: -https://tailscale.com/kb/1441/kubernetes-operator-connector - - - -_Appears in:_ -- [ConnectorList](#connectorlist) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `Connector` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[ConnectorSpec](#connectorspec)_ | ConnectorSpec describes the desired Tailscale component.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | | -| `status` _[ConnectorStatus](#connectorstatus)_ | ConnectorStatus describes the status of the Connector. This is set
and managed by the Tailscale operator. | | | - - -#### ConnectorList - - - - - - - - - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `ConnectorList` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[Connector](#connector) array_ | | | | - - -#### ConnectorSpec - - - -ConnectorSpec describes a Tailscale node to be deployed in the cluster. - - - -_Appears in:_ -- [Connector](#connector) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `tags` _[Tags](#tags)_ | Tags that the Tailscale node will be tagged with.
Defaults to [tag:k8s].
To autoapprove the subnet routes or exit node defined by a Connector,
you can configure Tailscale ACLs to give these tags the necessary
permissions.
See https://tailscale.com/kb/1337/acl-syntax#autoapprovers.
If you specify custom tags here, you must also make the operator an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a Connector node has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. | | Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$`
Type: string
| -| `hostname` _[Hostname](#hostname)_ | Hostname is the tailnet hostname that should be assigned to the
Connector node. If unset, hostname defaults to name>-connector. Hostname can contain lower case letters, numbers and
dashes, it must not start or end with a dash and must be between 2
and 63 characters long. | | Pattern: `^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`
Type: string
| -| `proxyClass` _string_ | ProxyClass is the name of the ProxyClass custom resource that
contains configuration options that should be applied to the
resources created for this Connector. If unset, the operator will
create resources with the default configuration. | | | -| `subnetRouter` _[SubnetRouter](#subnetrouter)_ | SubnetRouter defines subnet routes that the Connector device should
expose to tailnet as a Tailscale subnet router.
https://tailscale.com/kb/1019/subnets/
If this field is unset, the device does not get configured as a Tailscale subnet router.
This field is mutually exclusive with the appConnector field. | | | -| `appConnector` _[AppConnector](#appconnector)_ | AppConnector defines whether the Connector device should act as a Tailscale app connector. A Connector that is
configured as an app connector cannot be a subnet router or an exit node. If this field is unset, the
Connector does not act as an app connector.
Note that you will need to manually configure the permissions and the domains for the app connector via the
Admin panel.
Note also that the main tested and supported use case of this config option is to deploy an app connector on
Kubernetes to access SaaS applications available on the public internet. Using the app connector to expose
cluster workloads or other internal workloads to tailnet might work, but this is not a use case that we have
tested or optimised for.
If you are using the app connector to access SaaS applications because you need a predictable egress IP that
can be whitelisted, it is also your responsibility to ensure that cluster traffic from the connector flows
via that predictable IP, for example by enforcing that cluster egress traffic is routed via an egress NAT
device with a static IP address.
https://tailscale.com/kb/1281/app-connectors | | | -| `exitNode` _boolean_ | ExitNode defines whether the Connector device should act as a Tailscale exit node. Defaults to false.
This field is mutually exclusive with the appConnector field.
https://tailscale.com/kb/1103/exit-nodes | | | - - -#### ConnectorStatus - - - -ConnectorStatus defines the observed state of the Connector. - - - -_Appears in:_ -- [Connector](#connector) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#condition-v1-meta) array_ | List of status conditions to indicate the status of the Connector.
Known condition types are `ConnectorReady`. | | | -| `subnetRoutes` _string_ | SubnetRoutes are the routes currently exposed to tailnet via this
Connector instance. | | | -| `isExitNode` _boolean_ | IsExitNode is set to true if the Connector acts as an exit node. | | | -| `isAppConnector` _boolean_ | IsAppConnector is set to true if the Connector acts as an app connector. | | | -| `tailnetIPs` _string array_ | TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
assigned to the Connector node. | | | -| `hostname` _string_ | Hostname is the fully qualified domain name of the Connector node.
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
node. | | | - - -#### Container - - - - - - - -_Appears in:_ -- [Pod](#pod) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `env` _[Env](#env) array_ | List of environment variables to set in the container.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables
Note that environment variables provided here will take precedence
over Tailscale-specific environment variables set by the operator,
however running proxies with custom values for Tailscale environment
variables (i.e TS_USERSPACE) is not recommended and might break in
the future. | | | -| `image` _string_ | Container image name. By default images are pulled from
docker.io/tailscale/tailscale, but the official images are also
available at ghcr.io/tailscale/tailscale. Specifying image name here
will override any proxy image values specified via the Kubernetes
operator's Helm chart values or PROXY_IMAGE env var in the operator
Deployment.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | | -| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#pullpolicy-v1-core)_ | Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | Enum: [Always Never IfNotPresent]
| -| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#resourcerequirements-v1-core)_ | Container resource requirements.
By default Tailscale Kubernetes operator does not apply any resource
requirements. The amount of resources required wil depend on the
amount of resources the operator needs to parse, usage patterns and
cluster size.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources | | | -| `securityContext` _[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#securitycontext-v1-core)_ | Container security context.
Security context specified here will override the security context by the operator.
By default the operator:
- sets 'privileged: true' for the init container
- set NET_ADMIN capability for tailscale container for proxies that
are created for Services or Connector.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context | | | - - -#### DNSConfig - - - -DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS -names resolvable by cluster workloads. Use this if: A) you need to refer to -tailnet services, exposed to cluster via Tailscale Kubernetes operator egress -proxies by the MagicDNS names of those tailnet services (usually because the -services run over HTTPS) -B) you have exposed a cluster workload to the tailnet using Tailscale Ingress -and you also want to refer to the workload from within the cluster over the -Ingress's MagicDNS name (usually because you have some callback component -that needs to use the same URL as that used by a non-cluster client on -tailnet). -When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will -deploy a nameserver for ts.net DNS names and automatically populate it with records -for any Tailscale egress or Ingress proxies deployed to that cluster. -Currently you must manually update your cluster DNS configuration to add the -IP address of the deployed nameserver as a ts.net stub nameserver. -Instructions for how to do it: -https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), -https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). -Tailscale Kubernetes operator will write the address of a Service fronting -the nameserver to dsnconfig.status.nameserver.ip. -DNSConfig is a singleton - you must not create more than one. -NB: if you want cluster workloads to be able to refer to Tailscale Ingress -using its MagicDNS name, you must also annotate the Ingress resource with -tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to -ensure that the proxy created for the Ingress listens on its Pod IP address. -NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported. - - - -_Appears in:_ -- [DNSConfigList](#dnsconfiglist) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `DNSConfig` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[DNSConfigSpec](#dnsconfigspec)_ | Spec describes the desired DNS configuration.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | | -| `status` _[DNSConfigStatus](#dnsconfigstatus)_ | Status describes the status of the DNSConfig. This is set
and managed by the Tailscale operator. | | | - - -#### DNSConfigList - - - - - - - - - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `DNSConfigList` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[DNSConfig](#dnsconfig) array_ | | | | - - -#### DNSConfigSpec - - - - - - - -_Appears in:_ -- [DNSConfig](#dnsconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `nameserver` _[Nameserver](#nameserver)_ | Configuration for a nameserver that can resolve ts.net DNS names
associated with in-cluster proxies for Tailscale egress Services and
Tailscale Ingresses. The operator will always deploy this nameserver
when a DNSConfig is applied. | | | - - -#### DNSConfigStatus - - - - - - - -_Appears in:_ -- [DNSConfig](#dnsconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#condition-v1-meta) array_ | | | | -| `nameserver` _[NameserverStatus](#nameserverstatus)_ | Nameserver describes the status of nameserver cluster resources. | | | - - -#### Env - - - - - - - -_Appears in:_ -- [Container](#container) -- [RecorderContainer](#recordercontainer) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _[Name](#name)_ | Name of the environment variable. Must be a C_IDENTIFIER. | | Pattern: `^[-._a-zA-Z][-._a-zA-Z0-9]*$`
Type: string
| -| `value` _string_ | Variable references $(VAR_NAME) are expanded using the previously defined
environment variables in the container and any service environment
variables. If a variable cannot be resolved, the reference in the input
string will be unchanged. Double $$ are reduced to a single $, which
allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will
produce the string literal "$(VAR_NAME)". Escaped references will never
be expanded, regardless of whether the variable exists or not. Defaults
to "". | | | - - -#### Hostname - -_Underlying type:_ _string_ - - - -_Validation:_ -- Pattern: `^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$` -- Type: string - -_Appears in:_ -- [ConnectorSpec](#connectorspec) - - - -#### HostnamePrefix - -_Underlying type:_ _string_ - - - -_Validation:_ -- Pattern: `^[a-z0-9][a-z0-9-]{0,61}$` -- Type: string - -_Appears in:_ -- [ProxyGroupSpec](#proxygroupspec) - - - -#### Metrics - - - - - - - -_Appears in:_ -- [ProxyClassSpec](#proxyclassspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `enable` _boolean_ | Setting enable to true will make the proxy serve Tailscale metrics
at :9001/debug/metrics.
Defaults to false. | | | - - -#### Name - -_Underlying type:_ _string_ - - - -_Validation:_ -- Pattern: `^[-._a-zA-Z][-._a-zA-Z0-9]*$` -- Type: string - -_Appears in:_ -- [Env](#env) - - - -#### Nameserver - - - - - - - -_Appears in:_ -- [DNSConfigSpec](#dnsconfigspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `image` _[NameserverImage](#nameserverimage)_ | Nameserver image. Defaults to tailscale/k8s-nameserver:unstable. | | | - - -#### NameserverImage - - - - - - - -_Appears in:_ -- [Nameserver](#nameserver) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `repo` _string_ | Repo defaults to tailscale/k8s-nameserver. | | | -| `tag` _string_ | Tag defaults to unstable. | | | - - -#### NameserverStatus - - - - - - - -_Appears in:_ -- [DNSConfigStatus](#dnsconfigstatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `ip` _string_ | IP is the ClusterIP of the Service fronting the deployed ts.net nameserver.
Currently you must manually update your cluster DNS config to add
this address as a stub nameserver for ts.net for cluster workloads to be
able to resolve MagicDNS names associated with egress or Ingress
proxies.
The IP address will change if you delete and recreate the DNSConfig. | | | - - -#### Pod - - - - - - - -_Appears in:_ -- [StatefulSet](#statefulset) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `labels` _object (keys:string, values:string)_ | Labels that will be added to the proxy Pod.
Any labels specified here will be merged with the default labels
applied to the Pod by the Tailscale Kubernetes operator.
Label keys and values must be valid Kubernetes label keys and values.
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set | | | -| `annotations` _object (keys:string, values:string)_ | Annotations that will be added to the proxy Pod.
Any annotations specified here will be merged with the default
annotations applied to the Pod by the Tailscale Kubernetes operator.
Annotations must be valid Kubernetes annotations.
https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set | | | -| `affinity` _[Affinity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#affinity-v1-core)_ | Proxy Pod's affinity rules.
By default, the Tailscale Kubernetes operator does not apply any affinity rules.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity | | | -| `tailscaleContainer` _[Container](#container)_ | Configuration for the proxy container running tailscale. | | | -| `tailscaleInitContainer` _[Container](#container)_ | Configuration for the proxy init container that enables forwarding. | | | -| `securityContext` _[PodSecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#podsecuritycontext-v1-core)_ | Proxy Pod's security context.
By default Tailscale Kubernetes operator does not apply any Pod
security context.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 | | | -| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#localobjectreference-v1-core) array_ | Proxy Pod's image pull Secrets.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec | | | -| `nodeName` _string_ | Proxy Pod's node name.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | | -| `nodeSelector` _object (keys:string, values:string)_ | Proxy Pod's node selector.
By default Tailscale Kubernetes operator does not apply any node
selector.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | | -| `tolerations` _[Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#toleration-v1-core) array_ | Proxy Pod's tolerations.
By default Tailscale Kubernetes operator does not apply any
tolerations.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | | -| `topologySpreadConstraints` _[TopologySpreadConstraint](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#topologyspreadconstraint-v1-core) array_ | Proxy Pod's topology spread constraints.
By default Tailscale Kubernetes operator does not apply any topology spread constraints.
https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ | | | - - -#### ProxyClass - - - -ProxyClass describes a set of configuration parameters that can be applied to -proxy resources created by the Tailscale Kubernetes operator. -To apply a given ProxyClass to resources created for a tailscale Ingress or -Service, use tailscale.com/proxy-class= label. To apply a -given ProxyClass to resources created for a Connector, use -connector.spec.proxyClass field. -ProxyClass is a cluster scoped resource. -More info: -https://tailscale.com/kb/1445/kubernetes-operator-customization#cluster-resource-customization-using-proxyclass-custom-resource - - - -_Appears in:_ -- [ProxyClassList](#proxyclasslist) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `ProxyClass` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[ProxyClassSpec](#proxyclassspec)_ | Specification of the desired state of the ProxyClass resource.
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | | -| `status` _[ProxyClassStatus](#proxyclassstatus)_ | Status of the ProxyClass. This is set and managed automatically.
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | | - - -#### ProxyClassList - - - - - - - - - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `ProxyClassList` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[ProxyClass](#proxyclass) array_ | | | | - - -#### ProxyClassSpec - - - - - - - -_Appears in:_ -- [ProxyClass](#proxyclass) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `statefulSet` _[StatefulSet](#statefulset)_ | Configuration parameters for the proxy's StatefulSet. Tailscale
Kubernetes operator deploys a StatefulSet for each of the user
configured proxies (Tailscale Ingress, Tailscale Service, Connector). | | | -| `metrics` _[Metrics](#metrics)_ | Configuration for proxy metrics. Metrics are currently not supported
for egress proxies and for Ingress proxies that have been configured
with tailscale.com/experimental-forward-cluster-traffic-via-ingress
annotation. Note that the metrics are currently considered unstable
and will likely change in breaking ways in the future - we only
recommend that you use those for debugging purposes. | | | -| `tailscale` _[TailscaleConfig](#tailscaleconfig)_ | TailscaleConfig contains options to configure the tailscale-specific
parameters of proxies. | | | - - -#### ProxyClassStatus - - - - - - - -_Appears in:_ -- [ProxyClass](#proxyclass) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#condition-v1-meta) array_ | List of status conditions to indicate the status of the ProxyClass.
Known condition types are `ProxyClassReady`. | | | - - -#### ProxyGroup - - - - - - - -_Appears in:_ -- [ProxyGroupList](#proxygrouplist) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `ProxyGroup` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[ProxyGroupSpec](#proxygroupspec)_ | Spec describes the desired ProxyGroup instances. | | | -| `status` _[ProxyGroupStatus](#proxygroupstatus)_ | ProxyGroupStatus describes the status of the ProxyGroup resources. This is
set and managed by the Tailscale operator. | | | - - -#### ProxyGroupList - - - - - - - - - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `ProxyGroupList` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[ProxyGroup](#proxygroup) array_ | | | | - - -#### ProxyGroupSpec - - - - - - - -_Appears in:_ -- [ProxyGroup](#proxygroup) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `type` _[ProxyGroupType](#proxygrouptype)_ | Type of the ProxyGroup proxies. Currently the only supported type is egress. | | Enum: [egress]
Type: string
| -| `tags` _[Tags](#tags)_ | Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s].
If you specify custom tags here, make sure you also make the operator
an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a ProxyGroup device has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. | | Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$`
Type: string
| -| `replicas` _integer_ | Replicas specifies how many replicas to create the StatefulSet with.
Defaults to 2. | | | -| `hostnamePrefix` _[HostnamePrefix](#hostnameprefix)_ | HostnamePrefix is the hostname prefix to use for tailnet devices created
by the ProxyGroup. Each device will have the integer number from its
StatefulSet pod appended to this prefix to form the full hostname.
HostnamePrefix can contain lower case letters, numbers and dashes, it
must not start with a dash and must be between 1 and 62 characters long. | | Pattern: `^[a-z0-9][a-z0-9-]{0,61}$`
Type: string
| -| `proxyClass` _string_ | ProxyClass is the name of the ProxyClass custom resource that contains
configuration options that should be applied to the resources created
for this ProxyGroup. If unset, and there is no default ProxyClass
configured, the operator will create resources with the default
configuration. | | | - - -#### ProxyGroupStatus - - - - - - - -_Appears in:_ -- [ProxyGroup](#proxygroup) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#condition-v1-meta) array_ | List of status conditions to indicate the status of the ProxyGroup
resources. Known condition types are `ProxyGroupReady`. | | | -| `devices` _[TailnetDevice](#tailnetdevice) array_ | List of tailnet devices associated with the ProxyGroup StatefulSet. | | | - - -#### ProxyGroupType - -_Underlying type:_ _string_ - - - -_Validation:_ -- Enum: [egress] -- Type: string - -_Appears in:_ -- [ProxyGroupSpec](#proxygroupspec) - - - -#### Recorder - - - - - - - -_Appears in:_ -- [RecorderList](#recorderlist) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `Recorder` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[RecorderSpec](#recorderspec)_ | Spec describes the desired recorder instance. | | | -| `status` _[RecorderStatus](#recorderstatus)_ | RecorderStatus describes the status of the recorder. This is set
and managed by the Tailscale operator. | | | - - -#### RecorderContainer - - - - - - - -_Appears in:_ -- [RecorderPod](#recorderpod) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `env` _[Env](#env) array_ | List of environment variables to set in the container.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables
Note that environment variables provided here will take precedence
over Tailscale-specific environment variables set by the operator,
however running proxies with custom values for Tailscale environment
variables (i.e TS_USERSPACE) is not recommended and might break in
the future. | | | -| `image` _string_ | Container image name including tag. Defaults to docker.io/tailscale/tsrecorder
with the same tag as the operator, but the official images are also
available at ghcr.io/tailscale/tsrecorder.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | | -| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#pullpolicy-v1-core)_ | Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | Enum: [Always Never IfNotPresent]
| -| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#resourcerequirements-v1-core)_ | Container resource requirements.
By default, the operator does not apply any resource requirements. The
amount of resources required wil depend on the volume of recordings sent.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources | | | -| `securityContext` _[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#securitycontext-v1-core)_ | Container security context. By default, the operator does not apply any
container security context.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context | | | - - -#### RecorderList - - - - - - - - - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | -| `kind` _string_ | `RecorderList` | | | -| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | -| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | -| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[Recorder](#recorder) array_ | | | | - - -#### RecorderPod - - - - - - - -_Appears in:_ -- [RecorderStatefulSet](#recorderstatefulset) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `labels` _object (keys:string, values:string)_ | Labels that will be added to Recorder Pods. Any labels specified here
will be merged with the default labels applied to the Pod by the operator.
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set | | | -| `annotations` _object (keys:string, values:string)_ | Annotations that will be added to Recorder Pods. Any annotations
specified here will be merged with the default annotations applied to
the Pod by the operator.
https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set | | | -| `affinity` _[Affinity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#affinity-v1-core)_ | Affinity rules for Recorder Pods. By default, the operator does not
apply any affinity rules.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity | | | -| `container` _[RecorderContainer](#recordercontainer)_ | Configuration for the Recorder container running tailscale. | | | -| `securityContext` _[PodSecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#podsecuritycontext-v1-core)_ | Security context for Recorder Pods. By default, the operator does not
apply any Pod security context.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 | | | -| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#localobjectreference-v1-core) array_ | Image pull Secrets for Recorder Pods.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec | | | -| `nodeSelector` _object (keys:string, values:string)_ | Node selector rules for Recorder Pods. By default, the operator does
not apply any node selector rules.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | | -| `tolerations` _[Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#toleration-v1-core) array_ | Tolerations for Recorder Pods. By default, the operator does not apply
any tolerations.
https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | | - - -#### RecorderSpec - - - - - - - -_Appears in:_ -- [Recorder](#recorder) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `statefulSet` _[RecorderStatefulSet](#recorderstatefulset)_ | Configuration parameters for the Recorder's StatefulSet. The operator
deploys a StatefulSet for each Recorder resource. | | | -| `tags` _[Tags](#tags)_ | Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s].
If you specify custom tags here, make sure you also make the operator
an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a Recorder node has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. | | Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$`
Type: string
| -| `enableUI` _boolean_ | Set to true to enable the Recorder UI. The UI lists and plays recorded sessions.
The UI will be served at :443. Defaults to false.
Corresponds to --ui tsrecorder flag https://tailscale.com/kb/1246/tailscale-ssh-session-recording#deploy-a-recorder-node.
Required if S3 storage is not set up, to ensure that recordings are accessible. | | | -| `storage` _[Storage](#storage)_ | Configure where to store session recordings. By default, recordings will
be stored in a local ephemeral volume, and will not be persisted past the
lifetime of a specific pod. | | | - - -#### RecorderStatefulSet - - - - - - - -_Appears in:_ -- [RecorderSpec](#recorderspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `labels` _object (keys:string, values:string)_ | Labels that will be added to the StatefulSet created for the Recorder.
Any labels specified here will be merged with the default labels applied
to the StatefulSet by the operator.
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set | | | -| `annotations` _object (keys:string, values:string)_ | Annotations that will be added to the StatefulSet created for the Recorder.
Any Annotations specified here will be merged with the default annotations
applied to the StatefulSet by the operator.
https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set | | | -| `pod` _[RecorderPod](#recorderpod)_ | Configuration for pods created by the Recorder's StatefulSet. | | | - - -#### RecorderStatus - - - - - - - -_Appears in:_ -- [Recorder](#recorder) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#condition-v1-meta) array_ | List of status conditions to indicate the status of the Recorder.
Known condition types are `RecorderReady`. | | | -| `devices` _[RecorderTailnetDevice](#recordertailnetdevice) array_ | List of tailnet devices associated with the Recorder StatefulSet. | | | - - -#### RecorderTailnetDevice - - - - - - - -_Appears in:_ -- [RecorderStatus](#recorderstatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `hostname` _string_ | Hostname is the fully qualified domain name of the device.
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
node. | | | -| `tailnetIPs` _string array_ | TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
assigned to the device. | | | -| `url` _string_ | URL where the UI is available if enabled for replaying recordings. This
will be an HTTPS MagicDNS URL. You must be connected to the same tailnet
as the recorder to access it. | | | - - -#### Route - -_Underlying type:_ _string_ - - - -_Validation:_ -- Format: cidr -- Type: string - -_Appears in:_ -- [Routes](#routes) - - - -#### Routes - -_Underlying type:_ _[Route](#route)_ - - - -_Validation:_ -- Format: cidr -- MinItems: 1 -- Type: string - -_Appears in:_ -- [AppConnector](#appconnector) -- [SubnetRouter](#subnetrouter) - - - -#### S3 - - - - - - - -_Appears in:_ -- [Storage](#storage) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `endpoint` _string_ | S3-compatible endpoint, e.g. s3.us-east-1.amazonaws.com. | | | -| `bucket` _string_ | Bucket name to write to. The bucket is expected to be used solely for
recordings, as there is no stable prefix for written object names. | | | -| `credentials` _[S3Credentials](#s3credentials)_ | Configure environment variable credentials for managing objects in the
configured bucket. If not set, tsrecorder will try to acquire credentials
first from the file system and then the STS API. | | | - - -#### S3Credentials - - - - - - - -_Appears in:_ -- [S3](#s3) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `secret` _[S3Secret](#s3secret)_ | Use a Kubernetes Secret from the operator's namespace as the source of
credentials. | | | - - -#### S3Secret - - - - - - - -_Appears in:_ -- [S3Credentials](#s3credentials) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | The name of a Kubernetes Secret in the operator's namespace that contains
credentials for writing to the configured bucket. Each key-value pair
from the secret's data will be mounted as an environment variable. It
should include keys for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY if
using a static access key. | | | - - -#### StatefulSet - - - - - - - -_Appears in:_ -- [ProxyClassSpec](#proxyclassspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `labels` _object (keys:string, values:string)_ | Labels that will be added to the StatefulSet created for the proxy.
Any labels specified here will be merged with the default labels
applied to the StatefulSet by the Tailscale Kubernetes operator as
well as any other labels that might have been applied by other
actors.
Label keys and values must be valid Kubernetes label keys and values.
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set | | | -| `annotations` _object (keys:string, values:string)_ | Annotations that will be added to the StatefulSet created for the proxy.
Any Annotations specified here will be merged with the default annotations
applied to the StatefulSet by the Tailscale Kubernetes operator as
well as any other annotations that might have been applied by other
actors.
Annotations must be valid Kubernetes annotations.
https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set | | | -| `pod` _[Pod](#pod)_ | Configuration for the proxy Pod. | | | - - -#### Storage - - - - - - - -_Appears in:_ -- [RecorderSpec](#recorderspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `s3` _[S3](#s3)_ | Configure an S3-compatible API for storage. Required if the UI is not
enabled, to ensure that recordings are accessible. | | | - - -#### SubnetRouter - - - -SubnetRouter defines subnet routes that should be exposed to tailnet via a -Connector node. - - - -_Appears in:_ -- [ConnectorSpec](#connectorspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `advertiseRoutes` _[Routes](#routes)_ | AdvertiseRoutes refer to CIDRs that the subnet router should make
available. Route values must be strings that represent a valid IPv4
or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes.
https://tailscale.com/kb/1201/4via6-subnets/ | | Format: cidr
MinItems: 1
Type: string
| - - -#### Tag - -_Underlying type:_ _string_ - - - -_Validation:_ -- Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$` -- Type: string - -_Appears in:_ -- [Tags](#tags) - - - -#### Tags - -_Underlying type:_ _[Tag](#tag)_ - - - -_Validation:_ -- Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$` -- Type: string - -_Appears in:_ -- [ConnectorSpec](#connectorspec) -- [ProxyGroupSpec](#proxygroupspec) -- [RecorderSpec](#recorderspec) - - - -#### TailnetDevice - - - - - - - -_Appears in:_ -- [ProxyGroupStatus](#proxygroupstatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `hostname` _string_ | Hostname is the fully qualified domain name of the device.
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
node. | | | -| `tailnetIPs` _string array_ | TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
assigned to the device. | | | - - -#### TailscaleConfig - - - - - - - -_Appears in:_ -- [ProxyClassSpec](#proxyclassspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `acceptRoutes` _boolean_ | AcceptRoutes can be set to true to make the proxy instance accept
routes advertized by other nodes on the tailnet, such as subnet
routes.
This is equivalent of passing --accept-routes flag to a tailscale Linux client.
https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices
Defaults to false. | | | - - diff --git a/k8s-operator/apis/doc.go b/k8s-operator/apis/doc.go deleted file mode 100644 index 0a1145ca8a0dc..0000000000000 --- a/k8s-operator/apis/doc.go +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// Package apis contains a constant to name the Tailscale Kubernetes Operator's schema group. -package apis - -const GroupName = "tailscale.com" diff --git a/k8s-operator/apis/v1alpha1/doc.go b/k8s-operator/apis/v1alpha1/doc.go deleted file mode 100644 index 467e73e17cb21..0000000000000 --- a/k8s-operator/apis/v1alpha1/doc.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// +kubebuilder:object:generate=true -// +groupName=tailscale.com -package v1alpha1 diff --git a/k8s-operator/apis/v1alpha1/register.go b/k8s-operator/apis/v1alpha1/register.go deleted file mode 100644 index 70b411d120994..0000000000000 --- a/k8s-operator/apis/v1alpha1/register.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package v1alpha1 - -import ( - "fmt" - - "tailscale.com/k8s-operator/apis" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/kubernetes/scheme" -) - -// SchemeGroupVersion is group version used to register these objects -var SchemeGroupVersion = schema.GroupVersion{Group: apis.GroupName, Version: "v1alpha1"} - -// Resource takes an unqualified resource and returns a Group qualified GroupResource -func Resource(resource string) schema.GroupResource { - return SchemeGroupVersion.WithResource(resource).GroupResource() -} - -var ( - SchemeBuilder runtime.SchemeBuilder - localSchemeBuilder = &SchemeBuilder - AddToScheme = localSchemeBuilder.AddToScheme - - GlobalScheme *runtime.Scheme -) - -func init() { - // We only register manually written functions here. The registration of the - // generated functions takes place in the generated files. The separation - // makes the code compile even when the generated files are missing. - localSchemeBuilder.Register(addKnownTypes) - - GlobalScheme = runtime.NewScheme() - if err := scheme.AddToScheme(GlobalScheme); err != nil { - panic(fmt.Sprintf("failed to add k8s.io scheme: %s", err)) - } - if err := AddToScheme(GlobalScheme); err != nil { - panic(fmt.Sprintf("failed to add tailscale.com scheme: %s", err)) - } -} - -// Adds the list of known types to api.Scheme. -func addKnownTypes(scheme *runtime.Scheme) error { - scheme.AddKnownTypes(SchemeGroupVersion, - &Connector{}, - &ConnectorList{}, - &ProxyClass{}, - &ProxyClassList{}, - &DNSConfig{}, - &DNSConfigList{}, - &Recorder{}, - &RecorderList{}, - &ProxyGroup{}, - &ProxyGroupList{}, - ) - - metav1.AddToGroupVersion(scheme, SchemeGroupVersion) - return nil -} diff --git a/k8s-operator/apis/v1alpha1/types_connector.go b/k8s-operator/apis/v1alpha1/types_connector.go deleted file mode 100644 index 0222584859bd6..0000000000000 --- a/k8s-operator/apis/v1alpha1/types_connector.go +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package v1alpha1 - -import ( - "fmt" - "strings" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// Code comments on these types should be treated as user facing documentation- -// they will appear on the Connector CRD i.e if someone runs kubectl explain connector. - -var ConnectorKind = "Connector" - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster,shortName=cn -// +kubebuilder:printcolumn:name="SubnetRoutes",type="string",JSONPath=`.status.subnetRoutes`,description="CIDR ranges exposed to tailnet by a subnet router defined via this Connector instance." -// +kubebuilder:printcolumn:name="IsExitNode",type="string",JSONPath=`.status.isExitNode`,description="Whether this Connector instance defines an exit node." -// +kubebuilder:printcolumn:name="IsAppConnector",type="string",JSONPath=`.status.isAppConnector`,description="Whether this Connector instance is an app connector." -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "ConnectorReady")].reason`,description="Status of the deployed Connector resources." - -// Connector defines a Tailscale node that will be deployed in the cluster. The -// node can be configured to act as a Tailscale subnet router and/or a Tailscale -// exit node. -// Connector is a cluster-scoped resource. -// More info: -// https://tailscale.com/kb/1441/kubernetes-operator-connector -type Connector struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // ConnectorSpec describes the desired Tailscale component. - // More info: - // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - Spec ConnectorSpec `json:"spec"` - - // ConnectorStatus describes the status of the Connector. This is set - // and managed by the Tailscale operator. - // +optional - Status ConnectorStatus `json:"status"` -} - -// +kubebuilder:object:root=true - -type ConnectorList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata"` - - Items []Connector `json:"items"` -} - -// ConnectorSpec describes a Tailscale node to be deployed in the cluster. -// +kubebuilder:validation:XValidation:rule="has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true) || has(self.appConnector)",message="A Connector needs to have at least one of exit node, subnet router or app connector configured." -// +kubebuilder:validation:XValidation:rule="!((has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true)) && has(self.appConnector))",message="The appConnector field is mutually exclusive with exitNode and subnetRouter fields." -type ConnectorSpec struct { - // Tags that the Tailscale node will be tagged with. - // Defaults to [tag:k8s]. - // To autoapprove the subnet routes or exit node defined by a Connector, - // you can configure Tailscale ACLs to give these tags the necessary - // permissions. - // See https://tailscale.com/kb/1337/acl-syntax#autoapprovers. - // If you specify custom tags here, you must also make the operator an owner of these tags. - // See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - // Tags cannot be changed once a Connector node has been created. - // Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - // +optional - Tags Tags `json:"tags,omitempty"` - // Hostname is the tailnet hostname that should be assigned to the - // Connector node. If unset, hostname defaults to -connector. Hostname can contain lower case letters, numbers and - // dashes, it must not start or end with a dash and must be between 2 - // and 63 characters long. - // +optional - Hostname Hostname `json:"hostname,omitempty"` - // ProxyClass is the name of the ProxyClass custom resource that - // contains configuration options that should be applied to the - // resources created for this Connector. If unset, the operator will - // create resources with the default configuration. - // +optional - ProxyClass string `json:"proxyClass,omitempty"` - // SubnetRouter defines subnet routes that the Connector device should - // expose to tailnet as a Tailscale subnet router. - // https://tailscale.com/kb/1019/subnets/ - // If this field is unset, the device does not get configured as a Tailscale subnet router. - // This field is mutually exclusive with the appConnector field. - // +optional - SubnetRouter *SubnetRouter `json:"subnetRouter,omitempty"` - // AppConnector defines whether the Connector device should act as a Tailscale app connector. A Connector that is - // configured as an app connector cannot be a subnet router or an exit node. If this field is unset, the - // Connector does not act as an app connector. - // Note that you will need to manually configure the permissions and the domains for the app connector via the - // Admin panel. - // Note also that the main tested and supported use case of this config option is to deploy an app connector on - // Kubernetes to access SaaS applications available on the public internet. Using the app connector to expose - // cluster workloads or other internal workloads to tailnet might work, but this is not a use case that we have - // tested or optimised for. - // If you are using the app connector to access SaaS applications because you need a predictable egress IP that - // can be whitelisted, it is also your responsibility to ensure that cluster traffic from the connector flows - // via that predictable IP, for example by enforcing that cluster egress traffic is routed via an egress NAT - // device with a static IP address. - // https://tailscale.com/kb/1281/app-connectors - // +optional - AppConnector *AppConnector `json:"appConnector,omitempty"` - // ExitNode defines whether the Connector device should act as a Tailscale exit node. Defaults to false. - // This field is mutually exclusive with the appConnector field. - // https://tailscale.com/kb/1103/exit-nodes - // +optional - ExitNode bool `json:"exitNode"` -} - -// SubnetRouter defines subnet routes that should be exposed to tailnet via a -// Connector node. -type SubnetRouter struct { - // AdvertiseRoutes refer to CIDRs that the subnet router should make - // available. Route values must be strings that represent a valid IPv4 - // or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. - // https://tailscale.com/kb/1201/4via6-subnets/ - AdvertiseRoutes Routes `json:"advertiseRoutes"` -} - -// AppConnector defines a Tailscale app connector node configured via Connector. -type AppConnector struct { - // Routes are optional preconfigured routes for the domains routed via the app connector. - // If not set, routes for the domains will be discovered dynamically. - // If set, the app connector will immediately be able to route traffic using the preconfigured routes, but may - // also dynamically discover other routes. - // https://tailscale.com/kb/1332/apps-best-practices#preconfiguration - // +optional - Routes Routes `json:"routes"` -} - -type Tags []Tag - -func (tags Tags) Stringify() []string { - stringTags := make([]string, len(tags)) - for i, t := range tags { - stringTags[i] = string(t) - } - return stringTags -} - -// +kubebuilder:validation:MinItems=1 -type Routes []Route - -func (routes Routes) Stringify() string { - if len(routes) < 1 { - return "" - } - var sb strings.Builder - sb.WriteString(string(routes[0])) - for _, r := range routes[1:] { - sb.WriteString(fmt.Sprintf(",%s", r)) - } - return sb.String() -} - -// +kubebuilder:validation:Type=string -// +kubebuilder:validation:Format=cidr -type Route string - -// +kubebuilder:validation:Type=string -// +kubebuilder:validation:Pattern=`^tag:[a-zA-Z][a-zA-Z0-9-]*$` -type Tag string - -// +kubebuilder:validation:Type=string -// +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$` -type Hostname string - -// ConnectorStatus defines the observed state of the Connector. -type ConnectorStatus struct { - // List of status conditions to indicate the status of the Connector. - // Known condition types are `ConnectorReady`. - // +listType=map - // +listMapKey=type - // +optional - Conditions []metav1.Condition `json:"conditions"` - // SubnetRoutes are the routes currently exposed to tailnet via this - // Connector instance. - // +optional - SubnetRoutes string `json:"subnetRoutes"` - // IsExitNode is set to true if the Connector acts as an exit node. - // +optional - IsExitNode bool `json:"isExitNode"` - // IsAppConnector is set to true if the Connector acts as an app connector. - // +optional - IsAppConnector bool `json:"isAppConnector"` - // TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - // assigned to the Connector node. - // +optional - TailnetIPs []string `json:"tailnetIPs,omitempty"` - // Hostname is the fully qualified domain name of the Connector node. - // If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - // node. - // +optional - Hostname string `json:"hostname,omitempty"` -} - -type ConditionType string - -const ( - ConnectorReady ConditionType = `ConnectorReady` - ProxyClassReady ConditionType = `ProxyClassReady` - ProxyGroupReady ConditionType = `ProxyGroupReady` - ProxyReady ConditionType = `TailscaleProxyReady` // a Tailscale-specific condition type for corev1.Service - RecorderReady ConditionType = `RecorderReady` - // EgressSvcValid gets set on a user configured ExternalName Service that defines a tailnet target to be exposed - // on a ProxyGroup. - // Set to true if the user provided configuration is valid. - EgressSvcValid ConditionType = `TailscaleEgressSvcValid` - // EgressSvcConfigured gets set on a user configured ExternalName Service that defines a tailnet target to be exposed - // on a ProxyGroup. - // Set to true if the cluster resources for the service have been successfully configured. - EgressSvcConfigured ConditionType = `TailscaleEgressSvcConfigured` - // EgressSvcReady gets set on a user configured ExternalName Service that defines a tailnet target to be exposed - // on a ProxyGroup. - // Set to true if the service is ready to route cluster traffic. - EgressSvcReady ConditionType = `TailscaleEgressSvcReady` -) diff --git a/k8s-operator/apis/v1alpha1/types_proxyclass.go b/k8s-operator/apis/v1alpha1/types_proxyclass.go deleted file mode 100644 index 0a224b7960495..0000000000000 --- a/k8s-operator/apis/v1alpha1/types_proxyclass.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package v1alpha1 - -import ( - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -var ProxyClassKind = "ProxyClass" - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "ProxyClassReady")].reason`,description="Status of the ProxyClass." - -// ProxyClass describes a set of configuration parameters that can be applied to -// proxy resources created by the Tailscale Kubernetes operator. -// To apply a given ProxyClass to resources created for a tailscale Ingress or -// Service, use tailscale.com/proxy-class= label. To apply a -// given ProxyClass to resources created for a Connector, use -// connector.spec.proxyClass field. -// ProxyClass is a cluster scoped resource. -// More info: -// https://tailscale.com/kb/1445/kubernetes-operator-customization#cluster-resource-customization-using-proxyclass-custom-resource -type ProxyClass struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // Specification of the desired state of the ProxyClass resource. - // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - Spec ProxyClassSpec `json:"spec"` - - // +optional - // Status of the ProxyClass. This is set and managed automatically. - // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - Status ProxyClassStatus `json:"status"` -} - -// +kubebuilder:object:root=true -type ProxyClassList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata"` - - Items []ProxyClass `json:"items"` -} - -type ProxyClassSpec struct { - // Configuration parameters for the proxy's StatefulSet. Tailscale - // Kubernetes operator deploys a StatefulSet for each of the user - // configured proxies (Tailscale Ingress, Tailscale Service, Connector). - // +optional - StatefulSet *StatefulSet `json:"statefulSet"` - // Configuration for proxy metrics. Metrics are currently not supported - // for egress proxies and for Ingress proxies that have been configured - // with tailscale.com/experimental-forward-cluster-traffic-via-ingress - // annotation. Note that the metrics are currently considered unstable - // and will likely change in breaking ways in the future - we only - // recommend that you use those for debugging purposes. - // +optional - Metrics *Metrics `json:"metrics,omitempty"` - // TailscaleConfig contains options to configure the tailscale-specific - // parameters of proxies. - // +optional - TailscaleConfig *TailscaleConfig `json:"tailscale,omitempty"` -} - -type TailscaleConfig struct { - // AcceptRoutes can be set to true to make the proxy instance accept - // routes advertized by other nodes on the tailnet, such as subnet - // routes. - // This is equivalent of passing --accept-routes flag to a tailscale Linux client. - // https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices - // Defaults to false. - AcceptRoutes bool `json:"acceptRoutes,omitempty"` -} - -type StatefulSet struct { - // Labels that will be added to the StatefulSet created for the proxy. - // Any labels specified here will be merged with the default labels - // applied to the StatefulSet by the Tailscale Kubernetes operator as - // well as any other labels that might have been applied by other - // actors. - // Label keys and values must be valid Kubernetes label keys and values. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - // +optional - Labels map[string]string `json:"labels,omitempty"` - // Annotations that will be added to the StatefulSet created for the proxy. - // Any Annotations specified here will be merged with the default annotations - // applied to the StatefulSet by the Tailscale Kubernetes operator as - // well as any other annotations that might have been applied by other - // actors. - // Annotations must be valid Kubernetes annotations. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - // +optional - Annotations map[string]string `json:"annotations,omitempty"` - // Configuration for the proxy Pod. - // +optional - Pod *Pod `json:"pod,omitempty"` -} - -type Pod struct { - // Labels that will be added to the proxy Pod. - // Any labels specified here will be merged with the default labels - // applied to the Pod by the Tailscale Kubernetes operator. - // Label keys and values must be valid Kubernetes label keys and values. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - // +optional - Labels map[string]string `json:"labels,omitempty"` - // Annotations that will be added to the proxy Pod. - // Any annotations specified here will be merged with the default - // annotations applied to the Pod by the Tailscale Kubernetes operator. - // Annotations must be valid Kubernetes annotations. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - // +optional - Annotations map[string]string `json:"annotations,omitempty"` - // Proxy Pod's affinity rules. - // By default, the Tailscale Kubernetes operator does not apply any affinity rules. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - // +optional - Affinity *corev1.Affinity `json:"affinity,omitempty"` - // Configuration for the proxy container running tailscale. - // +optional - TailscaleContainer *Container `json:"tailscaleContainer,omitempty"` - // Configuration for the proxy init container that enables forwarding. - // +optional - TailscaleInitContainer *Container `json:"tailscaleInitContainer,omitempty"` - // Proxy Pod's security context. - // By default Tailscale Kubernetes operator does not apply any Pod - // security context. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - // +optional - SecurityContext *corev1.PodSecurityContext `json:"securityContext,omitempty"` - // Proxy Pod's image pull Secrets. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - // +optional - ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` - // Proxy Pod's node name. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - // +optional - NodeName string `json:"nodeName,omitempty"` - // Proxy Pod's node selector. - // By default Tailscale Kubernetes operator does not apply any node - // selector. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - // +optional - NodeSelector map[string]string `json:"nodeSelector,omitempty"` - // Proxy Pod's tolerations. - // By default Tailscale Kubernetes operator does not apply any - // tolerations. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - // +optional - Tolerations []corev1.Toleration `json:"tolerations,omitempty"` - // Proxy Pod's topology spread constraints. - // By default Tailscale Kubernetes operator does not apply any topology spread constraints. - // https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ - // +optional - TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` -} - -type Metrics struct { - // Setting enable to true will make the proxy serve Tailscale metrics - // at :9001/debug/metrics. - // Defaults to false. - Enable bool `json:"enable"` -} - -type Container struct { - // List of environment variables to set in the container. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - // Note that environment variables provided here will take precedence - // over Tailscale-specific environment variables set by the operator, - // however running proxies with custom values for Tailscale environment - // variables (i.e TS_USERSPACE) is not recommended and might break in - // the future. - // +optional - Env []Env `json:"env,omitempty"` - // Container image name. By default images are pulled from - // docker.io/tailscale/tailscale, but the official images are also - // available at ghcr.io/tailscale/tailscale. Specifying image name here - // will override any proxy image values specified via the Kubernetes - // operator's Helm chart values or PROXY_IMAGE env var in the operator - // Deployment. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - // +optional - Image string `json:"image,omitempty"` - // Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - // +kubebuilder:validation:Enum=Always;Never;IfNotPresent - // +optional - ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` - // Container resource requirements. - // By default Tailscale Kubernetes operator does not apply any resource - // requirements. The amount of resources required wil depend on the - // amount of resources the operator needs to parse, usage patterns and - // cluster size. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - // Container security context. - // Security context specified here will override the security context by the operator. - // By default the operator: - // - sets 'privileged: true' for the init container - // - set NET_ADMIN capability for tailscale container for proxies that - // are created for Services or Connector. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - // +optional - SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"` -} - -type Env struct { - // Name of the environment variable. Must be a C_IDENTIFIER. - Name Name `json:"name"` - // Variable references $(VAR_NAME) are expanded using the previously defined - // environment variables in the container and any service environment - // variables. If a variable cannot be resolved, the reference in the input - // string will be unchanged. Double $$ are reduced to a single $, which - // allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - // produce the string literal "$(VAR_NAME)". Escaped references will never - // be expanded, regardless of whether the variable exists or not. Defaults - // to "". - // +optional - Value string `json:"value,omitempty"` -} - -// +kubebuilder:validation:Type=string -// +kubebuilder:validation:Pattern=`^[-._a-zA-Z][-._a-zA-Z0-9]*$` -type Name string - -type ProxyClassStatus struct { - // List of status conditions to indicate the status of the ProxyClass. - // Known condition types are `ProxyClassReady`. - // +listType=map - // +listMapKey=type - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` -} diff --git a/k8s-operator/apis/v1alpha1/types_proxygroup.go b/k8s-operator/apis/v1alpha1/types_proxygroup.go deleted file mode 100644 index 7e5515ba9d66c..0000000000000 --- a/k8s-operator/apis/v1alpha1/types_proxygroup.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster,shortName=pg -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "ProxyGroupReady")].reason`,description="Status of the deployed ProxyGroup resources." - -type ProxyGroup struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // Spec describes the desired ProxyGroup instances. - Spec ProxyGroupSpec `json:"spec"` - - // ProxyGroupStatus describes the status of the ProxyGroup resources. This is - // set and managed by the Tailscale operator. - // +optional - Status ProxyGroupStatus `json:"status"` -} - -// +kubebuilder:object:root=true - -type ProxyGroupList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata"` - - Items []ProxyGroup `json:"items"` -} - -type ProxyGroupSpec struct { - // Type of the ProxyGroup proxies. Currently the only supported type is egress. - Type ProxyGroupType `json:"type"` - - // Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s]. - // If you specify custom tags here, make sure you also make the operator - // an owner of these tags. - // See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - // Tags cannot be changed once a ProxyGroup device has been created. - // Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - // +optional - Tags Tags `json:"tags,omitempty"` - - // Replicas specifies how many replicas to create the StatefulSet with. - // Defaults to 2. - // +optional - Replicas *int32 `json:"replicas,omitempty"` - - // HostnamePrefix is the hostname prefix to use for tailnet devices created - // by the ProxyGroup. Each device will have the integer number from its - // StatefulSet pod appended to this prefix to form the full hostname. - // HostnamePrefix can contain lower case letters, numbers and dashes, it - // must not start with a dash and must be between 1 and 62 characters long. - // +optional - HostnamePrefix HostnamePrefix `json:"hostnamePrefix,omitempty"` - - // ProxyClass is the name of the ProxyClass custom resource that contains - // configuration options that should be applied to the resources created - // for this ProxyGroup. If unset, and there is no default ProxyClass - // configured, the operator will create resources with the default - // configuration. - // +optional - ProxyClass string `json:"proxyClass,omitempty"` -} - -type ProxyGroupStatus struct { - // List of status conditions to indicate the status of the ProxyGroup - // resources. Known condition types are `ProxyGroupReady`. - // +listType=map - // +listMapKey=type - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` - - // List of tailnet devices associated with the ProxyGroup StatefulSet. - // +listType=map - // +listMapKey=hostname - // +optional - Devices []TailnetDevice `json:"devices,omitempty"` -} - -type TailnetDevice struct { - // Hostname is the fully qualified domain name of the device. - // If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - // node. - Hostname string `json:"hostname"` - - // TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - // assigned to the device. - // +optional - TailnetIPs []string `json:"tailnetIPs,omitempty"` -} - -// +kubebuilder:validation:Type=string -// +kubebuilder:validation:Enum=egress -type ProxyGroupType string - -const ( - ProxyGroupTypeEgress ProxyGroupType = "egress" -) - -// +kubebuilder:validation:Type=string -// +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]{0,61}$` -type HostnamePrefix string diff --git a/k8s-operator/apis/v1alpha1/types_recorder.go b/k8s-operator/apis/v1alpha1/types_recorder.go deleted file mode 100644 index 3728154b45170..0000000000000 --- a/k8s-operator/apis/v1alpha1/types_recorder.go +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package v1alpha1 - -import ( - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster,shortName=rec -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "RecorderReady")].reason`,description="Status of the deployed Recorder resources." -// +kubebuilder:printcolumn:name="URL",type="string",JSONPath=`.status.devices[?(@.url != "")].url`,description="URL on which the UI is exposed if enabled." - -type Recorder struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // Spec describes the desired recorder instance. - Spec RecorderSpec `json:"spec"` - - // RecorderStatus describes the status of the recorder. This is set - // and managed by the Tailscale operator. - // +optional - Status RecorderStatus `json:"status"` -} - -// +kubebuilder:object:root=true - -type RecorderList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata"` - - Items []Recorder `json:"items"` -} - -type RecorderSpec struct { - // Configuration parameters for the Recorder's StatefulSet. The operator - // deploys a StatefulSet for each Recorder resource. - // +optional - StatefulSet RecorderStatefulSet `json:"statefulSet"` - - // Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s]. - // If you specify custom tags here, make sure you also make the operator - // an owner of these tags. - // See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - // Tags cannot be changed once a Recorder node has been created. - // Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - // +optional - Tags Tags `json:"tags,omitempty"` - - // TODO(tomhjp): Support a hostname or hostname prefix field, depending on - // the plan for multiple replicas. - - // Set to true to enable the Recorder UI. The UI lists and plays recorded sessions. - // The UI will be served at :443. Defaults to false. - // Corresponds to --ui tsrecorder flag https://tailscale.com/kb/1246/tailscale-ssh-session-recording#deploy-a-recorder-node. - // Required if S3 storage is not set up, to ensure that recordings are accessible. - // +optional - EnableUI bool `json:"enableUI,omitempty"` - - // Configure where to store session recordings. By default, recordings will - // be stored in a local ephemeral volume, and will not be persisted past the - // lifetime of a specific pod. - // +optional - Storage Storage `json:"storage,omitempty"` -} - -type RecorderStatefulSet struct { - // Labels that will be added to the StatefulSet created for the Recorder. - // Any labels specified here will be merged with the default labels applied - // to the StatefulSet by the operator. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - // +optional - Labels map[string]string `json:"labels,omitempty"` - - // Annotations that will be added to the StatefulSet created for the Recorder. - // Any Annotations specified here will be merged with the default annotations - // applied to the StatefulSet by the operator. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - // +optional - Annotations map[string]string `json:"annotations,omitempty"` - - // Configuration for pods created by the Recorder's StatefulSet. - // +optional - Pod RecorderPod `json:"pod,omitempty"` -} - -type RecorderPod struct { - // Labels that will be added to Recorder Pods. Any labels specified here - // will be merged with the default labels applied to the Pod by the operator. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - // +optional - Labels map[string]string `json:"labels,omitempty"` - - // Annotations that will be added to Recorder Pods. Any annotations - // specified here will be merged with the default annotations applied to - // the Pod by the operator. - // https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - // +optional - Annotations map[string]string `json:"annotations,omitempty"` - - // Affinity rules for Recorder Pods. By default, the operator does not - // apply any affinity rules. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - // +optional - Affinity *corev1.Affinity `json:"affinity,omitempty"` - - // Configuration for the Recorder container running tailscale. - // +optional - Container RecorderContainer `json:"container,omitempty"` - - // Security context for Recorder Pods. By default, the operator does not - // apply any Pod security context. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - // +optional - SecurityContext *corev1.PodSecurityContext `json:"securityContext,omitempty"` - - // Image pull Secrets for Recorder Pods. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - // +optional - ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` - - // Node selector rules for Recorder Pods. By default, the operator does - // not apply any node selector rules. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - // +optional - NodeSelector map[string]string `json:"nodeSelector,omitempty"` - - // Tolerations for Recorder Pods. By default, the operator does not apply - // any tolerations. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - // +optional - Tolerations []corev1.Toleration `json:"tolerations,omitempty"` -} - -type RecorderContainer struct { - // List of environment variables to set in the container. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - // Note that environment variables provided here will take precedence - // over Tailscale-specific environment variables set by the operator, - // however running proxies with custom values for Tailscale environment - // variables (i.e TS_USERSPACE) is not recommended and might break in - // the future. - // +optional - Env []Env `json:"env,omitempty"` - - // Container image name including tag. Defaults to docker.io/tailscale/tsrecorder - // with the same tag as the operator, but the official images are also - // available at ghcr.io/tailscale/tsrecorder. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - // +optional - Image string `json:"image,omitempty"` - - // Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - // +kubebuilder:validation:Enum=Always;Never;IfNotPresent - // +optional - ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` - - // Container resource requirements. - // By default, the operator does not apply any resource requirements. The - // amount of resources required wil depend on the volume of recordings sent. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - - // Container security context. By default, the operator does not apply any - // container security context. - // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - // +optional - SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"` -} - -type Storage struct { - // Configure an S3-compatible API for storage. Required if the UI is not - // enabled, to ensure that recordings are accessible. - // +optional - S3 *S3 `json:"s3,omitempty"` -} - -type S3 struct { - // S3-compatible endpoint, e.g. s3.us-east-1.amazonaws.com. - Endpoint string `json:"endpoint,omitempty"` - - // Bucket name to write to. The bucket is expected to be used solely for - // recordings, as there is no stable prefix for written object names. - Bucket string `json:"bucket,omitempty"` - - // Configure environment variable credentials for managing objects in the - // configured bucket. If not set, tsrecorder will try to acquire credentials - // first from the file system and then the STS API. - // +optional - Credentials S3Credentials `json:"credentials,omitempty"` -} - -type S3Credentials struct { - // Use a Kubernetes Secret from the operator's namespace as the source of - // credentials. - // +optional - Secret S3Secret `json:"secret,omitempty"` -} - -type S3Secret struct { - // The name of a Kubernetes Secret in the operator's namespace that contains - // credentials for writing to the configured bucket. Each key-value pair - // from the secret's data will be mounted as an environment variable. It - // should include keys for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY if - // using a static access key. - //+optional - Name string `json:"name,omitempty"` -} - -type RecorderStatus struct { - // List of status conditions to indicate the status of the Recorder. - // Known condition types are `RecorderReady`. - // +listType=map - // +listMapKey=type - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` - - // List of tailnet devices associated with the Recorder StatefulSet. - // +listType=map - // +listMapKey=hostname - // +optional - Devices []RecorderTailnetDevice `json:"devices,omitempty"` -} - -type RecorderTailnetDevice struct { - // Hostname is the fully qualified domain name of the device. - // If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - // node. - Hostname string `json:"hostname"` - - // TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - // assigned to the device. - // +optional - TailnetIPs []string `json:"tailnetIPs,omitempty"` - - // URL where the UI is available if enabled for replaying recordings. This - // will be an HTTPS MagicDNS URL. You must be connected to the same tailnet - // as the recorder to access it. - // +optional - URL string `json:"url,omitempty"` -} diff --git a/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go b/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go deleted file mode 100644 index 60d212279f4f5..0000000000000 --- a/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// Code comments on these types should be treated as user facing documentation- -// they will appear on the DNSConfig CRD i.e if someone runs kubectl explain dnsconfig. - -var DNSConfigKind = "DNSConfig" - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:scope=Cluster,shortName=dc -// +kubebuilder:printcolumn:name="NameserverIP",type="string",JSONPath=`.status.nameserver.ip`,description="Service IP address of the nameserver" - -// DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS -// names resolvable by cluster workloads. Use this if: A) you need to refer to -// tailnet services, exposed to cluster via Tailscale Kubernetes operator egress -// proxies by the MagicDNS names of those tailnet services (usually because the -// services run over HTTPS) -// B) you have exposed a cluster workload to the tailnet using Tailscale Ingress -// and you also want to refer to the workload from within the cluster over the -// Ingress's MagicDNS name (usually because you have some callback component -// that needs to use the same URL as that used by a non-cluster client on -// tailnet). -// When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will -// deploy a nameserver for ts.net DNS names and automatically populate it with records -// for any Tailscale egress or Ingress proxies deployed to that cluster. -// Currently you must manually update your cluster DNS configuration to add the -// IP address of the deployed nameserver as a ts.net stub nameserver. -// Instructions for how to do it: -// https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), -// https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). -// Tailscale Kubernetes operator will write the address of a Service fronting -// the nameserver to dsnconfig.status.nameserver.ip. -// DNSConfig is a singleton - you must not create more than one. -// NB: if you want cluster workloads to be able to refer to Tailscale Ingress -// using its MagicDNS name, you must also annotate the Ingress resource with -// tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to -// ensure that the proxy created for the Ingress listens on its Pod IP address. -// NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported. -type DNSConfig struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - // Spec describes the desired DNS configuration. - // More info: - // https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - Spec DNSConfigSpec `json:"spec"` - - // Status describes the status of the DNSConfig. This is set - // and managed by the Tailscale operator. - // +optional - Status DNSConfigStatus `json:"status"` -} - -// +kubebuilder:object:root=true - -type DNSConfigList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata"` - - Items []DNSConfig `json:"items"` -} - -type DNSConfigSpec struct { - // Configuration for a nameserver that can resolve ts.net DNS names - // associated with in-cluster proxies for Tailscale egress Services and - // Tailscale Ingresses. The operator will always deploy this nameserver - // when a DNSConfig is applied. - Nameserver *Nameserver `json:"nameserver"` -} - -type Nameserver struct { - // Nameserver image. Defaults to tailscale/k8s-nameserver:unstable. - // +optional - Image *NameserverImage `json:"image,omitempty"` -} - -type NameserverImage struct { - // Repo defaults to tailscale/k8s-nameserver. - // +optional - Repo string `json:"repo,omitempty"` - // Tag defaults to unstable. - // +optional - Tag string `json:"tag,omitempty"` -} - -type DNSConfigStatus struct { - // +listType=map - // +listMapKey=type - // +optional - Conditions []metav1.Condition `json:"conditions"` - // Nameserver describes the status of nameserver cluster resources. - // +optional - Nameserver *NameserverStatus `json:"nameserver"` -} - -type NameserverStatus struct { - // IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. - // Currently you must manually update your cluster DNS config to add - // this address as a stub nameserver for ts.net for cluster workloads to be - // able to resolve MagicDNS names associated with egress or Ingress - // proxies. - // The IP address will change if you delete and recreate the DNSConfig. - // +optional - IP string `json:"ip"` -} - -// NameserverReady is set to True if the nameserver has been successfully -// deployed to cluster. -const NameserverReady ConditionType = `NameserverReady` diff --git a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go b/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go deleted file mode 100644 index c2f69dc045314..0000000000000 --- a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go +++ /dev/null @@ -1,1100 +0,0 @@ -//go:build !ignore_autogenerated && !plan9 - -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by controller-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" -) - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AppConnector) DeepCopyInto(out *AppConnector) { - *out = *in - if in.Routes != nil { - in, out := &in.Routes, &out.Routes - *out = make(Routes, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppConnector. -func (in *AppConnector) DeepCopy() *AppConnector { - if in == nil { - return nil - } - out := new(AppConnector) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Connector) DeepCopyInto(out *Connector) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Connector. -func (in *Connector) DeepCopy() *Connector { - if in == nil { - return nil - } - out := new(Connector) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *Connector) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ConnectorList) DeepCopyInto(out *ConnectorList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]Connector, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorList. -func (in *ConnectorList) DeepCopy() *ConnectorList { - if in == nil { - return nil - } - out := new(ConnectorList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ConnectorList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ConnectorSpec) DeepCopyInto(out *ConnectorSpec) { - *out = *in - if in.Tags != nil { - in, out := &in.Tags, &out.Tags - *out = make(Tags, len(*in)) - copy(*out, *in) - } - if in.SubnetRouter != nil { - in, out := &in.SubnetRouter, &out.SubnetRouter - *out = new(SubnetRouter) - (*in).DeepCopyInto(*out) - } - if in.AppConnector != nil { - in, out := &in.AppConnector, &out.AppConnector - *out = new(AppConnector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorSpec. -func (in *ConnectorSpec) DeepCopy() *ConnectorSpec { - if in == nil { - return nil - } - out := new(ConnectorSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ConnectorStatus) DeepCopyInto(out *ConnectorStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.TailnetIPs != nil { - in, out := &in.TailnetIPs, &out.TailnetIPs - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorStatus. -func (in *ConnectorStatus) DeepCopy() *ConnectorStatus { - if in == nil { - return nil - } - out := new(ConnectorStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Container) DeepCopyInto(out *Container) { - *out = *in - if in.Env != nil { - in, out := &in.Env, &out.Env - *out = make([]Env, len(*in)) - copy(*out, *in) - } - in.Resources.DeepCopyInto(&out.Resources) - if in.SecurityContext != nil { - in, out := &in.SecurityContext, &out.SecurityContext - *out = new(corev1.SecurityContext) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Container. -func (in *Container) DeepCopy() *Container { - if in == nil { - return nil - } - out := new(Container) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DNSConfig) DeepCopyInto(out *DNSConfig) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfig. -func (in *DNSConfig) DeepCopy() *DNSConfig { - if in == nil { - return nil - } - out := new(DNSConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DNSConfig) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DNSConfigList) DeepCopyInto(out *DNSConfigList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]DNSConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigList. -func (in *DNSConfigList) DeepCopy() *DNSConfigList { - if in == nil { - return nil - } - out := new(DNSConfigList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DNSConfigList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DNSConfigSpec) DeepCopyInto(out *DNSConfigSpec) { - *out = *in - if in.Nameserver != nil { - in, out := &in.Nameserver, &out.Nameserver - *out = new(Nameserver) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigSpec. -func (in *DNSConfigSpec) DeepCopy() *DNSConfigSpec { - if in == nil { - return nil - } - out := new(DNSConfigSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DNSConfigStatus) DeepCopyInto(out *DNSConfigStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Nameserver != nil { - in, out := &in.Nameserver, &out.Nameserver - *out = new(NameserverStatus) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigStatus. -func (in *DNSConfigStatus) DeepCopy() *DNSConfigStatus { - if in == nil { - return nil - } - out := new(DNSConfigStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Env) DeepCopyInto(out *Env) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Env. -func (in *Env) DeepCopy() *Env { - if in == nil { - return nil - } - out := new(Env) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Metrics) DeepCopyInto(out *Metrics) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Metrics. -func (in *Metrics) DeepCopy() *Metrics { - if in == nil { - return nil - } - out := new(Metrics) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Nameserver) DeepCopyInto(out *Nameserver) { - *out = *in - if in.Image != nil { - in, out := &in.Image, &out.Image - *out = new(NameserverImage) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Nameserver. -func (in *Nameserver) DeepCopy() *Nameserver { - if in == nil { - return nil - } - out := new(Nameserver) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NameserverImage) DeepCopyInto(out *NameserverImage) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NameserverImage. -func (in *NameserverImage) DeepCopy() *NameserverImage { - if in == nil { - return nil - } - out := new(NameserverImage) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NameserverStatus) DeepCopyInto(out *NameserverStatus) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NameserverStatus. -func (in *NameserverStatus) DeepCopy() *NameserverStatus { - if in == nil { - return nil - } - out := new(NameserverStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Pod) DeepCopyInto(out *Pod) { - *out = *in - if in.Labels != nil { - in, out := &in.Labels, &out.Labels - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Annotations != nil { - in, out := &in.Annotations, &out.Annotations - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Affinity != nil { - in, out := &in.Affinity, &out.Affinity - *out = new(corev1.Affinity) - (*in).DeepCopyInto(*out) - } - if in.TailscaleContainer != nil { - in, out := &in.TailscaleContainer, &out.TailscaleContainer - *out = new(Container) - (*in).DeepCopyInto(*out) - } - if in.TailscaleInitContainer != nil { - in, out := &in.TailscaleInitContainer, &out.TailscaleInitContainer - *out = new(Container) - (*in).DeepCopyInto(*out) - } - if in.SecurityContext != nil { - in, out := &in.SecurityContext, &out.SecurityContext - *out = new(corev1.PodSecurityContext) - (*in).DeepCopyInto(*out) - } - if in.ImagePullSecrets != nil { - in, out := &in.ImagePullSecrets, &out.ImagePullSecrets - *out = make([]corev1.LocalObjectReference, len(*in)) - copy(*out, *in) - } - if in.NodeSelector != nil { - in, out := &in.NodeSelector, &out.NodeSelector - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Tolerations != nil { - in, out := &in.Tolerations, &out.Tolerations - *out = make([]corev1.Toleration, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.TopologySpreadConstraints != nil { - in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints - *out = make([]corev1.TopologySpreadConstraint, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Pod. -func (in *Pod) DeepCopy() *Pod { - if in == nil { - return nil - } - out := new(Pod) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyClass) DeepCopyInto(out *ProxyClass) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyClass. -func (in *ProxyClass) DeepCopy() *ProxyClass { - if in == nil { - return nil - } - out := new(ProxyClass) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ProxyClass) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyClassList) DeepCopyInto(out *ProxyClassList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]ProxyClass, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyClassList. -func (in *ProxyClassList) DeepCopy() *ProxyClassList { - if in == nil { - return nil - } - out := new(ProxyClassList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ProxyClassList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyClassSpec) DeepCopyInto(out *ProxyClassSpec) { - *out = *in - if in.StatefulSet != nil { - in, out := &in.StatefulSet, &out.StatefulSet - *out = new(StatefulSet) - (*in).DeepCopyInto(*out) - } - if in.Metrics != nil { - in, out := &in.Metrics, &out.Metrics - *out = new(Metrics) - **out = **in - } - if in.TailscaleConfig != nil { - in, out := &in.TailscaleConfig, &out.TailscaleConfig - *out = new(TailscaleConfig) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyClassSpec. -func (in *ProxyClassSpec) DeepCopy() *ProxyClassSpec { - if in == nil { - return nil - } - out := new(ProxyClassSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyClassStatus) DeepCopyInto(out *ProxyClassStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyClassStatus. -func (in *ProxyClassStatus) DeepCopy() *ProxyClassStatus { - if in == nil { - return nil - } - out := new(ProxyClassStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyGroup) DeepCopyInto(out *ProxyGroup) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyGroup. -func (in *ProxyGroup) DeepCopy() *ProxyGroup { - if in == nil { - return nil - } - out := new(ProxyGroup) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ProxyGroup) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyGroupList) DeepCopyInto(out *ProxyGroupList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]ProxyGroup, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyGroupList. -func (in *ProxyGroupList) DeepCopy() *ProxyGroupList { - if in == nil { - return nil - } - out := new(ProxyGroupList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *ProxyGroupList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyGroupSpec) DeepCopyInto(out *ProxyGroupSpec) { - *out = *in - if in.Tags != nil { - in, out := &in.Tags, &out.Tags - *out = make(Tags, len(*in)) - copy(*out, *in) - } - if in.Replicas != nil { - in, out := &in.Replicas, &out.Replicas - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyGroupSpec. -func (in *ProxyGroupSpec) DeepCopy() *ProxyGroupSpec { - if in == nil { - return nil - } - out := new(ProxyGroupSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyGroupStatus) DeepCopyInto(out *ProxyGroupStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Devices != nil { - in, out := &in.Devices, &out.Devices - *out = make([]TailnetDevice, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyGroupStatus. -func (in *ProxyGroupStatus) DeepCopy() *ProxyGroupStatus { - if in == nil { - return nil - } - out := new(ProxyGroupStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Recorder) DeepCopyInto(out *Recorder) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Recorder. -func (in *Recorder) DeepCopy() *Recorder { - if in == nil { - return nil - } - out := new(Recorder) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *Recorder) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RecorderContainer) DeepCopyInto(out *RecorderContainer) { - *out = *in - if in.Env != nil { - in, out := &in.Env, &out.Env - *out = make([]Env, len(*in)) - copy(*out, *in) - } - in.Resources.DeepCopyInto(&out.Resources) - if in.SecurityContext != nil { - in, out := &in.SecurityContext, &out.SecurityContext - *out = new(corev1.SecurityContext) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecorderContainer. -func (in *RecorderContainer) DeepCopy() *RecorderContainer { - if in == nil { - return nil - } - out := new(RecorderContainer) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RecorderList) DeepCopyInto(out *RecorderList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]Recorder, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecorderList. -func (in *RecorderList) DeepCopy() *RecorderList { - if in == nil { - return nil - } - out := new(RecorderList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *RecorderList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RecorderPod) DeepCopyInto(out *RecorderPod) { - *out = *in - if in.Labels != nil { - in, out := &in.Labels, &out.Labels - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Annotations != nil { - in, out := &in.Annotations, &out.Annotations - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Affinity != nil { - in, out := &in.Affinity, &out.Affinity - *out = new(corev1.Affinity) - (*in).DeepCopyInto(*out) - } - in.Container.DeepCopyInto(&out.Container) - if in.SecurityContext != nil { - in, out := &in.SecurityContext, &out.SecurityContext - *out = new(corev1.PodSecurityContext) - (*in).DeepCopyInto(*out) - } - if in.ImagePullSecrets != nil { - in, out := &in.ImagePullSecrets, &out.ImagePullSecrets - *out = make([]corev1.LocalObjectReference, len(*in)) - copy(*out, *in) - } - if in.NodeSelector != nil { - in, out := &in.NodeSelector, &out.NodeSelector - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Tolerations != nil { - in, out := &in.Tolerations, &out.Tolerations - *out = make([]corev1.Toleration, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecorderPod. -func (in *RecorderPod) DeepCopy() *RecorderPod { - if in == nil { - return nil - } - out := new(RecorderPod) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RecorderSpec) DeepCopyInto(out *RecorderSpec) { - *out = *in - in.StatefulSet.DeepCopyInto(&out.StatefulSet) - if in.Tags != nil { - in, out := &in.Tags, &out.Tags - *out = make(Tags, len(*in)) - copy(*out, *in) - } - in.Storage.DeepCopyInto(&out.Storage) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecorderSpec. -func (in *RecorderSpec) DeepCopy() *RecorderSpec { - if in == nil { - return nil - } - out := new(RecorderSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RecorderStatefulSet) DeepCopyInto(out *RecorderStatefulSet) { - *out = *in - if in.Labels != nil { - in, out := &in.Labels, &out.Labels - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Annotations != nil { - in, out := &in.Annotations, &out.Annotations - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - in.Pod.DeepCopyInto(&out.Pod) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecorderStatefulSet. -func (in *RecorderStatefulSet) DeepCopy() *RecorderStatefulSet { - if in == nil { - return nil - } - out := new(RecorderStatefulSet) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RecorderStatus) DeepCopyInto(out *RecorderStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Devices != nil { - in, out := &in.Devices, &out.Devices - *out = make([]RecorderTailnetDevice, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecorderStatus. -func (in *RecorderStatus) DeepCopy() *RecorderStatus { - if in == nil { - return nil - } - out := new(RecorderStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RecorderTailnetDevice) DeepCopyInto(out *RecorderTailnetDevice) { - *out = *in - if in.TailnetIPs != nil { - in, out := &in.TailnetIPs, &out.TailnetIPs - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecorderTailnetDevice. -func (in *RecorderTailnetDevice) DeepCopy() *RecorderTailnetDevice { - if in == nil { - return nil - } - out := new(RecorderTailnetDevice) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in Routes) DeepCopyInto(out *Routes) { - { - in := &in - *out = make(Routes, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Routes. -func (in Routes) DeepCopy() Routes { - if in == nil { - return nil - } - out := new(Routes) - in.DeepCopyInto(out) - return *out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *S3) DeepCopyInto(out *S3) { - *out = *in - out.Credentials = in.Credentials -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3. -func (in *S3) DeepCopy() *S3 { - if in == nil { - return nil - } - out := new(S3) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *S3Credentials) DeepCopyInto(out *S3Credentials) { - *out = *in - out.Secret = in.Secret -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3Credentials. -func (in *S3Credentials) DeepCopy() *S3Credentials { - if in == nil { - return nil - } - out := new(S3Credentials) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *S3Secret) DeepCopyInto(out *S3Secret) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3Secret. -func (in *S3Secret) DeepCopy() *S3Secret { - if in == nil { - return nil - } - out := new(S3Secret) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *StatefulSet) DeepCopyInto(out *StatefulSet) { - *out = *in - if in.Labels != nil { - in, out := &in.Labels, &out.Labels - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Annotations != nil { - in, out := &in.Annotations, &out.Annotations - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Pod != nil { - in, out := &in.Pod, &out.Pod - *out = new(Pod) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatefulSet. -func (in *StatefulSet) DeepCopy() *StatefulSet { - if in == nil { - return nil - } - out := new(StatefulSet) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Storage) DeepCopyInto(out *Storage) { - *out = *in - if in.S3 != nil { - in, out := &in.S3, &out.S3 - *out = new(S3) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Storage. -func (in *Storage) DeepCopy() *Storage { - if in == nil { - return nil - } - out := new(Storage) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SubnetRouter) DeepCopyInto(out *SubnetRouter) { - *out = *in - if in.AdvertiseRoutes != nil { - in, out := &in.AdvertiseRoutes, &out.AdvertiseRoutes - *out = make(Routes, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubnetRouter. -func (in *SubnetRouter) DeepCopy() *SubnetRouter { - if in == nil { - return nil - } - out := new(SubnetRouter) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in Tags) DeepCopyInto(out *Tags) { - { - in := &in - *out = make(Tags, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Tags. -func (in Tags) DeepCopy() Tags { - if in == nil { - return nil - } - out := new(Tags) - in.DeepCopyInto(out) - return *out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TailnetDevice) DeepCopyInto(out *TailnetDevice) { - *out = *in - if in.TailnetIPs != nil { - in, out := &in.TailnetIPs, &out.TailnetIPs - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TailnetDevice. -func (in *TailnetDevice) DeepCopy() *TailnetDevice { - if in == nil { - return nil - } - out := new(TailnetDevice) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TailscaleConfig) DeepCopyInto(out *TailscaleConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TailscaleConfig. -func (in *TailscaleConfig) DeepCopy() *TailscaleConfig { - if in == nil { - return nil - } - out := new(TailscaleConfig) - in.DeepCopyInto(out) - return out -} diff --git a/k8s-operator/conditions.go b/k8s-operator/conditions.go deleted file mode 100644 index ace0fb7e33a75..0000000000000 --- a/k8s-operator/conditions.go +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package kube - -import ( - "slices" - "time" - - "go.uber.org/zap" - xslices "golang.org/x/exp/slices" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstime" -) - -// SetConnectorCondition ensures that Connector status has a condition with the -// given attributes. LastTransitionTime gets set every time condition's status -// changes. -func SetConnectorCondition(cn *tsapi.Connector, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { - conds := updateCondition(cn.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) - cn.Status.Conditions = conds -} - -// RemoveConnectorCondition will remove condition of the given type if it exists. -func RemoveConnectorCondition(conn *tsapi.Connector, conditionType tsapi.ConditionType) { - conn.Status.Conditions = slices.DeleteFunc(conn.Status.Conditions, func(cond metav1.Condition) bool { - return cond.Type == string(conditionType) - }) -} - -// SetProxyClassCondition ensures that ProxyClass status has a condition with the -// given attributes. LastTransitionTime gets set every time condition's status -// changes. -func SetProxyClassCondition(pc *tsapi.ProxyClass, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { - conds := updateCondition(pc.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) - pc.Status.Conditions = conds -} - -// SetDNSConfigCondition ensures that DNSConfig status has a condition with the -// given attributes. LastTransitionTime gets set every time condition's status -// changes -func SetDNSConfigCondition(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { - conds := updateCondition(dnsCfg.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) - dnsCfg.Status.Conditions = conds -} - -// SetServiceCondition ensures that Service status has a condition with the -// given attributes. LastTransitionTime gets set every time condition's status -// changes. -func SetServiceCondition(svc *corev1.Service, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, clock tstime.Clock, logger *zap.SugaredLogger) { - conds := updateCondition(svc.Status.Conditions, conditionType, status, reason, message, 0, clock, logger) - svc.Status.Conditions = conds -} - -// GetServiceCondition returns Service condition with the specified type, if it exists on the Service. -func GetServiceCondition(svc *corev1.Service, conditionType tsapi.ConditionType) *metav1.Condition { - idx := xslices.IndexFunc(svc.Status.Conditions, func(cond metav1.Condition) bool { - return cond.Type == string(conditionType) - }) - - if idx == -1 { - return nil - } - return &svc.Status.Conditions[idx] -} - -// RemoveServiceCondition will remove condition of the given type if it exists. -func RemoveServiceCondition(svc *corev1.Service, conditionType tsapi.ConditionType) { - svc.Status.Conditions = slices.DeleteFunc(svc.Status.Conditions, func(cond metav1.Condition) bool { - return cond.Type == string(conditionType) - }) -} - -func EgressServiceIsValidAndConfigured(svc *corev1.Service) bool { - for _, typ := range []tsapi.ConditionType{tsapi.EgressSvcValid, tsapi.EgressSvcConfigured} { - cond := GetServiceCondition(svc, typ) - if cond == nil || cond.Status != metav1.ConditionTrue { - return false - } - } - return true -} - -// SetRecorderCondition ensures that Recorder status has a condition with the -// given attributes. LastTransitionTime gets set every time condition's status -// changes. -func SetRecorderCondition(tsr *tsapi.Recorder, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { - conds := updateCondition(tsr.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) - tsr.Status.Conditions = conds -} - -// SetProxyGroupCondition ensures that ProxyGroup status has a condition with the -// given attributes. LastTransitionTime gets set every time condition's status -// changes. -func SetProxyGroupCondition(pg *tsapi.ProxyGroup, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { - conds := updateCondition(pg.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) - pg.Status.Conditions = conds -} - -func updateCondition(conds []metav1.Condition, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []metav1.Condition { - newCondition := metav1.Condition{ - Type: string(conditionType), - Status: status, - Reason: reason, - Message: message, - ObservedGeneration: gen, - } - - nowTime := metav1.NewTime(clock.Now().Truncate(time.Second)) - newCondition.LastTransitionTime = nowTime - - idx := xslices.IndexFunc(conds, func(cond metav1.Condition) bool { - return cond.Type == string(conditionType) - }) - - if idx == -1 { - conds = append(conds, newCondition) - return conds - } - - cond := conds[idx] // update the existing condition - - // If this update doesn't contain a state transition, don't update last - // transition time. - if cond.Status == status { - newCondition.LastTransitionTime = cond.LastTransitionTime - } else { - logger.Infof("Status change for condition %s from %s to %s", conditionType, cond.Status, status) - } - conds[idx] = newCondition - return conds -} - -func ProxyClassIsReady(pc *tsapi.ProxyClass) bool { - idx := xslices.IndexFunc(pc.Status.Conditions, func(cond metav1.Condition) bool { - return cond.Type == string(tsapi.ProxyClassReady) - }) - if idx == -1 { - return false - } - cond := pc.Status.Conditions[idx] - return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pc.Generation -} - -func ProxyGroupIsReady(pg *tsapi.ProxyGroup) bool { - idx := xslices.IndexFunc(pg.Status.Conditions, func(cond metav1.Condition) bool { - return cond.Type == string(tsapi.ProxyGroupReady) - }) - if idx == -1 { - return false - } - cond := pg.Status.Conditions[idx] - return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pg.Generation -} - -func DNSCfgIsReady(cfg *tsapi.DNSConfig) bool { - idx := xslices.IndexFunc(cfg.Status.Conditions, func(cond metav1.Condition) bool { - return cond.Type == string(tsapi.NameserverReady) - }) - if idx == -1 { - return false - } - cond := cfg.Status.Conditions[idx] - return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == cfg.Generation -} diff --git a/k8s-operator/conditions_test.go b/k8s-operator/conditions_test.go deleted file mode 100644 index 7eb65257d3414..0000000000000 --- a/k8s-operator/conditions_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package kube - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "go.uber.org/zap" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - tsapi "tailscale.com/k8s-operator/apis/v1alpha1" - "tailscale.com/tstest" -) - -func TestSetConnectorCondition(t *testing.T) { - cn := tsapi.Connector{} - clock := tstest.NewClock(tstest.ClockOpts{}) - fakeNow := metav1.NewTime(clock.Now().Truncate(time.Second)) - fakePast := metav1.NewTime(clock.Now().Truncate(time.Second).Add(-5 * time.Minute)) - zl, err := zap.NewDevelopment() - assert.Nil(t, err) - - // Set up a new condition - SetConnectorCondition(&cn, tsapi.ConnectorReady, metav1.ConditionTrue, "someReason", "someMsg", 1, clock, zl.Sugar()) - assert.Equal(t, cn, tsapi.Connector{ - Status: tsapi.ConnectorStatus{ - Conditions: []metav1.Condition{ - { - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionTrue, - Reason: "someReason", - Message: "someMsg", - ObservedGeneration: 1, - LastTransitionTime: fakeNow, - }, - }, - }, - }) - - // Modify status of an existing condition - cn.Status = tsapi.ConnectorStatus{ - Conditions: []metav1.Condition{ - { - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionFalse, - Reason: "someReason", - Message: "someMsg", - ObservedGeneration: 1, - LastTransitionTime: fakePast, - }, - }, - } - SetConnectorCondition(&cn, tsapi.ConnectorReady, metav1.ConditionTrue, "anotherReason", "anotherMsg", 2, clock, zl.Sugar()) - assert.Equal(t, cn, tsapi.Connector{ - Status: tsapi.ConnectorStatus{ - Conditions: []metav1.Condition{ - { - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionTrue, - Reason: "anotherReason", - Message: "anotherMsg", - ObservedGeneration: 2, - LastTransitionTime: fakeNow, - }, - }, - }, - }) - - // Don't modify last transition time if status hasn't changed - cn.Status = tsapi.ConnectorStatus{ - Conditions: []metav1.Condition{ - { - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionTrue, - Reason: "someReason", - Message: "someMsg", - ObservedGeneration: 1, - LastTransitionTime: fakePast, - }, - }, - } - SetConnectorCondition(&cn, tsapi.ConnectorReady, metav1.ConditionTrue, "anotherReason", "anotherMsg", 2, clock, zl.Sugar()) - assert.Equal(t, cn, tsapi.Connector{ - Status: tsapi.ConnectorStatus{ - Conditions: []metav1.Condition{ - { - Type: string(tsapi.ConnectorReady), - Status: metav1.ConditionTrue, - Reason: "anotherReason", - Message: "anotherMsg", - ObservedGeneration: 2, - LastTransitionTime: fakePast, - }, - }, - }, - }) -} diff --git a/k8s-operator/sessionrecording/fakes/fakes.go b/k8s-operator/sessionrecording/fakes/fakes.go deleted file mode 100644 index 9eb1047e4242f..0000000000000 --- a/k8s-operator/sessionrecording/fakes/fakes.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// Package fakes contains mocks used for testing 'kubectl exec' session -// recording functionality. -package fakes - -import ( - "bytes" - "encoding/json" - "net" - "sync" - "testing" - "time" - - "math/rand" - - "tailscale.com/sessionrecording" - "tailscale.com/tstime" -) - -func New(conn net.Conn, wb bytes.Buffer, rb bytes.Buffer, closed bool) net.Conn { - return &TestConn{ - Conn: conn, - writeBuf: wb, - readBuf: rb, - closed: closed, - } -} - -type TestConn struct { - net.Conn - // writeBuf contains whatever was send to the conn via Write. - writeBuf bytes.Buffer - // readBuf contains whatever was sent to the conn via Read. - readBuf bytes.Buffer - sync.RWMutex // protects the following - closed bool -} - -var _ net.Conn = &TestConn{} - -func (tc *TestConn) Read(b []byte) (int, error) { - return tc.readBuf.Read(b) -} - -func (tc *TestConn) Write(b []byte) (int, error) { - return tc.writeBuf.Write(b) -} - -func (tc *TestConn) Close() error { - tc.Lock() - defer tc.Unlock() - tc.closed = true - return nil -} - -func (tc *TestConn) IsClosed() bool { - tc.Lock() - defer tc.Unlock() - return tc.closed -} - -func (tc *TestConn) WriteBufBytes() []byte { - return tc.writeBuf.Bytes() -} - -func (tc *TestConn) ResetReadBuf() { - tc.readBuf.Reset() -} - -func (tc *TestConn) WriteReadBufBytes(b []byte) error { - _, err := tc.readBuf.Write(b) - return err -} - -type TestSessionRecorder struct { - // buf holds data that was sent to the session recorder. - buf bytes.Buffer -} - -func (t *TestSessionRecorder) Write(b []byte) (int, error) { - return t.buf.Write(b) -} - -func (t *TestSessionRecorder) Close() error { - t.buf.Reset() - return nil -} - -func (t *TestSessionRecorder) Bytes() []byte { - return t.buf.Bytes() -} - -func CastLine(t *testing.T, p []byte, clock tstime.Clock) []byte { - t.Helper() - j, err := json.Marshal([]any{ - clock.Now().Sub(clock.Now()).Seconds(), - "o", - string(p), - }) - if err != nil { - t.Fatalf("error marshalling cast line: %v", err) - } - return append(j, '\n') -} - -func AsciinemaResizeMsg(t *testing.T, width, height int) []byte { - t.Helper() - ch := sessionrecording.CastHeader{ - Width: width, - Height: height, - } - bs, err := json.Marshal(ch) - if err != nil { - t.Fatalf("error marshalling CastHeader: %v", err) - } - return append(bs, '\n') -} - -func RandomBytes(t *testing.T) [][]byte { - t.Helper() - r := rand.New(rand.NewSource(time.Now().UnixNano())) - n := r.Intn(4096) - b := make([]byte, n) - t.Logf("RandomBytes: generating byte slice of length %d", n) - _, err := r.Read(b) - if err != nil { - t.Fatalf("error generating random byte slice: %v", err) - } - if len(b) < 2 { - return [][]byte{b} - } - split := r.Intn(len(b) - 1) - return [][]byte{b[:split], b[split:]} -} diff --git a/k8s-operator/sessionrecording/hijacker.go b/k8s-operator/sessionrecording/hijacker.go deleted file mode 100644 index 43aa14e613887..0000000000000 --- a/k8s-operator/sessionrecording/hijacker.go +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// Package sessionrecording contains functionality for recording Kubernetes API -// server proxy 'kubectl exec' sessions. -package sessionrecording - -import ( - "bufio" - "bytes" - "context" - "fmt" - "io" - "net" - "net/http" - "net/http/httptrace" - "net/netip" - "strings" - - "github.com/pkg/errors" - "go.uber.org/zap" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/k8s-operator/sessionrecording/spdy" - "tailscale.com/k8s-operator/sessionrecording/tsrecorder" - "tailscale.com/k8s-operator/sessionrecording/ws" - "tailscale.com/sessionrecording" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" - "tailscale.com/tstime" - "tailscale.com/util/clientmetric" - "tailscale.com/util/multierr" -) - -const ( - SPDYProtocol Protocol = "SPDY" - WSProtocol Protocol = "WebSocket" -) - -// Protocol is the streaming protocol of the hijacked session. Supported -// protocols are SPDY and WebSocket. -type Protocol string - -var ( - // CounterSessionRecordingsAttempted counts the number of session recording attempts. - CounterSessionRecordingsAttempted = clientmetric.NewCounter("k8s_auth_proxy_session_recordings_attempted") - - // counterSessionRecordingsUploaded counts the number of successfully uploaded session recordings. - counterSessionRecordingsUploaded = clientmetric.NewCounter("k8s_auth_proxy_session_recordings_uploaded") -) - -func New(opts HijackerOpts) *Hijacker { - return &Hijacker{ - ts: opts.TS, - req: opts.Req, - who: opts.Who, - ResponseWriter: opts.W, - pod: opts.Pod, - ns: opts.Namespace, - addrs: opts.Addrs, - failOpen: opts.FailOpen, - proto: opts.Proto, - log: opts.Log, - connectToRecorder: sessionrecording.ConnectToRecorder, - } -} - -type HijackerOpts struct { - TS *tsnet.Server - Req *http.Request - W http.ResponseWriter - Who *apitype.WhoIsResponse - Addrs []netip.AddrPort - Log *zap.SugaredLogger - Pod string - Namespace string - FailOpen bool - Proto Protocol -} - -// Hijacker implements [net/http.Hijacker] interface. -// It must be configured with an http request for a 'kubectl exec' session that -// needs to be recorded. It knows how to hijack the connection and configure for -// the session contents to be sent to a tsrecorder instance. -type Hijacker struct { - http.ResponseWriter - ts *tsnet.Server - req *http.Request - who *apitype.WhoIsResponse - log *zap.SugaredLogger - pod string // pod being exec-d - ns string // namespace of the pod being exec-d - addrs []netip.AddrPort // tsrecorder addresses - failOpen bool // whether to fail open if recording fails - connectToRecorder RecorderDialFn - proto Protocol // streaming protocol -} - -// RecorderDialFn dials the specified netip.AddrPorts that should be tsrecorder -// addresses. It tries to connect to recorder endpoints one by one, till one -// connection succeeds. In case of success, returns a list with a single -// successful recording attempt and an error channel. If the connection errors -// after having been established, an error is sent down the channel. -type RecorderDialFn func(context.Context, []netip.AddrPort, sessionrecording.DialFunc) (io.WriteCloser, []*tailcfg.SSHRecordingAttempt, <-chan error, error) - -// Hijack hijacks a 'kubectl exec' session and configures for the session -// contents to be sent to a recorder. -func (h *Hijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { - h.log.Infof("recorder addrs: %v, failOpen: %v", h.addrs, h.failOpen) - reqConn, brw, err := h.ResponseWriter.(http.Hijacker).Hijack() - if err != nil { - return nil, nil, fmt.Errorf("error hijacking connection: %w", err) - } - - conn, err := h.setUpRecording(context.Background(), reqConn) - if err != nil { - return nil, nil, fmt.Errorf("error setting up session recording: %w", err) - } - return conn, brw, nil -} - -// setupRecording attempts to connect to the recorders set via -// spdyHijacker.addrs. Returns conn from provided opts, wrapped in recording -// logic. If connecting to the recorder fails or an error is received during the -// session and spdyHijacker.failOpen is false, connection will be closed. -func (h *Hijacker) setUpRecording(ctx context.Context, conn net.Conn) (net.Conn, error) { - const ( - // https://docs.asciinema.org/manual/asciicast/v2/ - asciicastv2 = 2 - ttyKey = "tty" - commandKey = "command" - containerKey = "container" - ) - var ( - wc io.WriteCloser - err error - errChan <-chan error - ) - h.log.Infof("kubectl exec session will be recorded, recorders: %v, fail open policy: %t", h.addrs, h.failOpen) - qp := h.req.URL.Query() - container := strings.Join(qp[containerKey], "") - var recorderAddr net.Addr - trace := &httptrace.ClientTrace{ - GotConn: func(info httptrace.GotConnInfo) { - recorderAddr = info.Conn.RemoteAddr() - }, - } - wc, _, errChan, err = h.connectToRecorder(httptrace.WithClientTrace(ctx, trace), h.addrs, h.ts.Dial) - if err != nil { - msg := fmt.Sprintf("error connecting to session recorders: %v", err) - if h.failOpen { - msg = msg + "; failure mode is 'fail open'; continuing session without recording." - h.log.Warnf(msg) - return conn, nil - } - msg = msg + "; failure mode is 'fail closed'; closing connection." - if err := closeConnWithWarning(conn, msg); err != nil { - return nil, multierr.New(errors.New(msg), err) - } - return nil, errors.New(msg) - } else { - h.log.Infof("exec session to container %q in Pod %q namespace %q will be recorded, the recording will be sent to a tsrecorder instance at %q", container, h.pod, h.ns, recorderAddr) - } - - cl := tstime.DefaultClock{} - rec := tsrecorder.New(wc, cl, cl.Now(), h.failOpen, h.log) - tty := strings.Join(qp[ttyKey], "") - hasTerm := (tty == "true") // session has terminal attached - ch := sessionrecording.CastHeader{ - Version: asciicastv2, - Timestamp: cl.Now().Unix(), - Command: strings.Join(qp[commandKey], " "), - SrcNode: strings.TrimSuffix(h.who.Node.Name, "."), - SrcNodeID: h.who.Node.StableID, - Kubernetes: &sessionrecording.Kubernetes{ - PodName: h.pod, - Namespace: h.ns, - Container: container, - }, - } - if !h.who.Node.IsTagged() { - ch.SrcNodeUser = h.who.UserProfile.LoginName - ch.SrcNodeUserID = h.who.Node.User - } else { - ch.SrcNodeTags = h.who.Node.Tags - } - - var lc net.Conn - switch h.proto { - case SPDYProtocol: - lc = spdy.New(conn, rec, ch, hasTerm, h.log) - case WSProtocol: - lc = ws.New(conn, rec, ch, hasTerm, h.log) - default: - return nil, fmt.Errorf("unknown protocol: %s", h.proto) - } - - go func() { - var err error - select { - case <-ctx.Done(): - return - case err = <-errChan: - } - if err == nil { - counterSessionRecordingsUploaded.Add(1) - h.log.Info("finished uploading the recording") - return - } - msg := fmt.Sprintf("connection to the session recorder errorred: %v;", err) - if h.failOpen { - msg += msg + "; failure mode is 'fail open'; continuing session without recording." - h.log.Info(msg) - return - } - msg += "; failure mode set to 'fail closed'; closing connection" - h.log.Error(msg) - // TODO (irbekrm): write a message to the client - if err := lc.Close(); err != nil { - h.log.Infof("error closing recorder connections: %v", err) - } - return - }() - return lc, nil -} - -func closeConnWithWarning(conn net.Conn, msg string) error { - b := io.NopCloser(bytes.NewBuffer([]byte(msg))) - resp := http.Response{Status: http.StatusText(http.StatusForbidden), StatusCode: http.StatusForbidden, Body: b} - if err := resp.Write(conn); err != nil { - return multierr.New(fmt.Errorf("error writing msg %q to conn: %v", msg, err), conn.Close()) - } - return conn.Close() -} diff --git a/k8s-operator/sessionrecording/hijacker_test.go b/k8s-operator/sessionrecording/hijacker_test.go deleted file mode 100644 index e166ce63b3c85..0000000000000 --- a/k8s-operator/sessionrecording/hijacker_test.go +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package sessionrecording - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/netip" - "net/url" - "testing" - "time" - - "go.uber.org/zap" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/k8s-operator/sessionrecording/fakes" - "tailscale.com/sessionrecording" - "tailscale.com/tailcfg" - "tailscale.com/tsnet" - "tailscale.com/tstest" -) - -func Test_Hijacker(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tests := []struct { - name string - failOpen bool - failRecorderConnect bool // fail initial connect to the recorder - failRecorderConnPostConnect bool // send error down the error channel - wantsConnClosed bool - wantsSetupErr bool - proto Protocol - }{ - { - name: "setup_succeeds_conn_stays_open", - proto: SPDYProtocol, - }, - { - name: "setup_succeeds_conn_stays_open_ws", - proto: WSProtocol, - }, - { - name: "setup_fails_policy_is_to_fail_open_conn_stays_open", - failOpen: true, - failRecorderConnect: true, - proto: SPDYProtocol, - }, - { - name: "setup_fails_policy_is_to_fail_closed_conn_is_closed", - failRecorderConnect: true, - wantsSetupErr: true, - wantsConnClosed: true, - proto: SPDYProtocol, - }, - { - name: "connection_fails_post-initial_connect_policy_is_to_fail_open_conn_stays_open", - failRecorderConnPostConnect: true, - failOpen: true, - proto: SPDYProtocol, - }, - { - name: "connection_fails_post-initial_connect,_policy_is_to_fail_closed_conn_is_closed", - failRecorderConnPostConnect: true, - wantsConnClosed: true, - proto: SPDYProtocol, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc := &fakes.TestConn{} - ch := make(chan error) - h := &Hijacker{ - connectToRecorder: func(context.Context, - []netip.AddrPort, - sessionrecording.DialFunc, - ) (wc io.WriteCloser, rec []*tailcfg.SSHRecordingAttempt, _ <-chan error, err error) { - if tt.failRecorderConnect { - err = errors.New("test") - } - return wc, rec, ch, err - }, - failOpen: tt.failOpen, - who: &apitype.WhoIsResponse{Node: &tailcfg.Node{}, UserProfile: &tailcfg.UserProfile{}}, - log: zl.Sugar(), - ts: &tsnet.Server{}, - req: &http.Request{URL: &url.URL{}}, - proto: tt.proto, - } - ctx := context.Background() - _, err := h.setUpRecording(ctx, tc) - if (err != nil) != tt.wantsSetupErr { - t.Errorf("spdyHijacker.setupRecording() error = %v, wantErr %v", err, tt.wantsSetupErr) - return - } - if tt.failRecorderConnPostConnect { - select { - case ch <- errors.New("err"): - case <-time.After(time.Second * 15): - t.Errorf("error from recorder conn was not read within 15 seconds") - } - } - timeout := time.Second * 20 - // TODO (irbekrm): cover case where an error is received - // over channel and the failure policy is to fail open - // (test that connection remains open over some period - // of time). - if err := tstest.WaitFor(timeout, func() (err error) { - if tt.wantsConnClosed != tc.IsClosed() { - return fmt.Errorf("got connection state: %t, wants connection state: %t", tc.IsClosed(), tt.wantsConnClosed) - } - return nil - }); err != nil { - t.Errorf("connection did not reach the desired state within %s", timeout.String()) - } - ctx.Done() - }) - } -} diff --git a/k8s-operator/sessionrecording/spdy/conn.go b/k8s-operator/sessionrecording/spdy/conn.go deleted file mode 100644 index 455c2225ad921..0000000000000 --- a/k8s-operator/sessionrecording/spdy/conn.go +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// Package spdy contains functionality for parsing SPDY streaming sessions. This -// is used for 'kubectl exec' session recording. -package spdy - -import ( - "bytes" - "encoding/binary" - "encoding/json" - "fmt" - "net" - "net/http" - "sync" - "sync/atomic" - - "go.uber.org/zap" - corev1 "k8s.io/api/core/v1" - "tailscale.com/k8s-operator/sessionrecording/tsrecorder" - "tailscale.com/sessionrecording" -) - -// New wraps the provided network connection and returns a connection whose reads and writes will get triggered as data is received on the hijacked connection. -// The connection must be a hijacked connection for a 'kubectl exec' session using SPDY. -// The hijacked connection is used to transmit SPDY streams between Kubernetes client ('kubectl') and the destination container. -// Data read from the underlying network connection is data sent via one of the SPDY streams from the client to the container. -// Data written to the underlying connection is data sent from the container to the client. -// We parse the data and send everything for the stdout/stderr streams to the configured tsrecorder as an asciinema recording with the provided header. -// https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/4006-transition-spdy-to-websockets#background-remotecommand-subprotocol -func New(nc net.Conn, rec *tsrecorder.Client, ch sessionrecording.CastHeader, hasTerm bool, log *zap.SugaredLogger) net.Conn { - return &conn{ - Conn: nc, - rec: rec, - ch: ch, - log: log, - hasTerm: hasTerm, - initialTermSizeSet: make(chan struct{}), - } -} - -// conn is a wrapper around net.Conn. It reads the bytestream for a 'kubectl -// exec' session streamed using SPDY protocol, sends session recording data to -// the configured recorder and forwards the raw bytes to the original -// destination. -type conn struct { - net.Conn - // rec knows how to send data written to it to a tsrecorder instance. - rec *tsrecorder.Client - - stdoutStreamID atomic.Uint32 - stderrStreamID atomic.Uint32 - resizeStreamID atomic.Uint32 - - wmu sync.Mutex // sequences writes - closed bool - - rmu sync.Mutex // sequences reads - - // The following fields are related to sending asciinema CastHeader. - // CastHeader must be sent before any payload. If the session has a - // terminal attached, the CastHeader must have '.Width' and '.Height' - // fields set for the tsrecorder UI to be able to play the recording. - // For 'kubectl exec' sessions, terminal width and height are sent as a - // resize message on resize stream from the client when the session - // starts as well as at any time the client detects a terminal change. - // We can intercept the resize message on Read calls. As there is no - // guarantee that the resize message from client will be intercepted - // before server writes stdout messages that we must record, we need to - // ensure that parsing stdout/stderr messages written to the connection - // waits till a resize message has been received and a CastHeader with - // correct terminal dimensions can be written. - - // ch is the asciinema CastHeader for the current session. - // https://docs.asciinema.org/manual/asciicast/v2/#header - ch sessionrecording.CastHeader - // writeCastHeaderOnce is used to ensure CastHeader gets sent to tsrecorder once. - writeCastHeaderOnce sync.Once - hasTerm bool // whether the session had TTY attached - // initialTermSizeSet channel gets sent a value once, when the Read has - // received a resize message and set the initial terminal size. It must - // be set to a buffered channel to prevent Reads being blocked on the - // first stdout/stderr write reading from the channel. - initialTermSizeSet chan struct{} - // sendInitialTermSizeSetOnce is used to ensure that a value is sent to - // initialTermSizeSet channel only once, when the initial resize message - // is received. - sendinitialTermSizeSetOnce sync.Once - - zlibReqReader zlibReader - // writeBuf is used to store data written to the connection that has not - // yet been parsed as SPDY frames. - writeBuf bytes.Buffer - // readBuf is used to store data read from the connection that has not - // yet been parsed as SPDY frames. - readBuf bytes.Buffer - log *zap.SugaredLogger -} - -// Read reads bytes from the original connection and parses them as SPDY frames. -// If the frame is a data frame for resize stream, sends resize message to the -// recorder. If the frame is a SYN_STREAM control frame that starts stdout, -// stderr or resize stream, store the stream ID. -func (c *conn) Read(b []byte) (int, error) { - c.rmu.Lock() - defer c.rmu.Unlock() - n, err := c.Conn.Read(b) - if err != nil { - return n, fmt.Errorf("error reading from connection: %w", err) - } - c.readBuf.Write(b[:n]) - - var sf spdyFrame - ok, err := sf.Parse(c.readBuf.Bytes(), c.log) - if err != nil { - return 0, fmt.Errorf("error parsing data read from connection: %w", err) - } - if !ok { - // The parsed data in the buffer will be processed together with - // the new data on the next call to Read. - return n, nil - } - c.readBuf.Next(len(sf.Raw)) // advance buffer past the parsed frame - - if !sf.Ctrl { // data frame - switch sf.StreamID { - case c.resizeStreamID.Load(): - - var msg spdyResizeMsg - if err = json.Unmarshal(sf.Payload, &msg); err != nil { - return 0, fmt.Errorf("error umarshalling resize msg: %w", err) - } - c.ch.Width = msg.Width - c.ch.Height = msg.Height - - // If this is initial resize message, the width and - // height will be sent in the CastHeader. If this is a - // subsequent resize message, we need to send asciinema - // resize message. - var isInitialResize bool - c.sendinitialTermSizeSetOnce.Do(func() { - isInitialResize = true - close(c.initialTermSizeSet) // unblock sending of CastHeader - }) - if !isInitialResize { - if err := c.rec.WriteResize(c.ch.Height, c.ch.Width); err != nil { - return 0, fmt.Errorf("error writing resize message: %w", err) - } - } - } - return n, nil - } - // We always want to parse the headers, even if we don't care about the - // frame, as we need to advance the zlib reader otherwise we will get - // garbage. - header, err := sf.parseHeaders(&c.zlibReqReader, c.log) - if err != nil { - return 0, fmt.Errorf("error parsing frame headers: %w", err) - } - if sf.Type == SYN_STREAM { - c.storeStreamID(sf, header) - } - return n, nil -} - -// Write forwards the raw data of the latest parsed SPDY frame to the original -// destination. If the frame is an SPDY data frame, it also sends the payload to -// the connected session recorder. -func (c *conn) Write(b []byte) (int, error) { - c.wmu.Lock() - defer c.wmu.Unlock() - c.writeBuf.Write(b) - - var sf spdyFrame - ok, err := sf.Parse(c.writeBuf.Bytes(), c.log) - if err != nil { - return 0, fmt.Errorf("error parsing data: %w", err) - } - if !ok { - // The parsed data in the buffer will be processed together with - // the new data on the next call to Write. - return len(b), nil - } - c.writeBuf.Next(len(sf.Raw)) // advance buffer past the parsed frame - - // If this is a stdout or stderr data frame, send its payload to the - // session recorder. - if !sf.Ctrl { - switch sf.StreamID { - case c.stdoutStreamID.Load(), c.stderrStreamID.Load(): - var err error - c.writeCastHeaderOnce.Do(func() { - // If this is a session with a terminal attached, - // we must wait for the terminal width and - // height to be parsed from a resize message - // before sending CastHeader, else tsrecorder - // will not be able to play this recording. - if c.hasTerm { - c.log.Debugf("write: waiting for the initial terminal size to be set before proceeding with sending the first payload") - <-c.initialTermSizeSet - } - err = c.rec.WriteCastHeader(c.ch) - }) - if err != nil { - return 0, fmt.Errorf("error writing CastHeader: %w", err) - } - if err := c.rec.WriteOutput(sf.Payload); err != nil { - return 0, fmt.Errorf("error sending payload to session recorder: %w", err) - } - } - } - // Forward the whole frame to the original destination. - _, err = c.Conn.Write(sf.Raw) // send to net.Conn - return len(b), err -} - -func (c *conn) Close() error { - c.wmu.Lock() - defer c.wmu.Unlock() - if c.closed { - return nil - } - c.writeBuf.Reset() - c.closed = true - err := c.Conn.Close() - c.rec.Close() - return err -} - -// storeStreamID parses SYN_STREAM SPDY control frame and updates -// conn to store the newly created stream's ID if it is one of -// the stream types we care about. Storing stream_id:stream_type mapping allows -// us to parse received data frames (that have stream IDs) differently depening -// on which stream they belong to (i.e send data frame payload for stdout stream -// to session recorder). -func (c *conn) storeStreamID(sf spdyFrame, header http.Header) { - const ( - streamTypeHeaderKey = "Streamtype" - ) - id := binary.BigEndian.Uint32(sf.Payload[0:4]) - switch header.Get(streamTypeHeaderKey) { - case corev1.StreamTypeStdout: - c.stdoutStreamID.Store(id) - case corev1.StreamTypeStderr: - c.stderrStreamID.Store(id) - case corev1.StreamTypeResize: - c.resizeStreamID.Store(id) - } -} - -type spdyResizeMsg struct { - Width int `json:"width"` - Height int `json:"height"` -} diff --git a/k8s-operator/sessionrecording/spdy/conn_test.go b/k8s-operator/sessionrecording/spdy/conn_test.go deleted file mode 100644 index 3485d61c4f454..0000000000000 --- a/k8s-operator/sessionrecording/spdy/conn_test.go +++ /dev/null @@ -1,316 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package spdy - -import ( - "encoding/json" - "fmt" - "reflect" - "testing" - - "go.uber.org/zap" - "tailscale.com/k8s-operator/sessionrecording/fakes" - "tailscale.com/k8s-operator/sessionrecording/tsrecorder" - "tailscale.com/sessionrecording" - "tailscale.com/tstest" -) - -// Test_Writes tests that 1 or more Write calls to spdyRemoteConnRecorder -// results in the expected data being forwarded to the original destination and -// the session recorder. -func Test_Writes(t *testing.T) { - var stdoutStreamID, stderrStreamID uint32 = 1, 2 - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - tests := []struct { - name string - inputs [][]byte - wantForwarded []byte - wantRecorded []byte - firstWrite bool - width int - height int - sendInitialResize bool - hasTerm bool - }{ - { - name: "single_write_control_frame_with_payload", - inputs: [][]byte{{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x1, 0x5}}, - wantForwarded: []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x1, 0x5}, - }, - { - name: "two_writes_control_frame_with_leftover", - inputs: [][]byte{{0x80, 0x3, 0x0, 0x1}, {0x0, 0x0, 0x0, 0x1, 0x5, 0x80, 0x3}}, - wantForwarded: []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x1, 0x5}, - }, - { - name: "single_write_stdout_data_frame", - inputs: [][]byte{{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0}}, - wantForwarded: []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0}, - }, - { - name: "single_write_stdout_data_frame_with_payload", - inputs: [][]byte{{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}}, - wantForwarded: []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: fakes.CastLine(t, []byte{0x1, 0x2, 0x3, 0x4, 0x5}, cl), - }, - { - name: "single_write_stderr_data_frame_with_payload", - inputs: [][]byte{{0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}}, - wantForwarded: []byte{0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: fakes.CastLine(t, []byte{0x1, 0x2, 0x3, 0x4, 0x5}, cl), - }, - { - name: "single_data_frame_unknow_stream_with_payload", - inputs: [][]byte{{0x0, 0x0, 0x0, 0x7, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}}, - wantForwarded: []byte{0x0, 0x0, 0x0, 0x7, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}, - }, - { - name: "control_frame_and_data_frame_split_across_two_writes", - inputs: [][]byte{{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}, {0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}}, - wantForwarded: []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: fakes.CastLine(t, []byte{0x1, 0x2, 0x3, 0x4, 0x5}, cl), - }, - { - name: "single_first_write_stdout_data_frame_with_payload_sess_has_terminal", - inputs: [][]byte{{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}}, - wantForwarded: []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: append(fakes.AsciinemaResizeMsg(t, 10, 20), fakes.CastLine(t, []byte{0x1, 0x2, 0x3, 0x4, 0x5}, cl)...), - width: 10, - height: 20, - hasTerm: true, - firstWrite: true, - sendInitialResize: true, - }, - { - name: "single_first_write_stdout_data_frame_with_payload_sess_does_not_have_terminal", - inputs: [][]byte{{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}}, - wantForwarded: []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x5, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: append(fakes.AsciinemaResizeMsg(t, 10, 20), fakes.CastLine(t, []byte{0x1, 0x2, 0x3, 0x4, 0x5}, cl)...), - width: 10, - height: 20, - firstWrite: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc := &fakes.TestConn{} - sr := &fakes.TestSessionRecorder{} - rec := tsrecorder.New(sr, cl, cl.Now(), true, zl.Sugar()) - - c := &conn{ - Conn: tc, - log: zl.Sugar(), - rec: rec, - ch: sessionrecording.CastHeader{ - Width: tt.width, - Height: tt.height, - }, - initialTermSizeSet: make(chan struct{}), - hasTerm: tt.hasTerm, - } - if !tt.firstWrite { - // this test case does not intend to test that cast header gets written once - c.writeCastHeaderOnce.Do(func() {}) - } - if tt.sendInitialResize { - close(c.initialTermSizeSet) - } - - c.stdoutStreamID.Store(stdoutStreamID) - c.stderrStreamID.Store(stderrStreamID) - for i, input := range tt.inputs { - c.hasTerm = tt.hasTerm - if _, err := c.Write(input); err != nil { - t.Errorf("[%d] spdyRemoteConnRecorder.Write() unexpected error %v", i, err) - } - } - - // Assert that the expected bytes have been forwarded to the original destination. - gotForwarded := tc.WriteBufBytes() - if !reflect.DeepEqual(gotForwarded, tt.wantForwarded) { - t.Errorf("expected bytes not forwarded, wants\n%v\ngot\n%v", tt.wantForwarded, gotForwarded) - } - - // Assert that the expected bytes have been forwarded to the session recorder. - gotRecorded := sr.Bytes() - if !reflect.DeepEqual(gotRecorded, tt.wantRecorded) { - t.Errorf("expected bytes not recorded, wants\n%v\ngot\n%v", tt.wantRecorded, gotRecorded) - } - }) - } -} - -// Test_Reads tests that 1 or more Read calls to spdyRemoteConnRecorder results -// in the expected data being forwarded to the original destination and the -// session recorder. -func Test_Reads(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - var reader zlibReader - resizeMsg := resizeMsgBytes(t, 10, 20) - synStreamStdoutPayload := payload(t, map[string]string{"Streamtype": "stdout"}, SYN_STREAM, 1) - synStreamStderrPayload := payload(t, map[string]string{"Streamtype": "stderr"}, SYN_STREAM, 2) - synStreamResizePayload := payload(t, map[string]string{"Streamtype": "resize"}, SYN_STREAM, 3) - syn_stream_ctrl_header := []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, uint8(len(synStreamStdoutPayload))} - - tests := []struct { - name string - inputs [][]byte - wantStdoutStreamID uint32 - wantStderrStreamID uint32 - wantResizeStreamID uint32 - wantWidth int - wantHeight int - resizeStreamIDBeforeRead uint32 - }{ - { - name: "resize_data_frame_single_read", - inputs: [][]byte{append([]byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, uint8(len(resizeMsg))}, resizeMsg...)}, - resizeStreamIDBeforeRead: 1, - wantWidth: 10, - wantHeight: 20, - }, - { - name: "resize_data_frame_two_reads", - inputs: [][]byte{{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, uint8(len(resizeMsg))}, resizeMsg}, - resizeStreamIDBeforeRead: 1, - wantWidth: 10, - wantHeight: 20, - }, - { - name: "syn_stream_ctrl_frame_stdout_single_read", - inputs: [][]byte{append(syn_stream_ctrl_header, synStreamStdoutPayload...)}, - wantStdoutStreamID: 1, - }, - { - name: "syn_stream_ctrl_frame_stderr_single_read", - inputs: [][]byte{append(syn_stream_ctrl_header, synStreamStderrPayload...)}, - wantStderrStreamID: 2, - }, - { - name: "syn_stream_ctrl_frame_resize_single_read", - inputs: [][]byte{append(syn_stream_ctrl_header, synStreamResizePayload...)}, - wantResizeStreamID: 3, - }, - { - name: "syn_stream_ctrl_frame_resize_four_reads_with_leftover", - inputs: [][]byte{syn_stream_ctrl_header, append(synStreamResizePayload, syn_stream_ctrl_header...), append(synStreamStderrPayload, syn_stream_ctrl_header...), append(synStreamStdoutPayload, 0x0, 0x3)}, - wantStdoutStreamID: 1, - wantStderrStreamID: 2, - wantResizeStreamID: 3, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc := &fakes.TestConn{} - sr := &fakes.TestSessionRecorder{} - rec := tsrecorder.New(sr, cl, cl.Now(), true, zl.Sugar()) - c := &conn{ - Conn: tc, - log: zl.Sugar(), - rec: rec, - initialTermSizeSet: make(chan struct{}), - } - c.resizeStreamID.Store(tt.resizeStreamIDBeforeRead) - - for i, input := range tt.inputs { - c.zlibReqReader = reader - tc.ResetReadBuf() - if err := tc.WriteReadBufBytes(input); err != nil { - t.Fatalf("writing bytes to test conn: %v", err) - } - _, err = c.Read(make([]byte, len(input))) - if err != nil { - t.Errorf("[%d] spdyRemoteConnRecorder.Read() resulted in an unexpected error: %v", i, err) - } - } - if id := c.resizeStreamID.Load(); id != tt.wantResizeStreamID && id != tt.resizeStreamIDBeforeRead { - t.Errorf("wants resizeStreamID: %d, got %d", tt.wantResizeStreamID, id) - } - if id := c.stderrStreamID.Load(); id != tt.wantStderrStreamID { - t.Errorf("wants stderrStreamID: %d, got %d", tt.wantStderrStreamID, id) - } - if id := c.stdoutStreamID.Load(); id != tt.wantStdoutStreamID { - t.Errorf("wants stdoutStreamID: %d, got %d", tt.wantStdoutStreamID, id) - } - if tt.wantHeight != 0 || tt.wantWidth != 0 { - if tt.wantWidth != c.ch.Width { - t.Errorf("wants width: %v, got %v", tt.wantWidth, c.ch.Width) - } - if tt.wantHeight != c.ch.Height { - t.Errorf("want height: %v, got %v", tt.wantHeight, c.ch.Height) - } - } - }) - } -} - -// Test_conn_ReadRand tests reading arbitrarily generated byte slices from conn to -// test that we don't panic when parsing input from a broken or malicious -// client. -func Test_conn_ReadRand(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatalf("error creating a test logger: %v", err) - } - for i := range 1000 { - tc := &fakes.TestConn{} - tc.ResetReadBuf() - c := &conn{ - Conn: tc, - log: zl.Sugar(), - } - bb := fakes.RandomBytes(t) - for j, input := range bb { - if err := tc.WriteReadBufBytes(input); err != nil { - t.Fatalf("[%d] writing bytes to test conn: %v", i, err) - } - f := func() { - c.Read(make([]byte, len(input))) - } - testPanic(t, f, fmt.Sprintf("[%d %d] Read panic parsing input of length %d", i, j, len(input))) - } - } -} - -// Test_conn_WriteRand calls conn.Write with an arbitrary input to validate that -// it does not panic. -func Test_conn_WriteRand(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatalf("error creating a test logger: %v", err) - } - for i := range 100 { - tc := &fakes.TestConn{} - c := &conn{ - Conn: tc, - log: zl.Sugar(), - } - bb := fakes.RandomBytes(t) - for j, input := range bb { - f := func() { - c.Write(input) - } - testPanic(t, f, fmt.Sprintf("[%d %d] Write: panic parsing input of length %d", i, j, len(input))) - } - } -} - -func resizeMsgBytes(t *testing.T, width, height int) []byte { - t.Helper() - bs, err := json.Marshal(spdyResizeMsg{Width: width, Height: height}) - if err != nil { - t.Fatalf("error marshalling resizeMsg: %v", err) - } - return bs -} diff --git a/k8s-operator/sessionrecording/spdy/frame.go b/k8s-operator/sessionrecording/spdy/frame.go deleted file mode 100644 index 54b29d33a9622..0000000000000 --- a/k8s-operator/sessionrecording/spdy/frame.go +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package spdy - -import ( - "bytes" - "encoding/binary" - "fmt" - "io" - "net/http" - "sync" - - "go.uber.org/zap" -) - -const ( - SYN_STREAM ControlFrameType = 1 // https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.1 - SYN_REPLY ControlFrameType = 2 // https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.2 - SYN_PING ControlFrameType = 6 // https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.5 -) - -// spdyFrame is a parsed SPDY frame as defined in -// https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt -// A SPDY frame can be either a control frame or a data frame. -type spdyFrame struct { - Raw []byte // full frame as raw bytes - - // Common frame fields: - Ctrl bool // true if this is a SPDY control frame - Payload []byte // payload as raw bytes - - // Control frame fields: - Version uint16 // SPDY protocol version - Type ControlFrameType - - // Data frame fields: - // StreamID is the id of the steam to which this data frame belongs. - // SPDY allows transmitting multiple data streams concurrently. - StreamID uint32 -} - -// Type of an SPDY control frame. -type ControlFrameType uint16 - -// Parse parses bytes into spdyFrame. -// If the bytes don't contain a full frame, return false. -// -// Control frame structure: -// -// +----------------------------------+ -// |C| Version(15bits) | Type(16bits) | -// +----------------------------------+ -// | Flags (8) | Length (24 bits) | -// +----------------------------------+ -// | Data | -// +----------------------------------+ -// -// Data frame structure: -// -// +----------------------------------+ -// |C| Stream-ID (31bits) | -// +----------------------------------+ -// | Flags (8) | Length (24 bits) | -// +----------------------------------+ -// | Data | -// +----------------------------------+ -// -// https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt -func (sf *spdyFrame) Parse(b []byte, log *zap.SugaredLogger) (ok bool, _ error) { - const ( - spdyHeaderLength = 8 - ) - have := len(b) - if have < spdyHeaderLength { // input does not contain full frame - return false, nil - } - - if !isSPDYFrameHeader(b) { - return false, fmt.Errorf("bytes %v do not seem to contain SPDY frames. Ensure that you are using a SPDY based client to 'kubectl exec'.", b) - } - - payloadLength := readInt24(b[5:8]) - frameLength := payloadLength + spdyHeaderLength - if have < frameLength { // input does not contain full frame - return false, nil - } - - frame := b[:frameLength:frameLength] // enforce frameLength capacity - - sf.Raw = frame - sf.Payload = frame[spdyHeaderLength:frameLength] - - sf.Ctrl = hasControlBitSet(frame) - - if !sf.Ctrl { // data frame - sf.StreamID = dataFrameStreamID(frame) - return true, nil - } - - sf.Version = controlFrameVersion(frame) - sf.Type = controlFrameType(frame) - return true, nil -} - -// parseHeaders retrieves any headers from this spdyFrame. -func (sf *spdyFrame) parseHeaders(z *zlibReader, log *zap.SugaredLogger) (http.Header, error) { - if !sf.Ctrl { - return nil, fmt.Errorf("[unexpected] parseHeaders called for a frame that is not a control frame") - } - const ( - // +------------------------------------+ - // |X| Stream-ID (31bits) | - // +------------------------------------+ - // |X| Associated-To-Stream-ID (31bits) | - // +------------------------------------+ - // | Pri|Unused | Slot | | - // +-------------------+ | - synStreamPayloadLengthBeforeHeaders = 10 - - // +------------------------------------+ - // |X| Stream-ID (31bits) | - //+------------------------------------+ - synReplyPayloadLengthBeforeHeaders = 4 - - // +----------------------------------| - // | 32-bit ID | - // +----------------------------------+ - pingPayloadLength = 4 - ) - - switch sf.Type { - case SYN_STREAM: - if len(sf.Payload) < synStreamPayloadLengthBeforeHeaders { - return nil, fmt.Errorf("SYN_STREAM frame too short: %v", len(sf.Payload)) - } - z.Set(sf.Payload[synStreamPayloadLengthBeforeHeaders:]) - return parseHeaders(z, log) - case SYN_REPLY: - if len(sf.Payload) < synReplyPayloadLengthBeforeHeaders { - return nil, fmt.Errorf("SYN_REPLY frame too short: %v", len(sf.Payload)) - } - if len(sf.Payload) == synReplyPayloadLengthBeforeHeaders { - return nil, nil // no headers - } - z.Set(sf.Payload[synReplyPayloadLengthBeforeHeaders:]) - return parseHeaders(z, log) - case SYN_PING: - if len(sf.Payload) != pingPayloadLength { - return nil, fmt.Errorf("PING frame with unexpected length %v", len(sf.Payload)) - } - return nil, nil // ping frame has no headers - - default: - log.Infof("[unexpected] unknown control frame type %v", sf.Type) - } - return nil, nil -} - -// parseHeaders expects to be passed a reader that contains a compressed SPDY control -// frame Name/Value Header Block with 0 or more headers: -// -// | Number of Name/Value pairs (int32) | <+ -// +------------------------------------+ | -// | Length of name (int32) | | This section is the "Name/Value -// +------------------------------------+ | Header Block", and is compressed. -// | Name (string) | | -// +------------------------------------+ | -// | Length of value (int32) | | -// +------------------------------------+ | -// | Value (string) | | -// +------------------------------------+ | -// | (repeats) | <+ -// -// It extracts the headers and returns them as http.Header. By doing that it -// also advances the provided reader past the headers block. -// See also https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.10 -func parseHeaders(decompressor io.Reader, log *zap.SugaredLogger) (http.Header, error) { - buf := bufPool.Get().(*bytes.Buffer) - defer bufPool.Put(buf) - buf.Reset() - - // readUint32 reads the next 4 decompressed bytes from the decompressor - // as a uint32. - readUint32 := func() (uint32, error) { - const uint32Length = 4 - if _, err := io.CopyN(buf, decompressor, uint32Length); err != nil { // decompress - return 0, fmt.Errorf("error decompressing bytes: %w", err) - } - return binary.BigEndian.Uint32(buf.Next(uint32Length)), nil // return as uint32 - } - - // readLenBytes decompresses and returns as bytes the next 'Name' or 'Value' - // field from SPDY Name/Value header block. decompressor must be at - // 'Length of name'/'Length of value' field. - readLenBytes := func() ([]byte, error) { - xLen, err := readUint32() // length of field to read - if err != nil { - return nil, err - } - if _, err := io.CopyN(buf, decompressor, int64(xLen)); err != nil { // decompress - return nil, err - } - return buf.Next(int(xLen)), nil - } - - numHeaders, err := readUint32() - if err != nil { - return nil, fmt.Errorf("error determining num headers: %v", err) - } - h := make(http.Header, numHeaders) - for i := uint32(0); i < numHeaders; i++ { - name, err := readLenBytes() - if err != nil { - return nil, err - } - ns := string(name) - if _, ok := h[ns]; ok { - return nil, fmt.Errorf("invalid data: duplicate header %q", ns) - } - val, err := readLenBytes() - if err != nil { - return nil, fmt.Errorf("error reading header data: %w", err) - } - for _, v := range bytes.Split(val, headerSep) { - h.Add(ns, string(v)) - } - } - return h, nil -} - -// isSPDYFrame validates that the input bytes start with a valid SPDY frame -// header. -func isSPDYFrameHeader(f []byte) bool { - if hasControlBitSet(f) { - // If this is a control frame, version and type must be set. - return controlFrameVersion(f) != uint16(0) && uint16(controlFrameType(f)) != uint16(0) - } - // If this is a data frame, stream ID must be set. - return dataFrameStreamID(f) != uint32(0) -} - -// spdyDataFrameStreamID returns stream ID for an SPDY data frame passed as the -// input data slice. StreaID is contained within bits [0-31) of a data frame -// header. -func dataFrameStreamID(frame []byte) uint32 { - return binary.BigEndian.Uint32(frame[0:4]) & 0x7f -} - -// controlFrameType returns the type of a SPDY control frame. -// See https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6 -func controlFrameType(f []byte) ControlFrameType { - return ControlFrameType(binary.BigEndian.Uint16(f[2:4])) -} - -// spdyControlFrameVersion returns SPDY version extracted from input bytes that -// must be a SPDY control frame. -func controlFrameVersion(frame []byte) uint16 { - bs := binary.BigEndian.Uint16(frame[0:2]) // first 16 bits - return bs & 0x7f // discard control bit -} - -// hasControlBitSet returns true if the passsed bytes have SPDY control bit set. -// SPDY frames can be either control frames or data frames. A control frame has -// control bit set to 1 and a data frame has it set to 0. -func hasControlBitSet(frame []byte) bool { - return frame[0]&0x80 == 128 // 0x80 -} - -var bufPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - -// Headers in SPDY header name/value block are separated by a 0 byte. -// https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.10 -var headerSep = []byte{0} - -func readInt24(b []byte) int { - _ = b[2] // bounds check hint to compiler; see golang.org/issue/14808 - return int(b[0])<<16 | int(b[1])<<8 | int(b[2]) -} diff --git a/k8s-operator/sessionrecording/spdy/frame_test.go b/k8s-operator/sessionrecording/spdy/frame_test.go deleted file mode 100644 index 4896cdcbf78a5..0000000000000 --- a/k8s-operator/sessionrecording/spdy/frame_test.go +++ /dev/null @@ -1,330 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package spdy - -import ( - "bytes" - "compress/zlib" - "encoding/binary" - "fmt" - "io" - "net/http" - "reflect" - "strings" - "testing" - "time" - - "math/rand" - - "github.com/google/go-cmp/cmp" - "go.uber.org/zap" -) - -func Test_spdyFrame_Parse(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tests := []struct { - name string - gotBytes []byte - wantFrame spdyFrame - wantOk bool - wantErr bool - }{ - { - name: "control_frame_syn_stream", - gotBytes: []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0}, - wantFrame: spdyFrame{ - Version: 3, - Type: SYN_STREAM, - Ctrl: true, - Raw: []byte{0x80, 0x3, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0}, - Payload: []byte{}, - }, - wantOk: true, - }, - { - name: "control_frame_syn_reply", - gotBytes: []byte{0x80, 0x3, 0x0, 0x2, 0x0, 0x0, 0x0, 0x0}, - wantFrame: spdyFrame{ - Ctrl: true, - Version: 3, - Type: SYN_REPLY, - Raw: []byte{0x80, 0x3, 0x0, 0x2, 0x0, 0x0, 0x0, 0x0}, - Payload: []byte{}, - }, - wantOk: true, - }, - { - name: "control_frame_headers", - gotBytes: []byte{0x80, 0x3, 0x0, 0x8, 0x0, 0x0, 0x0, 0x0}, - wantFrame: spdyFrame{ - Ctrl: true, - Version: 3, - Type: 8, - Raw: []byte{0x80, 0x3, 0x0, 0x8, 0x0, 0x0, 0x0, 0x0}, - Payload: []byte{}, - }, - wantOk: true, - }, - { - name: "data_frame_stream_id_5", - gotBytes: []byte{0x0, 0x0, 0x0, 0x5, 0x0, 0x0, 0x0, 0x0}, - wantFrame: spdyFrame{ - Payload: []byte{}, - StreamID: 5, - Raw: []byte{0x0, 0x0, 0x0, 0x5, 0x0, 0x0, 0x0, 0x0}, - }, - wantOk: true, - }, - { - name: "frame_with_incomplete_header", - gotBytes: []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, - }, - { - name: "frame_with_incomplete_payload", - gotBytes: []byte{0x0, 0x0, 0x0, 0x5, 0x0, 0x0, 0x0, 0x2}, // header specifies payload length of 2 - }, - { - name: "control_bit_set_not_spdy_frame", - gotBytes: []byte{0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, // header specifies payload length of 2 - wantErr: true, - }, - { - name: "control_bit_not_set_not_spdy_frame", - gotBytes: []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, // header specifies payload length of 2 - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sf := &spdyFrame{} - gotOk, err := sf.Parse(tt.gotBytes, zl.Sugar()) - if (err != nil) != tt.wantErr { - t.Errorf("spdyFrame.Parse() error = %v, wantErr %v", err, tt.wantErr) - return - } - if gotOk != tt.wantOk { - t.Errorf("spdyFrame.Parse() = %v, want %v", gotOk, tt.wantOk) - } - if diff := cmp.Diff(*sf, tt.wantFrame); diff != "" { - t.Errorf("Unexpected SPDY frame (-got +want):\n%s", diff) - } - }) - } -} - -func Test_spdyFrame_parseHeaders(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - tests := []struct { - name string - isCtrl bool - payload []byte - typ ControlFrameType - wantHeader http.Header - wantErr bool - }{ - { - name: "syn_stream_with_header", - payload: payload(t, map[string]string{"Streamtype": "stdin"}, SYN_STREAM, 1), - typ: SYN_STREAM, - isCtrl: true, - wantHeader: header(map[string]string{"Streamtype": "stdin"}), - }, - { - name: "syn_ping", - payload: payload(t, nil, SYN_PING, 0), - typ: SYN_PING, - isCtrl: true, - }, - { - name: "syn_reply_headers", - payload: payload(t, map[string]string{"foo": "bar", "bar": "baz"}, SYN_REPLY, 0), - typ: SYN_REPLY, - isCtrl: true, - wantHeader: header(map[string]string{"foo": "bar", "bar": "baz"}), - }, - { - name: "syn_reply_no_headers", - payload: payload(t, nil, SYN_REPLY, 0), - typ: SYN_REPLY, - isCtrl: true, - }, - { - name: "syn_stream_too_short_payload", - payload: []byte{0, 1, 2, 3, 4}, - typ: SYN_STREAM, - isCtrl: true, - wantErr: true, - }, - { - name: "syn_reply_too_short_payload", - payload: []byte{0, 1, 2}, - typ: SYN_REPLY, - isCtrl: true, - wantErr: true, - }, - { - name: "syn_ping_too_short_payload", - payload: []byte{0, 1, 2}, - typ: SYN_PING, - isCtrl: true, - wantErr: true, - }, - { - name: "not_a_control_frame", - payload: []byte{0, 1, 2, 3}, - typ: SYN_PING, - wantErr: true, - }, - } - for _, tt := range tests { - var reader zlibReader - t.Run(tt.name, func(t *testing.T) { - sf := &spdyFrame{ - Ctrl: tt.isCtrl, - Type: tt.typ, - Payload: tt.payload, - } - gotHeader, err := sf.parseHeaders(&reader, zl.Sugar()) - if (err != nil) != tt.wantErr { - t.Errorf("spdyFrame.parseHeaders() error = %v, wantErr %v", err, tt.wantErr) - } - if !reflect.DeepEqual(gotHeader, tt.wantHeader) { - t.Errorf("spdyFrame.parseHeaders() = %v, want %v", gotHeader, tt.wantHeader) - } - }) - } -} - -// Test_spdyFrame_ParseRand calls spdyFrame.Parse with randomly generated bytes -// to test that it doesn't panic. -func Test_spdyFrame_ParseRand(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - r := rand.New(rand.NewSource(time.Now().UnixNano())) - for i := range 100 { - n := r.Intn(4096) - b := make([]byte, n) - _, err := r.Read(b) - if err != nil { - t.Fatalf("error generating random byte slice: %v", err) - } - sf := &spdyFrame{} - f := func() { - sf.Parse(b, zl.Sugar()) - } - testPanic(t, f, fmt.Sprintf("[%d] Parse panicked running with byte slice of length %d: %v", i, n, r)) - } -} - -// payload takes a control frame type and a map with 0 or more header keys and -// values and returns a SPDY control frame payload with the header as SPDY zlib -// compressed header name/value block. The payload is padded with arbitrary -// bytes to ensure the header name/value block is in the correct position for -// the frame type. -func payload(t *testing.T, headerM map[string]string, typ ControlFrameType, streamID int) []byte { - t.Helper() - - buf := bytes.NewBuffer([]byte{}) - writeControlFramePayloadBeforeHeaders(t, buf, typ, streamID) - if len(headerM) == 0 { - return buf.Bytes() - } - - w, err := zlib.NewWriterLevelDict(buf, zlib.BestCompression, spdyTxtDictionary) - if err != nil { - t.Fatalf("error creating new zlib writer: %v", err) - } - if len(headerM) != 0 { - writeHeaderValueBlock(t, w, headerM) - } - if err != nil { - t.Fatalf("error writing headers: %v", err) - } - w.Flush() - return buf.Bytes() -} - -// writeControlFramePayloadBeforeHeaders writes to w N bytes, N being the number -// of bytes that control frame payload for that control frame is required to -// contain before the name/value header block. -func writeControlFramePayloadBeforeHeaders(t *testing.T, w io.Writer, typ ControlFrameType, streamID int) { - t.Helper() - switch typ { - case SYN_STREAM: - // needs 10 bytes in payload before any headers - if err := binary.Write(w, binary.BigEndian, uint32(streamID)); err != nil { - t.Fatalf("writing streamID: %v", err) - } - if err := binary.Write(w, binary.BigEndian, [6]byte{0}); err != nil { - t.Fatalf("writing payload: %v", err) - } - case SYN_REPLY: - // needs 4 bytes in payload before any headers - if err := binary.Write(w, binary.BigEndian, uint32(0)); err != nil { - t.Fatalf("writing payload: %v", err) - } - case SYN_PING: - // needs 4 bytes in payload - if err := binary.Write(w, binary.BigEndian, uint32(0)); err != nil { - t.Fatalf("writing payload: %v", err) - } - default: - t.Fatalf("unexpected frame type: %v", typ) - } -} - -// writeHeaderValue block takes http.Header and zlib writer, writes the headers -// as SPDY zlib compressed bytes to the writer. -// Adopted from https://github.com/moby/spdystream/blob/v0.2.0/spdy/write.go#L171-L198 (which is also what Kubernetes uses). -func writeHeaderValueBlock(t *testing.T, w io.Writer, headerM map[string]string) { - t.Helper() - h := header(headerM) - if err := binary.Write(w, binary.BigEndian, uint32(len(h))); err != nil { - t.Fatalf("error writing header block length: %v", err) - } - for name, values := range h { - if err := binary.Write(w, binary.BigEndian, uint32(len(name))); err != nil { - t.Fatalf("error writing name length for name %q: %v", name, err) - } - name = strings.ToLower(name) - if _, err := io.WriteString(w, name); err != nil { - t.Fatalf("error writing name %q: %v", name, err) - } - v := strings.Join(values, string(headerSep)) - if err := binary.Write(w, binary.BigEndian, uint32(len(v))); err != nil { - t.Fatalf("error writing value length for value %q: %v", v, err) - } - if _, err := io.WriteString(w, v); err != nil { - t.Fatalf("error writing value %q: %v", v, err) - } - } -} - -func header(hs map[string]string) http.Header { - h := make(http.Header, len(hs)) - for key, val := range hs { - h.Add(key, val) - } - return h -} - -func testPanic(t *testing.T, f func(), msg string) { - t.Helper() - defer func() { - if r := recover(); r != nil { - t.Fatal(msg, r) - } - }() - f() -} diff --git a/k8s-operator/sessionrecording/spdy/zlib-reader.go b/k8s-operator/sessionrecording/spdy/zlib-reader.go deleted file mode 100644 index 1eb654be35632..0000000000000 --- a/k8s-operator/sessionrecording/spdy/zlib-reader.go +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package spdy - -import ( - "bytes" - "compress/zlib" - "io" -) - -// zlibReader contains functionality to parse zlib compressed SPDY data. -// See https://www.ietf.org/archive/id/draft-mbelshe-httpbis-spdy-00.txt section 2.6.10.1 -type zlibReader struct { - io.ReadCloser - underlying io.LimitedReader // zlib compressed SPDY data -} - -// Read decompresses zlibReader's underlying zlib compressed SPDY data and reads -// it into b. -func (z *zlibReader) Read(b []byte) (int, error) { - if z.ReadCloser == nil { - r, err := zlib.NewReaderDict(&z.underlying, spdyTxtDictionary) - if err != nil { - return 0, err - } - z.ReadCloser = r - } - return z.ReadCloser.Read(b) -} - -// Set sets zlibReader's underlying data. b must be zlib compressed SPDY data. -func (z *zlibReader) Set(b []byte) { - z.underlying.R = bytes.NewReader(b) - z.underlying.N = int64(len(b)) -} - -// spdyTxtDictionary is the dictionary defined in the SPDY spec. -// https://datatracker.ietf.org/doc/html/draft-mbelshe-httpbis-spdy-00#section-2.6.10.1 -var spdyTxtDictionary = []byte{ - 0x00, 0x00, 0x00, 0x07, 0x6f, 0x70, 0x74, 0x69, // - - - - o p t i - 0x6f, 0x6e, 0x73, 0x00, 0x00, 0x00, 0x04, 0x68, // o n s - - - - h - 0x65, 0x61, 0x64, 0x00, 0x00, 0x00, 0x04, 0x70, // e a d - - - - p - 0x6f, 0x73, 0x74, 0x00, 0x00, 0x00, 0x03, 0x70, // o s t - - - - p - 0x75, 0x74, 0x00, 0x00, 0x00, 0x06, 0x64, 0x65, // u t - - - - d e - 0x6c, 0x65, 0x74, 0x65, 0x00, 0x00, 0x00, 0x05, // l e t e - - - - - 0x74, 0x72, 0x61, 0x63, 0x65, 0x00, 0x00, 0x00, // t r a c e - - - - 0x06, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x00, // - a c c e p t - - 0x00, 0x00, 0x0e, 0x61, 0x63, 0x63, 0x65, 0x70, // - - - a c c e p - 0x74, 0x2d, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, // t - c h a r s e - 0x74, 0x00, 0x00, 0x00, 0x0f, 0x61, 0x63, 0x63, // t - - - - a c c - 0x65, 0x70, 0x74, 0x2d, 0x65, 0x6e, 0x63, 0x6f, // e p t - e n c o - 0x64, 0x69, 0x6e, 0x67, 0x00, 0x00, 0x00, 0x0f, // d i n g - - - - - 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x2d, 0x6c, // a c c e p t - l - 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x00, // a n g u a g e - - 0x00, 0x00, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x70, // - - - a c c e p - 0x74, 0x2d, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, // t - r a n g e s - 0x00, 0x00, 0x00, 0x03, 0x61, 0x67, 0x65, 0x00, // - - - - a g e - - 0x00, 0x00, 0x05, 0x61, 0x6c, 0x6c, 0x6f, 0x77, // - - - a l l o w - 0x00, 0x00, 0x00, 0x0d, 0x61, 0x75, 0x74, 0x68, // - - - - a u t h - 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, // o r i z a t i o - 0x6e, 0x00, 0x00, 0x00, 0x0d, 0x63, 0x61, 0x63, // n - - - - c a c - 0x68, 0x65, 0x2d, 0x63, 0x6f, 0x6e, 0x74, 0x72, // h e - c o n t r - 0x6f, 0x6c, 0x00, 0x00, 0x00, 0x0a, 0x63, 0x6f, // o l - - - - c o - 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, // n n e c t i o n - 0x00, 0x00, 0x00, 0x0c, 0x63, 0x6f, 0x6e, 0x74, // - - - - c o n t - 0x65, 0x6e, 0x74, 0x2d, 0x62, 0x61, 0x73, 0x65, // e n t - b a s e - 0x00, 0x00, 0x00, 0x10, 0x63, 0x6f, 0x6e, 0x74, // - - - - c o n t - 0x65, 0x6e, 0x74, 0x2d, 0x65, 0x6e, 0x63, 0x6f, // e n t - e n c o - 0x64, 0x69, 0x6e, 0x67, 0x00, 0x00, 0x00, 0x10, // d i n g - - - - - 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, // c o n t e n t - - 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, // l a n g u a g e - 0x00, 0x00, 0x00, 0x0e, 0x63, 0x6f, 0x6e, 0x74, // - - - - c o n t - 0x65, 0x6e, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, // e n t - l e n g - 0x74, 0x68, 0x00, 0x00, 0x00, 0x10, 0x63, 0x6f, // t h - - - - c o - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, 0x6c, 0x6f, // n t e n t - l o - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x00, 0x00, // c a t i o n - - - 0x00, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, // - - c o n t e n - 0x74, 0x2d, 0x6d, 0x64, 0x35, 0x00, 0x00, 0x00, // t - m d 5 - - - - 0x0d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, // - c o n t e n t - 0x2d, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x00, 0x00, // - r a n g e - - - 0x00, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, // - - c o n t e n - 0x74, 0x2d, 0x74, 0x79, 0x70, 0x65, 0x00, 0x00, // t - t y p e - - - 0x00, 0x04, 0x64, 0x61, 0x74, 0x65, 0x00, 0x00, // - - d a t e - - - 0x00, 0x04, 0x65, 0x74, 0x61, 0x67, 0x00, 0x00, // - - e t a g - - - 0x00, 0x06, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, // - - e x p e c t - 0x00, 0x00, 0x00, 0x07, 0x65, 0x78, 0x70, 0x69, // - - - - e x p i - 0x72, 0x65, 0x73, 0x00, 0x00, 0x00, 0x04, 0x66, // r e s - - - - f - 0x72, 0x6f, 0x6d, 0x00, 0x00, 0x00, 0x04, 0x68, // r o m - - - - h - 0x6f, 0x73, 0x74, 0x00, 0x00, 0x00, 0x08, 0x69, // o s t - - - - i - 0x66, 0x2d, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x00, // f - m a t c h - - 0x00, 0x00, 0x11, 0x69, 0x66, 0x2d, 0x6d, 0x6f, // - - - i f - m o - 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2d, 0x73, // d i f i e d - s - 0x69, 0x6e, 0x63, 0x65, 0x00, 0x00, 0x00, 0x0d, // i n c e - - - - - 0x69, 0x66, 0x2d, 0x6e, 0x6f, 0x6e, 0x65, 0x2d, // i f - n o n e - - 0x6d, 0x61, 0x74, 0x63, 0x68, 0x00, 0x00, 0x00, // m a t c h - - - - 0x08, 0x69, 0x66, 0x2d, 0x72, 0x61, 0x6e, 0x67, // - i f - r a n g - 0x65, 0x00, 0x00, 0x00, 0x13, 0x69, 0x66, 0x2d, // e - - - - i f - - 0x75, 0x6e, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, // u n m o d i f i - 0x65, 0x64, 0x2d, 0x73, 0x69, 0x6e, 0x63, 0x65, // e d - s i n c e - 0x00, 0x00, 0x00, 0x0d, 0x6c, 0x61, 0x73, 0x74, // - - - - l a s t - 0x2d, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, // - m o d i f i e - 0x64, 0x00, 0x00, 0x00, 0x08, 0x6c, 0x6f, 0x63, // d - - - - l o c - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x00, 0x00, 0x00, // a t i o n - - - - 0x0c, 0x6d, 0x61, 0x78, 0x2d, 0x66, 0x6f, 0x72, // - m a x - f o r - 0x77, 0x61, 0x72, 0x64, 0x73, 0x00, 0x00, 0x00, // w a r d s - - - - 0x06, 0x70, 0x72, 0x61, 0x67, 0x6d, 0x61, 0x00, // - p r a g m a - - 0x00, 0x00, 0x12, 0x70, 0x72, 0x6f, 0x78, 0x79, // - - - p r o x y - 0x2d, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, // - a u t h e n t - 0x69, 0x63, 0x61, 0x74, 0x65, 0x00, 0x00, 0x00, // i c a t e - - - - 0x13, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x2d, 0x61, // - p r o x y - a - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, // u t h o r i z a - 0x74, 0x69, 0x6f, 0x6e, 0x00, 0x00, 0x00, 0x05, // t i o n - - - - - 0x72, 0x61, 0x6e, 0x67, 0x65, 0x00, 0x00, 0x00, // r a n g e - - - - 0x07, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x72, // - r e f e r e r - 0x00, 0x00, 0x00, 0x0b, 0x72, 0x65, 0x74, 0x72, // - - - - r e t r - 0x79, 0x2d, 0x61, 0x66, 0x74, 0x65, 0x72, 0x00, // y - a f t e r - - 0x00, 0x00, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, // - - - s e r v e - 0x72, 0x00, 0x00, 0x00, 0x02, 0x74, 0x65, 0x00, // r - - - - t e - - 0x00, 0x00, 0x07, 0x74, 0x72, 0x61, 0x69, 0x6c, // - - - t r a i l - 0x65, 0x72, 0x00, 0x00, 0x00, 0x11, 0x74, 0x72, // e r - - - - t r - 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x2d, 0x65, // a n s f e r - e - 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x00, // n c o d i n g - - 0x00, 0x00, 0x07, 0x75, 0x70, 0x67, 0x72, 0x61, // - - - u p g r a - 0x64, 0x65, 0x00, 0x00, 0x00, 0x0a, 0x75, 0x73, // d e - - - - u s - 0x65, 0x72, 0x2d, 0x61, 0x67, 0x65, 0x6e, 0x74, // e r - a g e n t - 0x00, 0x00, 0x00, 0x04, 0x76, 0x61, 0x72, 0x79, // - - - - v a r y - 0x00, 0x00, 0x00, 0x03, 0x76, 0x69, 0x61, 0x00, // - - - - v i a - - 0x00, 0x00, 0x07, 0x77, 0x61, 0x72, 0x6e, 0x69, // - - - w a r n i - 0x6e, 0x67, 0x00, 0x00, 0x00, 0x10, 0x77, 0x77, // n g - - - - w w - 0x77, 0x2d, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, // w - a u t h e n - 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x00, 0x00, // t i c a t e - - - 0x00, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, // - - m e t h o d - 0x00, 0x00, 0x00, 0x03, 0x67, 0x65, 0x74, 0x00, // - - - - g e t - - 0x00, 0x00, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, // - - - s t a t u - 0x73, 0x00, 0x00, 0x00, 0x06, 0x32, 0x30, 0x30, // s - - - - 2 0 0 - 0x20, 0x4f, 0x4b, 0x00, 0x00, 0x00, 0x07, 0x76, // - O K - - - - v - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x00, 0x00, // e r s i o n - - - 0x00, 0x08, 0x48, 0x54, 0x54, 0x50, 0x2f, 0x31, // - - H T T P - 1 - 0x2e, 0x31, 0x00, 0x00, 0x00, 0x03, 0x75, 0x72, // - 1 - - - - u r - 0x6c, 0x00, 0x00, 0x00, 0x06, 0x70, 0x75, 0x62, // l - - - - p u b - 0x6c, 0x69, 0x63, 0x00, 0x00, 0x00, 0x0a, 0x73, // l i c - - - - s - 0x65, 0x74, 0x2d, 0x63, 0x6f, 0x6f, 0x6b, 0x69, // e t - c o o k i - 0x65, 0x00, 0x00, 0x00, 0x0a, 0x6b, 0x65, 0x65, // e - - - - k e e - 0x70, 0x2d, 0x61, 0x6c, 0x69, 0x76, 0x65, 0x00, // p - a l i v e - - 0x00, 0x00, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, // - - - o r i g i - 0x6e, 0x31, 0x30, 0x30, 0x31, 0x30, 0x31, 0x32, // n 1 0 0 1 0 1 2 - 0x30, 0x31, 0x32, 0x30, 0x32, 0x32, 0x30, 0x35, // 0 1 2 0 2 2 0 5 - 0x32, 0x30, 0x36, 0x33, 0x30, 0x30, 0x33, 0x30, // 2 0 6 3 0 0 3 0 - 0x32, 0x33, 0x30, 0x33, 0x33, 0x30, 0x34, 0x33, // 2 3 0 3 3 0 4 3 - 0x30, 0x35, 0x33, 0x30, 0x36, 0x33, 0x30, 0x37, // 0 5 3 0 6 3 0 7 - 0x34, 0x30, 0x32, 0x34, 0x30, 0x35, 0x34, 0x30, // 4 0 2 4 0 5 4 0 - 0x36, 0x34, 0x30, 0x37, 0x34, 0x30, 0x38, 0x34, // 6 4 0 7 4 0 8 4 - 0x30, 0x39, 0x34, 0x31, 0x30, 0x34, 0x31, 0x31, // 0 9 4 1 0 4 1 1 - 0x34, 0x31, 0x32, 0x34, 0x31, 0x33, 0x34, 0x31, // 4 1 2 4 1 3 4 1 - 0x34, 0x34, 0x31, 0x35, 0x34, 0x31, 0x36, 0x34, // 4 4 1 5 4 1 6 4 - 0x31, 0x37, 0x35, 0x30, 0x32, 0x35, 0x30, 0x34, // 1 7 5 0 2 5 0 4 - 0x35, 0x30, 0x35, 0x32, 0x30, 0x33, 0x20, 0x4e, // 5 0 5 2 0 3 - N - 0x6f, 0x6e, 0x2d, 0x41, 0x75, 0x74, 0x68, 0x6f, // o n - A u t h o - 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, // r i t a t i v e - 0x20, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, // - I n f o r m a - 0x74, 0x69, 0x6f, 0x6e, 0x32, 0x30, 0x34, 0x20, // t i o n 2 0 4 - - 0x4e, 0x6f, 0x20, 0x43, 0x6f, 0x6e, 0x74, 0x65, // N o - C o n t e - 0x6e, 0x74, 0x33, 0x30, 0x31, 0x20, 0x4d, 0x6f, // n t 3 0 1 - M o - 0x76, 0x65, 0x64, 0x20, 0x50, 0x65, 0x72, 0x6d, // v e d - P e r m - 0x61, 0x6e, 0x65, 0x6e, 0x74, 0x6c, 0x79, 0x34, // a n e n t l y 4 - 0x30, 0x30, 0x20, 0x42, 0x61, 0x64, 0x20, 0x52, // 0 0 - B a d - R - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x34, 0x30, // e q u e s t 4 0 - 0x31, 0x20, 0x55, 0x6e, 0x61, 0x75, 0x74, 0x68, // 1 - U n a u t h - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x34, 0x30, // o r i z e d 4 0 - 0x33, 0x20, 0x46, 0x6f, 0x72, 0x62, 0x69, 0x64, // 3 - F o r b i d - 0x64, 0x65, 0x6e, 0x34, 0x30, 0x34, 0x20, 0x4e, // d e n 4 0 4 - N - 0x6f, 0x74, 0x20, 0x46, 0x6f, 0x75, 0x6e, 0x64, // o t - F o u n d - 0x35, 0x30, 0x30, 0x20, 0x49, 0x6e, 0x74, 0x65, // 5 0 0 - I n t e - 0x72, 0x6e, 0x61, 0x6c, 0x20, 0x53, 0x65, 0x72, // r n a l - S e r - 0x76, 0x65, 0x72, 0x20, 0x45, 0x72, 0x72, 0x6f, // v e r - E r r o - 0x72, 0x35, 0x30, 0x31, 0x20, 0x4e, 0x6f, 0x74, // r 5 0 1 - N o t - 0x20, 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, // - I m p l e m e - 0x6e, 0x74, 0x65, 0x64, 0x35, 0x30, 0x33, 0x20, // n t e d 5 0 3 - - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x20, // S e r v i c e - - 0x55, 0x6e, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, // U n a v a i l a - 0x62, 0x6c, 0x65, 0x4a, 0x61, 0x6e, 0x20, 0x46, // b l e J a n - F - 0x65, 0x62, 0x20, 0x4d, 0x61, 0x72, 0x20, 0x41, // e b - M a r - A - 0x70, 0x72, 0x20, 0x4d, 0x61, 0x79, 0x20, 0x4a, // p r - M a y - J - 0x75, 0x6e, 0x20, 0x4a, 0x75, 0x6c, 0x20, 0x41, // u n - J u l - A - 0x75, 0x67, 0x20, 0x53, 0x65, 0x70, 0x74, 0x20, // u g - S e p t - - 0x4f, 0x63, 0x74, 0x20, 0x4e, 0x6f, 0x76, 0x20, // O c t - N o v - - 0x44, 0x65, 0x63, 0x20, 0x30, 0x30, 0x3a, 0x30, // D e c - 0 0 - 0 - 0x30, 0x3a, 0x30, 0x30, 0x20, 0x4d, 0x6f, 0x6e, // 0 - 0 0 - M o n - 0x2c, 0x20, 0x54, 0x75, 0x65, 0x2c, 0x20, 0x57, // - - T u e - - W - 0x65, 0x64, 0x2c, 0x20, 0x54, 0x68, 0x75, 0x2c, // e d - - T h u - - 0x20, 0x46, 0x72, 0x69, 0x2c, 0x20, 0x53, 0x61, // - F r i - - S a - 0x74, 0x2c, 0x20, 0x53, 0x75, 0x6e, 0x2c, 0x20, // t - - S u n - - - 0x47, 0x4d, 0x54, 0x63, 0x68, 0x75, 0x6e, 0x6b, // G M T c h u n k - 0x65, 0x64, 0x2c, 0x74, 0x65, 0x78, 0x74, 0x2f, // e d - t e x t - - 0x68, 0x74, 0x6d, 0x6c, 0x2c, 0x69, 0x6d, 0x61, // h t m l - i m a - 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x2c, 0x69, // g e - p n g - i - 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x6a, 0x70, 0x67, // m a g e - j p g - 0x2c, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x67, // - i m a g e - g - 0x69, 0x66, 0x2c, 0x61, 0x70, 0x70, 0x6c, 0x69, // i f - a p p l i - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x78, // c a t i o n - x - 0x6d, 0x6c, 0x2c, 0x61, 0x70, 0x70, 0x6c, 0x69, // m l - a p p l i - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x78, // c a t i o n - x - 0x68, 0x74, 0x6d, 0x6c, 0x2b, 0x78, 0x6d, 0x6c, // h t m l - x m l - 0x2c, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, // - t e x t - p l - 0x61, 0x69, 0x6e, 0x2c, 0x74, 0x65, 0x78, 0x74, // a i n - t e x t - 0x2f, 0x6a, 0x61, 0x76, 0x61, 0x73, 0x63, 0x72, // - j a v a s c r - 0x69, 0x70, 0x74, 0x2c, 0x70, 0x75, 0x62, 0x6c, // i p t - p u b l - 0x69, 0x63, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, // i c p r i v a t - 0x65, 0x6d, 0x61, 0x78, 0x2d, 0x61, 0x67, 0x65, // e m a x - a g e - 0x3d, 0x67, 0x7a, 0x69, 0x70, 0x2c, 0x64, 0x65, // - g z i p - d e - 0x66, 0x6c, 0x61, 0x74, 0x65, 0x2c, 0x73, 0x64, // f l a t e - s d - 0x63, 0x68, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, // c h c h a r s e - 0x74, 0x3d, 0x75, 0x74, 0x66, 0x2d, 0x38, 0x63, // t - u t f - 8 c - 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x3d, 0x69, // h a r s e t - i - 0x73, 0x6f, 0x2d, 0x38, 0x38, 0x35, 0x39, 0x2d, // s o - 8 8 5 9 - - 0x31, 0x2c, 0x75, 0x74, 0x66, 0x2d, 0x2c, 0x2a, // 1 - u t f - - - - 0x2c, 0x65, 0x6e, 0x71, 0x3d, 0x30, 0x2e, // - e n q - 0 - -} diff --git a/k8s-operator/sessionrecording/tsrecorder/tsrecorder.go b/k8s-operator/sessionrecording/tsrecorder/tsrecorder.go deleted file mode 100644 index af5fcb8da641a..0000000000000 --- a/k8s-operator/sessionrecording/tsrecorder/tsrecorder.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// Package tsrecorder contains functionality for connecting to a tsrecorder instance. -package tsrecorder - -import ( - "encoding/json" - "fmt" - "io" - "sync" - "time" - - "github.com/pkg/errors" - "go.uber.org/zap" - "tailscale.com/sessionrecording" - "tailscale.com/tstime" -) - -func New(conn io.WriteCloser, clock tstime.Clock, start time.Time, failOpen bool, logger *zap.SugaredLogger) *Client { - return &Client{ - start: start, - clock: clock, - conn: conn, - failOpen: failOpen, - } -} - -// recorder knows how to send the provided bytes to the configured tsrecorder -// instance in asciinema format. -type Client struct { - start time.Time - clock tstime.Clock - - // failOpen specifies whether the session should be allowed to - // continue if writing to the recording fails. - failOpen bool - // failedOpen is set to true if the recording of this session failed and - // we should not attempt to send any more data. - failedOpen bool - - logger *zap.SugaredLogger - - mu sync.Mutex // guards writes to conn - conn io.WriteCloser // connection to a tsrecorder instance -} - -// WriteOutput sends terminal stdout and stderr to the tsrecorder. -// https://docs.asciinema.org/manual/asciicast/v2/#o-output-data-written-to-a-terminal -func (rec *Client) WriteOutput(p []byte) (err error) { - const outputEventCode = "o" - if len(p) == 0 { - return nil - } - return rec.write([]any{ - rec.clock.Now().Sub(rec.start).Seconds(), - outputEventCode, - string(p)}) -} - -// WriteResize writes an asciinema resize message. This can be called if -// terminal size has changed. -// https://docs.asciinema.org/manual/asciicast/v2/#r-resize -func (rec *Client) WriteResize(height, width int) (err error) { - const resizeEventCode = "r" - p := fmt.Sprintf("%dx%d", height, width) - return rec.write([]any{ - rec.clock.Now().Sub(rec.start).Seconds(), - resizeEventCode, - string(p)}) -} - -// WriteCastHeaders writes asciinema CastHeader. This must be called once, -// before any payload is sent to the tsrecorder. -// https://docs.asciinema.org/manual/asciicast/v2/#header -func (rec *Client) WriteCastHeader(ch sessionrecording.CastHeader) error { - return rec.write(ch) -} - -// write writes the data to session recorder. If recording fails and policy is -// 'fail open', sets the state to failed and does not attempt to write any more -// data during this session. -func (rec *Client) write(data any) error { - if rec.failedOpen { - return nil - } - j, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("error marshalling data as json: %v", err) - } - j = append(j, '\n') - if err := rec.writeCastLine(j); err != nil { - if !rec.failOpen { - return fmt.Errorf("error writing payload to recorder: %w", err) - } - rec.logger.Infof("error writing to tsrecorder: %v. Failure policy is to fail open, so rest of session contents will not be recorded.", err) - rec.failedOpen = true - } - return nil -} - -func (rec *Client) Close() error { - rec.mu.Lock() - defer rec.mu.Unlock() - if rec.conn == nil { - return nil - } - err := rec.conn.Close() - rec.conn = nil - return err -} - -// writeToRecorder sends bytes to the tsrecorder. The bytes should be in -// asciinema format. -func (c *Client) writeCastLine(j []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.conn == nil { - return errors.New("recorder closed") - } - _, err := c.conn.Write(j) - if err != nil { - return fmt.Errorf("recorder write error: %w", err) - } - return nil -} - -type ResizeMsg struct { - Width int `json:"width"` - Height int `json:"height"` -} diff --git a/k8s-operator/sessionrecording/ws/conn.go b/k8s-operator/sessionrecording/ws/conn.go deleted file mode 100644 index 86029f67b1f13..0000000000000 --- a/k8s-operator/sessionrecording/ws/conn.go +++ /dev/null @@ -1,345 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// package ws has functionality to parse 'kubectl exec' sessions streamed using -// WebSocket protocol. -package ws - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "sync" - - "go.uber.org/zap" - "k8s.io/apimachinery/pkg/util/remotecommand" - "tailscale.com/k8s-operator/sessionrecording/tsrecorder" - "tailscale.com/sessionrecording" - "tailscale.com/util/multierr" -) - -// New wraps the provided network connection and returns a connection whose reads and writes will get triggered as data is received on the hijacked connection. -// The connection must be a hijacked connection for a 'kubectl exec' session using WebSocket protocol and a *.channel.k8s.io subprotocol. -// The hijacked connection is used to transmit *.channel.k8s.io streams between Kubernetes client ('kubectl') and the destination proxy controlled by Kubernetes. -// Data read from the underlying network connection is data sent via one of the streams from the client to the container. -// Data written to the underlying connection is data sent from the container to the client. -// We parse the data and send everything for the stdout/stderr streams to the configured tsrecorder as an asciinema recording with the provided header. -// https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/4006-transition-spdy-to-websockets#proposal-new-remotecommand-sub-protocol-version---v5channelk8sio -func New(c net.Conn, rec *tsrecorder.Client, ch sessionrecording.CastHeader, hasTerm bool, log *zap.SugaredLogger) net.Conn { - return &conn{ - Conn: c, - rec: rec, - ch: ch, - hasTerm: hasTerm, - log: log, - initialTermSizeSet: make(chan struct{}, 1), - } -} - -// conn is a wrapper around net.Conn. It reads the bytestream -// for a 'kubectl exec' session, sends session recording data to the configured -// recorder and forwards the raw bytes to the original destination. -// A new conn is created per session. -// conn only knows to how to read a 'kubectl exec' session that is streamed using WebSocket protocol. -// https://www.rfc-editor.org/rfc/rfc6455 -type conn struct { - net.Conn - // rec knows how to send data to a tsrecorder instance. - rec *tsrecorder.Client - - // The following fields are related to sending asciinema CastHeader. - // CastHeader must be sent before any payload. If the session has a - // terminal attached, the CastHeader must have '.Width' and '.Height' - // fields set for the tsrecorder UI to be able to play the recording. - // For 'kubectl exec' sessions, terminal width and height are sent as a - // resize message on resize stream from the client when the session - // starts as well as at any time the client detects a terminal change. - // We can intercept the resize message on Read calls. As there is no - // guarantee that the resize message from client will be intercepted - // before server writes stdout messages that we must record, we need to - // ensure that parsing stdout/stderr messages written to the connection - // waits till a resize message has been received and a CastHeader with - // correct terminal dimensions can be written. - - // ch is asciinema CastHeader for the current session. - // https://docs.asciinema.org/manual/asciicast/v2/#header - ch sessionrecording.CastHeader - // writeCastHeaderOnce is used to ensure CastHeader gets sent to tsrecorder once. - writeCastHeaderOnce sync.Once - hasTerm bool // whether the session has TTY attached - // initialTermSizeSet channel gets sent a value once, when the Read has - // received a resize message and set the initial terminal size. It must - // be set to a buffered channel to prevent Reads being blocked on the - // first stdout/stderr write reading from the channel. - initialTermSizeSet chan struct{} - // sendInitialTermSizeSetOnce is used to ensure that a value is sent to - // initialTermSizeSet channel only once, when the initial resize message - // is received. - sendInitialTermSizeSetOnce sync.Once - - log *zap.SugaredLogger - - rmu sync.Mutex // sequences reads - // currentReadMsg contains parsed contents of a websocket binary data message that - // is currently being read from the underlying net.Conn. - currentReadMsg *message - // readBuf contains bytes for a currently parsed binary data message - // read from the underlying conn. If the message is masked, it is - // unmasked in place, so having this buffer allows us to avoid modifying - // the original byte array. - readBuf bytes.Buffer - - wmu sync.Mutex // sequences writes - closed bool // connection is closed - // writeBuf contains bytes for a currently parsed binary data message - // being written to the underlying conn. If the message is masked, it is - // unmasked in place, so having this buffer allows us to avoid modifying - // the original byte array. - writeBuf bytes.Buffer - // currentWriteMsg contains parsed contents of a websocket binary data message that - // is currently being written to the underlying net.Conn. - currentWriteMsg *message -} - -// Read reads bytes from the original connection and parses them as websocket -// message fragments. -// Bytes read from the original connection are the bytes sent from the Kubernetes client (kubectl) to the destination container via kubelet. - -// If the message is for the resize stream, sets the width -// and height of the CastHeader for this connection. -// The fragment can be incomplete. -func (c *conn) Read(b []byte) (int, error) { - c.rmu.Lock() - defer c.rmu.Unlock() - n, err := c.Conn.Read(b) - if err != nil { - // It seems that we sometimes get a wrapped io.EOF, but the - // caller checks for io.EOF with ==. - if errors.Is(err, io.EOF) { - err = io.EOF - } - return 0, err - } - if n == 0 { - c.log.Debug("[unexpected] Read called for 0 length bytes") - return 0, nil - } - - typ := messageType(opcode(b)) - if (typ == noOpcode && c.readMsgIsIncomplete()) || c.readBufHasIncompleteFragment() { // subsequent fragment - if typ, err = c.curReadMsgType(); err != nil { - return 0, err - } - } - - // A control message can not be fragmented and we are not interested in - // these messages. Just return. - if isControlMessage(typ) { - return n, nil - } - - // The only data message type that Kubernetes supports is binary message. - // If we received another message type, return and let the API server close the connection. - // https://github.com/kubernetes/client-go/blob/release-1.30/tools/remotecommand/websocket.go#L281 - if typ != binaryMessage { - c.log.Infof("[unexpected] received a data message with a type that is not binary message type %v", typ) - return n, nil - } - - readMsg := &message{typ: typ} // start a new message... - // ... or pick up an already started one if the previous fragment was not final. - if c.readMsgIsIncomplete() || c.readBufHasIncompleteFragment() { - readMsg = c.currentReadMsg - } - - if _, err := c.readBuf.Write(b[:n]); err != nil { - return 0, fmt.Errorf("[unexpected] error writing message contents to read buffer: %w", err) - } - - ok, err := readMsg.Parse(c.readBuf.Bytes(), c.log) - if err != nil { - return 0, fmt.Errorf("error parsing message: %v", err) - } - if !ok { // incomplete fragment - return n, nil - } - c.readBuf.Next(len(readMsg.raw)) - - if readMsg.isFinalized && !c.readMsgIsIncomplete() { - // Stream IDs for websocket streams are static. - // https://github.com/kubernetes/client-go/blob/v0.30.0-rc.1/tools/remotecommand/websocket.go#L218 - if readMsg.streamID.Load() == remotecommand.StreamResize { - var msg tsrecorder.ResizeMsg - if err = json.Unmarshal(readMsg.payload, &msg); err != nil { - return 0, fmt.Errorf("error umarshalling resize message: %w", err) - } - - c.ch.Width = msg.Width - c.ch.Height = msg.Height - - // If this is initial resize message, the width and - // height will be sent in the CastHeader. If this is a - // subsequent resize message, we need to send asciinema - // resize message. - var isInitialResize bool - c.sendInitialTermSizeSetOnce.Do(func() { - isInitialResize = true - close(c.initialTermSizeSet) // unblock sending of CastHeader - }) - if !isInitialResize { - if err := c.rec.WriteResize(c.ch.Height, c.ch.Width); err != nil { - return 0, fmt.Errorf("error writing resize message: %w", err) - } - } - } - } - c.currentReadMsg = readMsg - return n, nil -} - -// Write parses the written bytes as WebSocket message fragment. If the message -// is for stdout or stderr streams, it is written to the configured tsrecorder. -// A message fragment can be incomplete. -func (c *conn) Write(b []byte) (int, error) { - c.wmu.Lock() - defer c.wmu.Unlock() - if len(b) == 0 { - c.log.Debug("[unexpected] Write called with 0 bytes") - return 0, nil - } - - typ := messageType(opcode(b)) - // If we are in process of parsing a message fragment, the received - // bytes are not structured as a message fragment and can not be used to - // determine a message fragment. - if c.writeBufHasIncompleteFragment() { // buffer contains previous incomplete fragment - var err error - if typ, err = c.curWriteMsgType(); err != nil { - return 0, err - } - } - - if isControlMessage(typ) { - return c.Conn.Write(b) - } - - writeMsg := &message{typ: typ} // start a new message... - // ... or continue the existing one if it has not been finalized. - if c.writeMsgIsIncomplete() || c.writeBufHasIncompleteFragment() { - writeMsg = c.currentWriteMsg - } - - if _, err := c.writeBuf.Write(b); err != nil { - c.log.Errorf("write: error writing to write buf: %v", err) - return 0, fmt.Errorf("[unexpected] error writing to internal write buffer: %w", err) - } - - ok, err := writeMsg.Parse(c.writeBuf.Bytes(), c.log) - if err != nil { - c.log.Errorf("write: parsing a message errored: %v", err) - return 0, fmt.Errorf("write: error parsing message: %v", err) - } - c.currentWriteMsg = writeMsg - if !ok { // incomplete fragment - return len(b), nil - } - c.writeBuf.Next(len(writeMsg.raw)) // advance frame - - if len(writeMsg.payload) != 0 && writeMsg.isFinalized { - if writeMsg.streamID.Load() == remotecommand.StreamStdOut || writeMsg.streamID.Load() == remotecommand.StreamStdErr { - var err error - c.writeCastHeaderOnce.Do(func() { - // If this is a session with a terminal attached, - // we must wait for the terminal width and - // height to be parsed from a resize message - // before sending CastHeader, else tsrecorder - // will not be able to play this recording. - if c.hasTerm { - c.log.Debug("waiting for terminal size to be set before starting to send recorded data") - <-c.initialTermSizeSet - } - err = c.rec.WriteCastHeader(c.ch) - }) - if err != nil { - return 0, fmt.Errorf("error writing CastHeader: %w", err) - } - if err := c.rec.WriteOutput(writeMsg.payload); err != nil { - return 0, fmt.Errorf("error writing message to recorder: %v", err) - } - } - } - _, err = c.Conn.Write(c.currentWriteMsg.raw) - if err != nil { - c.log.Errorf("write: error writing to conn: %v", err) - } - return len(b), nil -} - -func (c *conn) Close() error { - c.wmu.Lock() - defer c.wmu.Unlock() - if c.closed { - return nil - } - c.closed = true - connCloseErr := c.Conn.Close() - recCloseErr := c.rec.Close() - return multierr.New(connCloseErr, recCloseErr) -} - -// writeBufHasIncompleteFragment returns true if the latest data message -// fragment written to the connection was incomplete and the following write -// must be the remaining payload bytes of that fragment. -func (c *conn) writeBufHasIncompleteFragment() bool { - return c.writeBuf.Len() != 0 -} - -// readBufHasIncompleteFragment returns true if the latest data message -// fragment read from the connection was incomplete and the following read -// must be the remaining payload bytes of that fragment. -func (c *conn) readBufHasIncompleteFragment() bool { - return c.readBuf.Len() != 0 -} - -// writeMsgIsIncomplete returns true if the latest WebSocket message written to -// the connection was fragmented and the next data message fragment written to -// the connection must be a fragment of that message. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.4 -func (c *conn) writeMsgIsIncomplete() bool { - return c.currentWriteMsg != nil && !c.currentWriteMsg.isFinalized -} - -// readMsgIsIncomplete returns true if the latest WebSocket message written to -// the connection was fragmented and the next data message fragment written to -// the connection must be a fragment of that message. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.4 -func (c *conn) readMsgIsIncomplete() bool { - return c.currentReadMsg != nil && !c.currentReadMsg.isFinalized -} -func (c *conn) curReadMsgType() (messageType, error) { - if c.currentReadMsg != nil { - return c.currentReadMsg.typ, nil - } - return 0, errors.New("[unexpected] attempted to determine type for nil message") -} - -func (c *conn) curWriteMsgType() (messageType, error) { - if c.currentWriteMsg != nil { - return c.currentWriteMsg.typ, nil - } - return 0, errors.New("[unexpected] attempted to determine type for nil message") -} - -// opcode reads the websocket message opcode that denotes the message type. -// opcode is contained in bits [4-8] of the message. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.2 -func opcode(b []byte) int { - // 0xf = 00001111; b & 00001111 zeroes out bits [0 - 3] of b - var mask byte = 0xf - return int(b[0] & mask) -} diff --git a/k8s-operator/sessionrecording/ws/conn_test.go b/k8s-operator/sessionrecording/ws/conn_test.go deleted file mode 100644 index 11174480ba605..0000000000000 --- a/k8s-operator/sessionrecording/ws/conn_test.go +++ /dev/null @@ -1,276 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package ws - -import ( - "fmt" - "reflect" - "testing" - - "go.uber.org/zap" - "k8s.io/apimachinery/pkg/util/remotecommand" - "tailscale.com/k8s-operator/sessionrecording/fakes" - "tailscale.com/k8s-operator/sessionrecording/tsrecorder" - "tailscale.com/sessionrecording" - "tailscale.com/tstest" -) - -func Test_conn_Read(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - // Resize stream ID + {"width": 10, "height": 20} - testResizeMsg := []byte{byte(remotecommand.StreamResize), 0x7b, 0x22, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, 0x3a, 0x31, 0x30, 0x2c, 0x22, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x22, 0x3a, 0x32, 0x30, 0x7d} - lenResizeMsgPayload := byte(len(testResizeMsg)) - - tests := []struct { - name string - inputs [][]byte - wantWidth int - wantHeight int - }{ - { - name: "single_read_control_message", - inputs: [][]byte{{0x88, 0x0}}, - }, - { - name: "single_read_resize_message", - inputs: [][]byte{append([]byte{0x82, lenResizeMsgPayload}, testResizeMsg...)}, - wantWidth: 10, - wantHeight: 20, - }, - { - name: "two_reads_resize_message", - inputs: [][]byte{{0x2, 0x9, 0x4, 0x7b, 0x22, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22}, {0x80, 0x11, 0x4, 0x3a, 0x31, 0x30, 0x2c, 0x22, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x22, 0x3a, 0x32, 0x30, 0x7d}}, - wantWidth: 10, - wantHeight: 20, - }, - { - name: "three_reads_resize_message_with_split_fragment", - inputs: [][]byte{{0x2, 0x9, 0x4, 0x7b, 0x22, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22}, {0x80, 0x11, 0x4, 0x3a, 0x31, 0x30, 0x2c, 0x22, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74}, {0x22, 0x3a, 0x32, 0x30, 0x7d}}, - wantWidth: 10, - wantHeight: 20, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc := &fakes.TestConn{} - tc.ResetReadBuf() - c := &conn{ - Conn: tc, - log: zl.Sugar(), - } - for i, input := range tt.inputs { - c.initialTermSizeSet = make(chan struct{}) - if err := tc.WriteReadBufBytes(input); err != nil { - t.Fatalf("writing bytes to test conn: %v", err) - } - _, err := c.Read(make([]byte, len(input))) - if err != nil { - t.Errorf("[%d] conn.Read() errored %v", i, err) - return - } - } - if tt.wantHeight != 0 || tt.wantWidth != 0 { - if tt.wantWidth != c.ch.Width { - t.Errorf("wants width: %v, got %v", tt.wantWidth, c.ch.Width) - } - if tt.wantHeight != c.ch.Height { - t.Errorf("want height: %v, got %v", tt.wantHeight, c.ch.Height) - } - } - }) - } -} - -func Test_conn_Write(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatal(err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - tests := []struct { - name string - inputs [][]byte - wantForwarded []byte - wantRecorded []byte - firstWrite bool - width int - height int - hasTerm bool - sendInitialResize bool - }{ - { - name: "single_write_control_frame", - inputs: [][]byte{{0x88, 0x0}}, - wantForwarded: []byte{0x88, 0x0}, - }, - { - name: "single_write_stdout_data_message", - inputs: [][]byte{{0x82, 0x3, 0x1, 0x7, 0x8}}, - wantForwarded: []byte{0x82, 0x3, 0x1, 0x7, 0x8}, - wantRecorded: fakes.CastLine(t, []byte{0x7, 0x8}, cl), - }, - { - name: "single_write_stderr_data_message", - inputs: [][]byte{{0x82, 0x3, 0x2, 0x7, 0x8}}, - wantForwarded: []byte{0x82, 0x3, 0x2, 0x7, 0x8}, - wantRecorded: fakes.CastLine(t, []byte{0x7, 0x8}, cl), - }, - { - name: "single_write_stdin_data_message", - inputs: [][]byte{{0x82, 0x3, 0x0, 0x7, 0x8}}, - wantForwarded: []byte{0x82, 0x3, 0x0, 0x7, 0x8}, - }, - { - name: "single_write_stdout_data_message_with_cast_header", - inputs: [][]byte{{0x82, 0x3, 0x1, 0x7, 0x8}}, - wantForwarded: []byte{0x82, 0x3, 0x1, 0x7, 0x8}, - wantRecorded: append(fakes.AsciinemaResizeMsg(t, 10, 20), fakes.CastLine(t, []byte{0x7, 0x8}, cl)...), - width: 10, - height: 20, - firstWrite: true, - }, - { - name: "two_writes_stdout_data_message", - inputs: [][]byte{{0x2, 0x3, 0x1, 0x7, 0x8}, {0x80, 0x6, 0x1, 0x1, 0x2, 0x3, 0x4, 0x5}}, - wantForwarded: []byte{0x2, 0x3, 0x1, 0x7, 0x8, 0x80, 0x6, 0x1, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: fakes.CastLine(t, []byte{0x7, 0x8, 0x1, 0x2, 0x3, 0x4, 0x5}, cl), - }, - { - name: "three_writes_stdout_data_message_with_split_fragment", - inputs: [][]byte{{0x2, 0x3, 0x1, 0x7, 0x8}, {0x80, 0x6, 0x1, 0x1, 0x2, 0x3}, {0x4, 0x5}}, - wantForwarded: []byte{0x2, 0x3, 0x1, 0x7, 0x8, 0x80, 0x6, 0x1, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: fakes.CastLine(t, []byte{0x7, 0x8, 0x1, 0x2, 0x3, 0x4, 0x5}, cl), - }, - { - name: "three_writes_stdout_data_message_with_split_fragment_cast_header_with_terminal", - inputs: [][]byte{{0x2, 0x3, 0x1, 0x7, 0x8}, {0x80, 0x6, 0x1, 0x1, 0x2, 0x3}, {0x4, 0x5}}, - wantForwarded: []byte{0x2, 0x3, 0x1, 0x7, 0x8, 0x80, 0x6, 0x1, 0x1, 0x2, 0x3, 0x4, 0x5}, - wantRecorded: append(fakes.AsciinemaResizeMsg(t, 10, 20), fakes.CastLine(t, []byte{0x7, 0x8, 0x1, 0x2, 0x3, 0x4, 0x5}, cl)...), - height: 20, - width: 10, - hasTerm: true, - firstWrite: true, - sendInitialResize: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc := &fakes.TestConn{} - sr := &fakes.TestSessionRecorder{} - rec := tsrecorder.New(sr, cl, cl.Now(), true, zl.Sugar()) - c := &conn{ - Conn: tc, - log: zl.Sugar(), - ch: sessionrecording.CastHeader{ - Width: tt.width, - Height: tt.height, - }, - rec: rec, - initialTermSizeSet: make(chan struct{}), - hasTerm: tt.hasTerm, - } - if !tt.firstWrite { - // This test case does not intend to test that cast header gets written once. - c.writeCastHeaderOnce.Do(func() {}) - } - if tt.sendInitialResize { - close(c.initialTermSizeSet) - } - for i, input := range tt.inputs { - _, err := c.Write(input) - if err != nil { - t.Fatalf("[%d] conn.Write() errored: %v", i, err) - } - } - // Assert that the expected bytes have been forwarded to the original destination. - gotForwarded := tc.WriteBufBytes() - if !reflect.DeepEqual(gotForwarded, tt.wantForwarded) { - t.Errorf("expected bytes not forwarded, wants\n%x\ngot\n%x", tt.wantForwarded, gotForwarded) - } - - // Assert that the expected bytes have been forwarded to the session recorder. - gotRecorded := sr.Bytes() - if !reflect.DeepEqual(gotRecorded, tt.wantRecorded) { - t.Errorf("expected bytes not recorded, wants\n%b\ngot\n%b", tt.wantRecorded, gotRecorded) - } - }) - } -} - -// Test_conn_ReadRand tests reading arbitrarily generated byte slices from conn to -// test that we don't panic when parsing input from a broken or malicious -// client. -func Test_conn_ReadRand(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatalf("error creating a test logger: %v", err) - } - for i := range 100 { - tc := &fakes.TestConn{} - tc.ResetReadBuf() - c := &conn{ - Conn: tc, - log: zl.Sugar(), - } - bb := fakes.RandomBytes(t) - for j, input := range bb { - if err := tc.WriteReadBufBytes(input); err != nil { - t.Fatalf("[%d] writing bytes to test conn: %v", i, err) - } - f := func() { - c.Read(make([]byte, len(input))) - } - testPanic(t, f, fmt.Sprintf("[%d %d] Read panic parsing input of length %d first bytes: %v, current read message: %+#v", i, j, len(input), firstBytes(input), c.currentReadMsg)) - } - } -} - -// Test_conn_WriteRand calls conn.Write with an arbitrary input to validate that it does not -// panic. -func Test_conn_WriteRand(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatalf("error creating a test logger: %v", err) - } - cl := tstest.NewClock(tstest.ClockOpts{}) - sr := &fakes.TestSessionRecorder{} - rec := tsrecorder.New(sr, cl, cl.Now(), true, zl.Sugar()) - for i := range 100 { - tc := &fakes.TestConn{} - c := &conn{ - Conn: tc, - log: zl.Sugar(), - rec: rec, - } - bb := fakes.RandomBytes(t) - for j, input := range bb { - f := func() { - c.Write(input) - } - testPanic(t, f, fmt.Sprintf("[%d %d] Write: panic parsing input of length %d first bytes %b current write message %+#v", i, j, len(input), firstBytes(input), c.currentWriteMsg)) - } - } -} - -func testPanic(t *testing.T, f func(), msg string) { - t.Helper() - defer func() { - if r := recover(); r != nil { - t.Fatal(msg, r) - } - }() - f() -} - -func firstBytes(b []byte) []byte { - if len(b) < 10 { - return b - } - return b[:10] -} diff --git a/k8s-operator/sessionrecording/ws/message.go b/k8s-operator/sessionrecording/ws/message.go deleted file mode 100644 index 713febec76ae8..0000000000000 --- a/k8s-operator/sessionrecording/ws/message.go +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package ws - -import ( - "encoding/binary" - "fmt" - "sync/atomic" - - "github.com/pkg/errors" - "go.uber.org/zap" - - "golang.org/x/net/websocket" -) - -const ( - noOpcode messageType = 0 // continuation frame for fragmented messages - binaryMessage messageType = 2 -) - -// messageType is the type of a websocket data or control message as defined by opcode. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.2 -// Known types of control messages are close, ping and pong. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.5 -// The only data message type supported by Kubernetes is binary message -// https://github.com/kubernetes/client-go/blob/v0.30.0-rc.1/tools/remotecommand/websocket.go#L281 -type messageType int - -// message is a parsed Websocket Message. -type message struct { - // payload is the contents of the so far parsed Websocket - // data Message payload, potentially from multiple fragments written by - // multiple invocations of Parse. As per RFC 6455 We can assume that the - // fragments will always arrive in order and data messages will not be - // interleaved. - payload []byte - - // isFinalized is set to true if msgPayload contains full contents of - // the message (the final fragment has been received). - isFinalized bool - - // streamID is the stream to which the message belongs, i.e stdin, stout - // etc. It is one of the stream IDs defined in - // https://github.com/kubernetes/apimachinery/blob/73d12d09c5be8703587b5127416eb83dc3b7e182/pkg/util/httpstream/wsstream/doc.go#L23-L36 - streamID atomic.Uint32 - - // typ is the type of a WebsocketMessage as defined by its opcode - // https://www.rfc-editor.org/rfc/rfc6455#section-5.2 - typ messageType - raw []byte -} - -// Parse accepts a websocket message fragment as a byte slice and parses its contents. -// It returns true if the fragment is complete, false if the fragment is incomplete. -// If the fragment is incomplete, Parse will be called again with the same fragment + more bytes when those are received. -// If the fragment is complete, it will be parsed into msg. -// A complete fragment can be: -// - a fragment that consists of a whole message -// - an initial fragment for a message for which we expect more fragments -// - a subsequent fragment for a message that we are currently parsing and whose so-far parsed contents are stored in msg. -// Parse must not be called with bytes that don't contain fragment header (so, no less than 2 bytes). -// 0 1 2 3 -// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 -// +-+-+-+-+-------+-+-------------+-------------------------------+ -// |F|R|R|R| opcode|M| Payload len | Extended payload length | -// |I|S|S|S| (4) |A| (7) | (16/64) | -// |N|V|V|V| |S| | (if payload len==126/127) | -// | |1|2|3| |K| | | -// +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + -// | Extended payload length continued, if payload len == 127 | -// + - - - - - - - - - - - - - - - +-------------------------------+ -// | |Masking-key, if MASK set to 1 | -// +-------------------------------+-------------------------------+ -// | Masking-key (continued) | Payload Data | -// +-------------------------------- - - - - - - - - - - - - - - - + -// : Payload Data continued ... : -// + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + -// | Payload Data continued ... | -// +---------------------------------------------------------------+ -// https://www.rfc-editor.org/rfc/rfc6455#section-5.2 -// -// Fragmentation rules: -// An unfragmented message consists of a single frame with the FIN -// bit set (Section 5.2) and an opcode other than 0. -// A fragmented message consists of a single frame with the FIN bit -// clear and an opcode other than 0, followed by zero or more frames -// with the FIN bit clear and the opcode set to 0, and terminated by -// a single frame with the FIN bit set and an opcode of 0. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.4 -func (msg *message) Parse(b []byte, log *zap.SugaredLogger) (bool, error) { - if len(b) < 2 { - return false, fmt.Errorf("[unexpected] Parse should not be called with less than 2 bytes, got %d bytes", len(b)) - } - if msg.typ != binaryMessage { - return false, fmt.Errorf("[unexpected] internal error: attempted to parse a message with type %d", msg.typ) - } - isInitialFragment := len(msg.raw) == 0 - - msg.isFinalized = isFinalFragment(b) - - maskSet := isMasked(b) - - payloadLength, payloadOffset, maskOffset, err := fragmentDimensions(b, maskSet) - if err != nil { - return false, fmt.Errorf("error determining payload length: %w", err) - } - log.Debugf("parse: parsing a message fragment with payload length: %d payload offset: %d maskOffset: %d mask set: %t, is finalized: %t, is initial fragment: %t", payloadLength, payloadOffset, maskOffset, maskSet, msg.isFinalized, isInitialFragment) - - if len(b) < int(payloadOffset+payloadLength) { // incomplete fragment - return false, nil - } - // TODO (irbekrm): perhaps only do this extra allocation if we know we - // will need to unmask? - msg.raw = make([]byte, int(payloadOffset)+int(payloadLength)) - copy(msg.raw, b[:payloadOffset+payloadLength]) - - // Extract the payload. - msgPayload := b[payloadOffset : payloadOffset+payloadLength] - - // Unmask the payload if needed. - // TODO (irbekrm): instead of unmasking all of the payload each time, - // determine if the payload is for a resize message early and skip - // unmasking the remaining bytes if not. - if maskSet { - m := b[maskOffset:payloadOffset] - var mask [4]byte - copy(mask[:], m) - maskBytes(mask, msgPayload) - } - - // Determine what stream the message is for. Stream ID of a Kubernetes - // streaming session is a 32bit integer, stored in the first byte of the - // message payload. - // https://github.com/kubernetes/apimachinery/commit/73d12d09c5be8703587b5127416eb83dc3b7e182#diff-291f96e8632d04d2d20f5fb00f6b323492670570d65434e8eac90c7a442d13bdR23-R36 - if len(msgPayload) == 0 { - return false, errors.New("[unexpected] received a message fragment with no stream ID") - } - - streamID := uint32(msgPayload[0]) - if !isInitialFragment && msg.streamID.Load() != streamID { - return false, fmt.Errorf("[unexpected] received message fragments with mismatched streamIDs %d and %d", msg.streamID.Load(), streamID) - } - msg.streamID.Store(streamID) - - // This is normal, Kubernetes seem to send a couple data messages with - // no payloads at the start. - if len(msgPayload) < 2 { - return true, nil - } - msgPayload = msgPayload[1:] // remove the stream ID byte - msg.payload = append(msg.payload, msgPayload...) - return true, nil -} - -// maskBytes applies mask to bytes in place. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.3 -func maskBytes(key [4]byte, b []byte) { - for i := range b { - b[i] = b[i] ^ key[i%4] - } -} - -// isControlMessage returns true if the message type is one of the known control -// frame message types. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.5 -func isControlMessage(t messageType) bool { - const ( - closeMessage messageType = 8 - pingMessage messageType = 9 - pongMessage messageType = 10 - ) - return t == closeMessage || t == pingMessage || t == pongMessage -} - -// isFinalFragment can be called with websocket message fragment and returns true if -// the fragment is the final fragment of a websocket message. -func isFinalFragment(b []byte) bool { - return extractFirstBit(b[0]) != 0 -} - -// isMasked can be called with a websocket message fragment and returns true if -// the payload of the message is masked. It uses the mask bit to determine if -// the payload is masked. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.3 -func isMasked(b []byte) bool { - return extractFirstBit(b[1]) != 0 -} - -// extractFirstBit extracts first bit of a byte by zeroing out all the other -// bits. -func extractFirstBit(b byte) byte { - return b & 0x80 -} - -// zeroFirstBit returns the provided byte with the first bit set to 0. -func zeroFirstBit(b byte) byte { - return b & 0x7f -} - -// fragmentDimensions returns payload length as well as payload offset and mask offset. -func fragmentDimensions(b []byte, maskSet bool) (payloadLength, payloadOffset, maskOffset uint64, _ error) { - - // payload length can be stored either in bits [9-15] or in bytes 2, 3 - // or in bytes 2, 3, 4, 5, 6, 7. - // https://www.rfc-editor.org/rfc/rfc6455#section-5.2 - // 0 1 2 3 - // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - // +-+-+-+-+-------+-+-------------+-------------------------------+ - // |F|R|R|R| opcode|M| Payload len | Extended payload length | - // |I|S|S|S| (4) |A| (7) | (16/64) | - // |N|V|V|V| |S| | (if payload len==126/127) | - // | |1|2|3| |K| | | - // +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + - // | Extended payload length continued, if payload len == 127 | - // + - - - - - - - - - - - - - - - +-------------------------------+ - // | |Masking-key, if MASK set to 1 | - // +-------------------------------+-------------------------------+ - payloadLengthIndicator := zeroFirstBit(b[1]) - switch { - case payloadLengthIndicator < 126: - maskOffset = 2 - payloadLength = uint64(payloadLengthIndicator) - case payloadLengthIndicator == 126: - maskOffset = 4 - if len(b) < int(maskOffset) { - return 0, 0, 0, fmt.Errorf("invalid message fragment- length indicator suggests that length is stored in bytes 2:4, but message length is only %d", len(b)) - } - payloadLength = uint64(binary.BigEndian.Uint16(b[2:4])) - case payloadLengthIndicator == 127: - maskOffset = 10 - if len(b) < int(maskOffset) { - return 0, 0, 0, fmt.Errorf("invalid message fragment- length indicator suggests that length is stored in bytes 2:10, but message length is only %d", len(b)) - } - payloadLength = binary.BigEndian.Uint64(b[2:10]) - default: - return 0, 0, 0, fmt.Errorf("unexpected payload length indicator value: %v", payloadLengthIndicator) - } - - // Ensure that a rogue or broken client doesn't cause us attempt to - // allocate a huge array by setting a high payload size. - // websocket.DefaultMaxPayloadBytes is the maximum payload size accepted - // by server side of this connection, so we can safely reject messages - // with larger payload size. - if payloadLength > websocket.DefaultMaxPayloadBytes { - return 0, 0, 0, fmt.Errorf("[unexpected]: too large payload size: %v", payloadLength) - } - - // Masking key can take up 0 or 4 bytes- we need to take that into - // account when determining payload offset. - // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - // .... - // + - - - - - - - - - - - - - - - +-------------------------------+ - // | |Masking-key, if MASK set to 1 | - // +-------------------------------+-------------------------------+ - // | Masking-key (continued) | Payload Data | - // + - - - - - - - - - - - - - - - +-------------------------------+ - // ... - if maskSet { - payloadOffset = maskOffset + 4 - } else { - payloadOffset = maskOffset - } - return -} diff --git a/k8s-operator/sessionrecording/ws/message_test.go b/k8s-operator/sessionrecording/ws/message_test.go deleted file mode 100644 index f634f86dc55c2..0000000000000 --- a/k8s-operator/sessionrecording/ws/message_test.go +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -package ws - -import ( - "encoding/binary" - "fmt" - "reflect" - "testing" - "time" - - "math/rand" - - "go.uber.org/zap" - "golang.org/x/net/websocket" -) - -func Test_msg_Parse(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatalf("error creating a test logger: %v", err) - } - testMask := [4]byte{1, 2, 3, 4} - bs126, bs126Len := bytesSlice2ByteLen(t) - bs127, bs127Len := byteSlice8ByteLen(t) - tests := []struct { - name string - b []byte - initialPayload []byte - wantPayload []byte - wantIsFinalized bool - wantStreamID uint32 - wantErr bool - }{ - { - name: "single_fragment_stdout_stream_no_payload_no_mask", - b: []byte{0x82, 0x1, 0x1}, - wantPayload: nil, - wantIsFinalized: true, - wantStreamID: 1, - }, - { - name: "single_fragment_stderr_steam_no_payload_has_mask", - b: append([]byte{0x82, 0x81, 0x1, 0x2, 0x3, 0x4}, maskedBytes(testMask, []byte{0x2})...), - wantPayload: nil, - wantIsFinalized: true, - wantStreamID: 2, - }, - { - name: "single_fragment_stdout_stream_no_mask_has_payload", - b: []byte{0x82, 0x3, 0x1, 0x7, 0x8}, - wantPayload: []byte{0x7, 0x8}, - wantIsFinalized: true, - wantStreamID: 1, - }, - { - name: "single_fragment_stdout_stream_has_mask_has_payload", - b: append([]byte{0x82, 0x83, 0x1, 0x2, 0x3, 0x4}, maskedBytes(testMask, []byte{0x1, 0x7, 0x8})...), - wantPayload: []byte{0x7, 0x8}, - wantIsFinalized: true, - wantStreamID: 1, - }, - { - name: "initial_fragment_stdout_stream_no_mask_has_payload", - b: []byte{0x2, 0x3, 0x1, 0x7, 0x8}, - wantPayload: []byte{0x7, 0x8}, - wantStreamID: 1, - }, - { - name: "initial_fragment_stdout_stream_has_mask_has_payload", - b: append([]byte{0x2, 0x83, 0x1, 0x2, 0x3, 0x4}, maskedBytes(testMask, []byte{0x1, 0x7, 0x8})...), - wantPayload: []byte{0x7, 0x8}, - wantStreamID: 1, - }, - { - name: "subsequent_fragment_stdout_stream_no_mask_has_payload", - b: []byte{0x0, 0x3, 0x1, 0x7, 0x8}, - initialPayload: []byte{0x1, 0x2, 0x3}, - wantPayload: []byte{0x1, 0x2, 0x3, 0x7, 0x8}, - wantStreamID: 1, - }, - { - name: "subsequent_fragment_stdout_stream_has_mask_has_payload", - b: append([]byte{0x0, 0x83, 0x1, 0x2, 0x3, 0x4}, maskedBytes(testMask, []byte{0x1, 0x7, 0x8})...), - initialPayload: []byte{0x1, 0x2, 0x3}, - wantPayload: []byte{0x1, 0x2, 0x3, 0x7, 0x8}, - wantStreamID: 1, - }, - { - name: "final_fragment_stdout_stream_no_mask_has_payload", - b: []byte{0x80, 0x3, 0x1, 0x7, 0x8}, - initialPayload: []byte{0x1, 0x2, 0x3}, - wantIsFinalized: true, - wantPayload: []byte{0x1, 0x2, 0x3, 0x7, 0x8}, - wantStreamID: 1, - }, - { - name: "final_fragment_stdout_stream_has_mask_has_payload", - b: append([]byte{0x80, 0x83, 0x1, 0x2, 0x3, 0x4}, maskedBytes(testMask, []byte{0x1, 0x7, 0x8})...), - initialPayload: []byte{0x1, 0x2, 0x3}, - wantIsFinalized: true, - wantPayload: []byte{0x1, 0x2, 0x3, 0x7, 0x8}, - wantStreamID: 1, - }, - { - name: "single_large_fragment_no_mask_length_hint_126", - b: append(append([]byte{0x80, 0x7e}, bs126Len...), append([]byte{0x1}, bs126...)...), - wantIsFinalized: true, - wantPayload: bs126, - wantStreamID: 1, - }, - { - name: "single_large_fragment_no_mask_length_hint_127", - b: append(append([]byte{0x80, 0x7f}, bs127Len...), append([]byte{0x1}, bs127...)...), - wantIsFinalized: true, - wantPayload: bs127, - wantStreamID: 1, - }, - { - name: "zero_length_bytes", - b: []byte{}, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := &message{ - typ: binaryMessage, - payload: tt.initialPayload, - } - if _, err := msg.Parse(tt.b, zl.Sugar()); (err != nil) != tt.wantErr { - t.Errorf("msg.Parse() = %v, wantsErr: %t", err, tt.wantErr) - } - if msg.isFinalized != tt.wantIsFinalized { - t.Errorf("wants message to be finalized: %t, got: %t", tt.wantIsFinalized, msg.isFinalized) - } - if msg.streamID.Load() != tt.wantStreamID { - t.Errorf("wants stream ID: %d, got: %d", tt.wantStreamID, msg.streamID.Load()) - } - if !reflect.DeepEqual(msg.payload, tt.wantPayload) { - t.Errorf("unexpected message payload after Parse, wants %b got %b", tt.wantPayload, msg.payload) - } - }) - } -} - -// Test_msg_Parse_Rand calls Parse with a randomly generated input to verify -// that it doesn't panic. -func Test_msg_Parse_Rand(t *testing.T) { - zl, err := zap.NewDevelopment() - if err != nil { - t.Fatalf("error creating a test logger: %v", err) - } - r := rand.New(rand.NewSource(time.Now().UnixNano())) - for i := range 100 { - n := r.Intn(4096) - b := make([]byte, n) - _, err := r.Read(b) - if err != nil { - t.Fatalf("error generating random byte slice: %v", err) - } - msg := message{typ: binaryMessage} - f := func() { - msg.Parse(b, zl.Sugar()) - } - testPanic(t, f, fmt.Sprintf("[%d] Parse panicked running with byte slice of length %d: %v", i, n, r)) - } -} - -// byteSlice2ByteLen generates a number that represents websocket message fragment length and is stored in an 8 byte slice. -// Returns the byte slice with the length as well as a slice of arbitrary bytes of the given length. -// This is used to generate test input representing websocket message with payload length hint 126. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.2 -func bytesSlice2ByteLen(t *testing.T) ([]byte, []byte) { - r := rand.New(rand.NewSource(time.Now().UnixNano())) - var n uint16 - n = uint16(rand.Intn(65535 - 1)) // space for and additional 1 byte stream ID - b := make([]byte, n) - _, err := r.Read(b) - if err != nil { - t.Fatalf("error generating random byte slice: %v ", err) - } - bb := make([]byte, 2) - binary.BigEndian.PutUint16(bb, n+1) // + stream ID - return b, bb -} - -// byteSlice8ByteLen generates a number that represents websocket message fragment length and is stored in an 8 byte slice. -// Returns the byte slice with the length as well as a slice of arbitrary bytes of the given length. -// This is used to generate test input representing websocket message with payload length hint 127. -// https://www.rfc-editor.org/rfc/rfc6455#section-5.2 -func byteSlice8ByteLen(t *testing.T) ([]byte, []byte) { - nanos := time.Now().UnixNano() - t.Logf("Creating random source with seed %v", nanos) - r := rand.New(rand.NewSource(nanos)) - var n uint64 - n = uint64(rand.Intn(websocket.DefaultMaxPayloadBytes - 1)) // space for and additional 1 byte stream ID - t.Logf("byteSlice8ByteLen: generating message payload of length %d", n) - b := make([]byte, n) - _, err := r.Read(b) - if err != nil { - t.Fatalf("error generating random byte slice: %v ", err) - } - bb := make([]byte, 8) - binary.BigEndian.PutUint64(bb, n+1) // + stream ID - return b, bb -} - -func maskedBytes(mask [4]byte, b []byte) []byte { - maskBytes(mask, b) - return b -} diff --git a/k8s-operator/utils.go b/k8s-operator/utils.go deleted file mode 100644 index 420d7e49c7ec2..0000000000000 --- a/k8s-operator/utils.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !plan9 - -// Package kube contains types and utilities for the Tailscale Kubernetes Operator. -package kube - -import ( - "fmt" - - "tailscale.com/tailcfg" -) - -const ( - Alpha1Version = "v1alpha1" - - DNSRecordsCMName = "dnsrecords" - DNSRecordsCMKey = "records.json" -) - -type Records struct { - // Version is the version of this Records configuration. Version is - // written by the operator, i.e when it first populates the Records. - // k8s-nameserver must verify that it knows how to parse a given - // version. - Version string `json:"version"` - // IP4 contains a mapping of DNS names to IPv4 address(es). - IP4 map[string][]string `json:"ip4"` -} - -// TailscaledConfigFileName returns a tailscaled config file name in -// format expected by containerboot for the given CapVer. -func TailscaledConfigFileName(cap tailcfg.CapabilityVersion) string { - return fmt.Sprintf("cap-%v.hujson", cap) -} - -// CapVerFromFileName parses the capability version from a tailscaled -// config file name previously generated by TailscaledConfigFileNameForCap. -func CapVerFromFileName(name string) (tailcfg.CapabilityVersion, error) { - if name == "tailscaled" { - return 0, nil - } - var cap tailcfg.CapabilityVersion - _, err := fmt.Sscanf(name, "cap-%d.hujson", &cap) - return cap, err -} diff --git a/kube/egressservices/egressservices_test.go b/kube/egressservices/egressservices_test.go deleted file mode 100644 index d6f952ea0a463..0000000000000 --- a/kube/egressservices/egressservices_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package egressservices - -import ( - "encoding/json" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func Test_jsonUnmarshalConfig(t *testing.T) { - tests := []struct { - name string - bs []byte - wantsCfg Config - wantsErr bool - }{ - { - name: "success", - bs: []byte(`{"ports":[{"protocol":"tcp","matchPort":4003,"targetPort":80}]}`), - wantsCfg: Config{Ports: map[PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {}}}, - }, - { - name: "failure_invalid_format", - bs: []byte(`{"ports":{"tcp:80":{}}}`), - wantsCfg: Config{Ports: map[PortMap]struct{}{}}, - wantsErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{} - if gotErr := json.Unmarshal(tt.bs, &cfg); (gotErr != nil) != tt.wantsErr { - t.Errorf("json.Unmarshal returned error %v, wants error %v", gotErr, tt.wantsErr) - } - if diff := cmp.Diff(cfg, tt.wantsCfg); diff != "" { - t.Errorf("unexpected secrets (-got +want):\n%s", diff) - } - }) - } -} - -func Test_jsonMarshalConfig(t *testing.T) { - tests := []struct { - name string - protocol string - matchPort uint16 - targetPort uint16 - wantsBs []byte - }{ - { - name: "success", - protocol: "tcp", - matchPort: 4003, - targetPort: 80, - wantsBs: []byte(`{"tailnetTarget":{"ip":"","fqdn":""},"ports":[{"protocol":"tcp","matchPort":4003,"targetPort":80}]}`), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{Ports: PortMaps{{ - Protocol: tt.protocol, - MatchPort: tt.matchPort, - TargetPort: tt.targetPort}: {}}} - - gotBs, gotErr := json.Marshal(&cfg) - if gotErr != nil { - t.Errorf("json.Marshal(%+#v) returned unexpected error %v", cfg, gotErr) - } - if diff := cmp.Diff(gotBs, tt.wantsBs); diff != "" { - t.Errorf("unexpected secrets (-got +want):\n%s", diff) - } - }) - } -} diff --git a/kube/kubeclient/client.go b/kube/kubeclient/client.go index d4309448df030..0b975e1e6fcf1 100644 --- a/kube/kubeclient/client.go +++ b/kube/kubeclient/client.go @@ -27,9 +27,9 @@ import ( "sync" "time" - "tailscale.com/kube/kubeapi" - "tailscale.com/tstime" - "tailscale.com/util/multierr" + "github.com/sagernet/tailscale/kube/kubeapi" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/util/multierr" ) const ( diff --git a/kube/kubeclient/client_test.go b/kube/kubeclient/client_test.go deleted file mode 100644 index 31878befe4106..0000000000000 --- a/kube/kubeclient/client_test.go +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package kubeclient - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/google/go-cmp/cmp" - "tailscale.com/kube/kubeapi" - "tailscale.com/tstest" -) - -func Test_client_Event(t *testing.T) { - cl := &tstest.Clock{} - tests := []struct { - name string - typ string - reason string - msg string - argSets []args - wantErr bool - }{ - { - name: "new_event_gets_created", - typ: "Normal", - reason: "TestReason", - msg: "TestMessage", - argSets: []args{ - { // request to GET event returns not found - wantsMethod: "GET", - wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason", - setErr: &kubeapi.Status{Code: 404}, - }, - { // sends POST request to create event - wantsMethod: "POST", - wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events", - wantsIn: &kubeapi.Event{ - ObjectMeta: kubeapi.ObjectMeta{ - Name: "test-pod.test-uid.testreason", - Namespace: "test-ns", - }, - Type: "Normal", - Reason: "TestReason", - Message: "TestMessage", - Source: kubeapi.EventSource{ - Component: "test-client", - }, - InvolvedObject: kubeapi.ObjectReference{ - Name: "test-pod", - UID: "test-uid", - Namespace: "test-ns", - APIVersion: "v1", - Kind: "Pod", - }, - FirstTimestamp: cl.Now(), - LastTimestamp: cl.Now(), - Count: 1, - }, - }, - }, - }, - { - name: "existing_event_gets_patched", - typ: "Warning", - reason: "TestReason", - msg: "TestMsg", - argSets: []args{ - { // request to GET event does not error - this is enough to assume that event exists - wantsMethod: "GET", - wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason", - setOut: []byte(`{"count":2}`), - }, - { // sends PATCH request to update the event - wantsMethod: "PATCH", - wantsURL: "test-apiserver/api/v1/namespaces/test-ns/events/test-pod.test-uid.testreason", - wantsIn: []JSONPatch{ - {Op: "replace", Path: "/count", Value: int32(3)}, - {Op: "replace", Path: "/lastTimestamp", Value: cl.Now()}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &client{ - cl: cl, - name: "test-client", - podName: "test-pod", - podUID: "test-uid", - url: "test-apiserver", - ns: "test-ns", - kubeAPIRequest: fakeKubeAPIRequest(t, tt.argSets), - hasEventsPerms: true, - } - if err := c.Event(context.Background(), tt.typ, tt.reason, tt.msg); (err != nil) != tt.wantErr { - t.Errorf("client.Event() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// args is a set of values for testing a single call to client.kubeAPIRequest. -type args struct { - // wantsMethod is the expected value of 'method' arg. - wantsMethod string - // wantsURL is the expected value of 'url' arg. - wantsURL string - // wantsIn is the expected value of 'in' arg. - wantsIn any - // setOut can be set to a byte slice representing valid JSON. If set 'out' arg will get set to the unmarshalled - // JSON object. - setOut []byte - // setErr is the error that kubeAPIRequest will return. - setErr error -} - -// fakeKubeAPIRequest can be used to test that a series of calls to client.kubeAPIRequest gets called with expected -// values and to set these calls to return preconfigured values. 'argSets' should be set to a slice of expected -// arguments and should-be return values of a series of kubeAPIRequest calls. -func fakeKubeAPIRequest(t *testing.T, argSets []args) kubeAPIRequestFunc { - count := 0 - f := func(ctx context.Context, gotMethod, gotUrl string, gotIn, gotOut any, opts ...func(*http.Request)) error { - t.Helper() - if count >= len(argSets) { - t.Fatalf("unexpected call to client.kubeAPIRequest, expected %d calls, but got a %dth call", len(argSets), count+1) - } - a := argSets[count] - if gotMethod != a.wantsMethod { - t.Errorf("[%d] got method %q, wants method %q", count, gotMethod, a.wantsMethod) - } - if gotUrl != a.wantsURL { - t.Errorf("[%d] got URL %q, wants URL %q", count, gotUrl, a.wantsURL) - } - if d := cmp.Diff(gotIn, a.wantsIn); d != "" { - t.Errorf("[%d] unexpected payload (-want + got):\n%s", count, d) - } - if len(a.setOut) != 0 { - if err := json.Unmarshal(a.setOut, gotOut); err != nil { - t.Fatalf("[%d] error unmarshalling output: %v", count, err) - } - } - count++ - return a.setErr - } - return f -} diff --git a/kube/kubeclient/fake_client.go b/kube/kubeclient/fake_client.go index 5716ca31b2f4c..b75b8629e3358 100644 --- a/kube/kubeclient/fake_client.go +++ b/kube/kubeclient/fake_client.go @@ -7,7 +7,7 @@ import ( "context" "net" - "tailscale.com/kube/kubeapi" + "github.com/sagernet/tailscale/kube/kubeapi" ) var _ Client = &FakeClient{} diff --git a/log/filelogger/log.go b/log/filelogger/log.go index 599e5237b3e22..25729f698af36 100644 --- a/log/filelogger/log.go +++ b/log/filelogger/log.go @@ -16,7 +16,7 @@ import ( "sync" "time" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) const ( diff --git a/log/filelogger/log_test.go b/log/filelogger/log_test.go deleted file mode 100644 index dfa489637f720..0000000000000 --- a/log/filelogger/log_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package filelogger - -import "testing" - -func TestRemoveDatePrefix(t *testing.T) { - tests := []struct { - in, want string - }{ - {"", ""}, - {"\n", "\n"}, - {"2009/01/23 01:23:23", "2009/01/23 01:23:23"}, - {"2009/01/23 01:23:23 \n", "\n"}, - {"2009/01/23 01:23:23 foo\n", "foo\n"}, - {"9999/01/23 01:23:23 foo\n", "foo\n"}, - {"2009_01/23 01:23:23 had an underscore\n", "2009_01/23 01:23:23 had an underscore\n"}, - } - for i, tt := range tests { - got := removeDatePrefix([]byte(tt.in)) - if string(got) != tt.want { - t.Logf("[%d] removeDatePrefix(%q) = %q; want %q", i, tt.in, got, tt.want) - } - } - -} diff --git a/log/sockstatlog/logger.go b/log/sockstatlog/logger.go index 3cc27c22d8af7..30f78785af3fb 100644 --- a/log/sockstatlog/logger.go +++ b/log/sockstatlog/logger.go @@ -17,15 +17,15 @@ import ( "sync/atomic" "time" - "tailscale.com/health" - "tailscale.com/logpolicy" - "tailscale.com/logtail" - "tailscale.com/logtail/filch" - "tailscale.com/net/netmon" - "tailscale.com/net/sockstats" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/util/mak" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/logpolicy" + "github.com/sagernet/tailscale/logtail" + "github.com/sagernet/tailscale/logtail/filch" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/util/mak" ) // pollInterval specifies how often to poll for socket stats. diff --git a/log/sockstatlog/logger_test.go b/log/sockstatlog/logger_test.go deleted file mode 100644 index 31fb17e460141..0000000000000 --- a/log/sockstatlog/logger_test.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package sockstatlog - -import ( - "context" - "testing" - - "github.com/google/go-cmp/cmp" - "tailscale.com/net/sockstats" - "tailscale.com/tstest" - "tailscale.com/types/logger" - "tailscale.com/types/logid" -) - -func TestResourceCleanup(t *testing.T) { - if !sockstats.IsAvailable { - t.Skip("sockstats not available") - } - tstest.ResourceCheck(t) - td := t.TempDir() - id, err := logid.NewPrivateID() - if err != nil { - t.Fatal(err) - } - lg, err := NewLogger(td, logger.Discard, id.Public(), nil, nil) - if err != nil { - t.Fatal(err) - } - lg.Write([]byte("hello")) - lg.Shutdown(context.Background()) -} - -func TestDelta(t *testing.T) { - tests := []struct { - name string - a, b *sockstats.SockStats - wantStats map[sockstats.Label]deltaStat - }{ - { - name: "nil a stat", - a: nil, - b: &sockstats.SockStats{}, - wantStats: nil, - }, - { - name: "nil b stat", - a: &sockstats.SockStats{}, - b: nil, - wantStats: nil, - }, - { - name: "no change", - a: &sockstats.SockStats{ - Stats: map[sockstats.Label]sockstats.SockStat{ - sockstats.LabelDERPHTTPClient: { - TxBytes: 10, - }, - }, - }, - b: &sockstats.SockStats{ - Stats: map[sockstats.Label]sockstats.SockStat{ - sockstats.LabelDERPHTTPClient: { - TxBytes: 10, - }, - }, - }, - wantStats: nil, - }, - { - name: "tx after empty stat", - a: &sockstats.SockStats{}, - b: &sockstats.SockStats{ - Stats: map[sockstats.Label]sockstats.SockStat{ - sockstats.LabelDERPHTTPClient: { - TxBytes: 10, - }, - }, - }, - wantStats: map[sockstats.Label]deltaStat{ - sockstats.LabelDERPHTTPClient: {10, 0}, - }, - }, - { - name: "rx after non-empty stat", - a: &sockstats.SockStats{ - Stats: map[sockstats.Label]sockstats.SockStat{ - sockstats.LabelDERPHTTPClient: { - TxBytes: 10, - RxBytes: 10, - }, - }, - }, - b: &sockstats.SockStats{ - Stats: map[sockstats.Label]sockstats.SockStat{ - sockstats.LabelDERPHTTPClient: { - TxBytes: 10, - RxBytes: 30, - }, - }, - }, - wantStats: map[sockstats.Label]deltaStat{ - sockstats.LabelDERPHTTPClient: {0, 20}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotStats := delta(tt.a, tt.b) - if !cmp.Equal(gotStats, tt.wantStats) { - t.Errorf("gotStats = %v, want %v", gotStats, tt.wantStats) - } - }) - } -} diff --git a/logpolicy/logpolicy.go b/logpolicy/logpolicy.go index d657c4e9352f3..55878adbc9b52 100644 --- a/logpolicy/logpolicy.go +++ b/logpolicy/logpolicy.go @@ -28,32 +28,32 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/log/filelogger" + "github.com/sagernet/tailscale/logtail" + "github.com/sagernet/tailscale/logtail/filch" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/dnsfallback" + "github.com/sagernet/tailscale/net/netknob" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/tlsdial" + "github.com/sagernet/tailscale/net/tshttpproxy" + "github.com/sagernet/tailscale/paths" + "github.com/sagernet/tailscale/safesocket" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/must" + "github.com/sagernet/tailscale/util/racebuild" + "github.com/sagernet/tailscale/util/syspolicy" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/version/distro" "golang.org/x/term" - "tailscale.com/atomicfile" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/log/filelogger" - "tailscale.com/logtail" - "tailscale.com/logtail/filch" - "tailscale.com/net/dnscache" - "tailscale.com/net/dnsfallback" - "tailscale.com/net/netknob" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/tlsdial" - "tailscale.com/net/tshttpproxy" - "tailscale.com/paths" - "tailscale.com/safesocket" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/util/clientmetric" - "tailscale.com/util/must" - "tailscale.com/util/racebuild" - "tailscale.com/util/syspolicy" - "tailscale.com/util/testenv" - "tailscale.com/version" - "tailscale.com/version/distro" ) var getLogTargetOnce struct { diff --git a/logpolicy/logpolicy_test.go b/logpolicy/logpolicy_test.go deleted file mode 100644 index fdbfe4506e038..0000000000000 --- a/logpolicy/logpolicy_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package logpolicy - -import ( - "os" - "reflect" - "testing" -) - -func TestLogHost(t *testing.T) { - v := reflect.ValueOf(&getLogTargetOnce).Elem() - reset := func() { - v.Set(reflect.Zero(v.Type())) - } - defer reset() - - tests := []struct { - env string - want string - }{ - {"", "log.tailscale.io"}, - {"http://foo.com", "foo.com"}, - {"https://foo.com", "foo.com"}, - {"https://foo.com/", "foo.com"}, - {"https://foo.com:123/", "foo.com"}, - } - for _, tt := range tests { - reset() - os.Setenv("TS_LOG_TARGET", tt.env) - if got := LogHost(); got != tt.want { - t.Errorf("for env %q, got %q, want %q", tt.env, got, tt.want) - } - } -} diff --git a/logtail/backoff/backoff.go b/logtail/backoff/backoff.go index c6aeae998fa27..dbdca130cdaf4 100644 --- a/logtail/backoff/backoff.go +++ b/logtail/backoff/backoff.go @@ -9,8 +9,8 @@ import ( "math/rand/v2" "time" - "tailscale.com/tstime" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/logger" ) // Backoff tracks state the history of consecutive failures and sleeps diff --git a/logtail/example/logreprocess/logreprocess.go b/logtail/example/logreprocess/logreprocess.go index 5dbf765788165..5191ef03f9d99 100644 --- a/logtail/example/logreprocess/logreprocess.go +++ b/logtail/example/logreprocess/logreprocess.go @@ -15,7 +15,7 @@ import ( "strings" "time" - "tailscale.com/types/logid" + "github.com/sagernet/tailscale/types/logid" ) func main() { diff --git a/logtail/example/logtail/logtail.go b/logtail/example/logtail/logtail.go index 0c9e442584410..fdd36a70e543f 100644 --- a/logtail/example/logtail/logtail.go +++ b/logtail/example/logtail/logtail.go @@ -11,8 +11,8 @@ import ( "log" "os" - "tailscale.com/logtail" - "tailscale.com/types/logid" + "github.com/sagernet/tailscale/logtail" + "github.com/sagernet/tailscale/types/logid" ) func main() { diff --git a/logtail/filch/filch_test.go b/logtail/filch/filch_test.go deleted file mode 100644 index 6b7b88414a72c..0000000000000 --- a/logtail/filch/filch_test.go +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package filch - -import ( - "fmt" - "io" - "os" - "runtime" - "strings" - "testing" - "unicode" - "unsafe" - - "tailscale.com/tstest" -) - -type filchTest struct { - *Filch -} - -func newFilchTest(t *testing.T, filePrefix string, opts Options) *filchTest { - f, err := New(filePrefix, opts) - if err != nil { - t.Fatal(err) - } - return &filchTest{Filch: f} -} - -func (f *filchTest) write(t *testing.T, s string) { - t.Helper() - if _, err := f.Write([]byte(s)); err != nil { - t.Fatal(err) - } -} - -func (f *filchTest) read(t *testing.T, want string) { - t.Helper() - if b, err := f.TryReadLine(); err != nil { - t.Fatalf("r.ReadLine() err=%v", err) - } else if got := strings.TrimRightFunc(string(b), unicode.IsSpace); got != want { - t.Errorf("r.ReadLine()=%q, want %q", got, want) - } -} - -func (f *filchTest) readEOF(t *testing.T) { - t.Helper() - if b, err := f.TryReadLine(); b != nil || err != nil { - t.Fatalf("r.ReadLine()=%q err=%v, want nil slice", string(b), err) - } -} - -func (f *filchTest) close(t *testing.T) { - t.Helper() - if err := f.Close(); err != nil { - t.Fatal(err) - } -} - -func TestDropOldLogs(t *testing.T) { - const line1 = "123456789" // 10 bytes (9+newline) - tests := []struct { - write, read int - }{ - {10, 10}, - {100, 100}, - {200, 200}, - {250, 150}, - {500, 200}, - } - for _, tc := range tests { - t.Run(fmt.Sprintf("w%d-r%d", tc.write, tc.read), func(t *testing.T) { - filePrefix := t.TempDir() - f := newFilchTest(t, filePrefix, Options{ReplaceStderr: false, MaxFileSize: 1000}) - defer f.close(t) - // Make filch rotate the logs 3 times - for range tc.write { - f.write(t, line1) - } - // We should only be able to read the last 150 lines - for i := range tc.read { - f.read(t, line1) - if t.Failed() { - t.Logf("could only read %d lines", i) - break - } - } - f.readEOF(t) - }) - } -} - -func TestQueue(t *testing.T) { - filePrefix := t.TempDir() - f := newFilchTest(t, filePrefix, Options{ReplaceStderr: false}) - - f.readEOF(t) - const line1 = "Hello, World!" - const line2 = "This is a test." - const line3 = "Of filch." - f.write(t, line1) - f.write(t, line2) - f.read(t, line1) - f.write(t, line3) - f.read(t, line2) - f.read(t, line3) - f.readEOF(t) - f.write(t, line1) - f.read(t, line1) - f.readEOF(t) - f.close(t) -} - -func TestRecover(t *testing.T) { - t.Run("empty", func(t *testing.T) { - filePrefix := t.TempDir() - f := newFilchTest(t, filePrefix, Options{ReplaceStderr: false}) - f.write(t, "hello") - f.read(t, "hello") - f.readEOF(t) - f.close(t) - - f = newFilchTest(t, filePrefix, Options{ReplaceStderr: false}) - f.readEOF(t) - f.close(t) - }) - - t.Run("cur", func(t *testing.T) { - filePrefix := t.TempDir() - f := newFilchTest(t, filePrefix, Options{ReplaceStderr: false}) - f.write(t, "hello") - f.close(t) - - f = newFilchTest(t, filePrefix, Options{ReplaceStderr: false}) - f.read(t, "hello") - f.readEOF(t) - f.close(t) - }) - - t.Run("alt", func(t *testing.T) { - t.Skip("currently broken on linux, passes on macOS") - /* --- FAIL: TestRecover/alt (0.00s) - filch_test.go:128: r.ReadLine()="world", want "hello" - filch_test.go:129: r.ReadLine()="hello", want "world" - */ - - filePrefix := t.TempDir() - f := newFilchTest(t, filePrefix, Options{ReplaceStderr: false}) - f.write(t, "hello") - f.read(t, "hello") - f.write(t, "world") - f.close(t) - - f = newFilchTest(t, filePrefix, Options{ReplaceStderr: false}) - // TODO(crawshaw): The "hello" log is replayed in recovery. - // We could reduce replays by risking some logs loss. - // What should our policy here be? - f.read(t, "hello") - f.read(t, "world") - f.readEOF(t) - f.close(t) - }) -} - -func TestFilchStderr(t *testing.T) { - if runtime.GOOS == "windows" { - // TODO(bradfitz): this is broken on Windows but not - // fully sure why. Investigate. But notably, the - // stderrFD variable (defined in filch.go) and set - // below is only ever read in filch_unix.go. So just - // skip this for test for now. - t.Skip("test broken on Windows") - } - pipeR, pipeW, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer pipeR.Close() - defer pipeW.Close() - - tstest.Replace(t, &stderrFD, int(pipeW.Fd())) - - filePrefix := t.TempDir() - f := newFilchTest(t, filePrefix, Options{ReplaceStderr: true}) - f.write(t, "hello") - if _, err := fmt.Fprintf(pipeW, "filch\n"); err != nil { - t.Fatal(err) - } - f.read(t, "hello") - f.read(t, "filch") - f.readEOF(t) - f.close(t) - - pipeW.Close() - b, err := io.ReadAll(pipeR) - if err != nil { - t.Fatal(err) - } - if len(b) > 0 { - t.Errorf("unexpected write to fake stderr: %s", b) - } -} - -func TestSizeOf(t *testing.T) { - s := unsafe.Sizeof(Filch{}) - if s > 4096 { - t.Fatalf("Filch{} has size %d on %v, decrease size of buf field", s, runtime.GOARCH) - } -} diff --git a/logtail/logtail.go b/logtail/logtail.go index 9df164273d74c..f51a7c965beda 100644 --- a/logtail/logtail.go +++ b/logtail/logtail.go @@ -25,16 +25,16 @@ import ( "time" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/envknob" - "tailscale.com/net/netmon" - "tailscale.com/net/sockstats" - "tailscale.com/net/tsaddr" - "tailscale.com/tstime" - tslogger "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/util/set" - "tailscale.com/util/truncate" - "tailscale.com/util/zstdframe" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/tstime" + tslogger "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/truncate" + "github.com/sagernet/tailscale/util/zstdframe" ) // maxSize is the maximum size that a single log entry can be. diff --git a/logtail/logtail_test.go b/logtail/logtail_test.go deleted file mode 100644 index 3ea6304067bfd..0000000000000 --- a/logtail/logtail_test.go +++ /dev/null @@ -1,556 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package logtail - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/go-json-experiment/json/jsontext" - "tailscale.com/envknob" - "tailscale.com/tstest" - "tailscale.com/tstime" - "tailscale.com/util/must" -) - -func TestFastShutdown(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - testServ := httptest.NewServer(http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) {})) - defer testServ.Close() - - l := NewLogger(Config{ - BaseURL: testServ.URL, - }, t.Logf) - err := l.Shutdown(ctx) - if err != nil { - t.Error(err) - } -} - -// maximum number of times a test will call l.Write() -const logLines = 3 - -type LogtailTestServer struct { - srv *httptest.Server // Log server - uploaded chan []byte -} - -func NewLogtailTestHarness(t *testing.T) (*LogtailTestServer, *Logger) { - ts := LogtailTestServer{} - - // max channel backlog = 1 "started" + #logLines x "log line" + 1 "closed" - ts.uploaded = make(chan []byte, 2+logLines) - - ts.srv = httptest.NewServer(http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - t.Error("failed to read HTTP request") - } - ts.uploaded <- body - })) - - t.Cleanup(ts.srv.Close) - - l := NewLogger(Config{BaseURL: ts.srv.URL}, t.Logf) - - // There is always an initial "logtail started" message - body := <-ts.uploaded - if !strings.Contains(string(body), "started") { - t.Errorf("unknown start logging statement: %q", string(body)) - } - - return &ts, l -} - -func TestDrainPendingMessages(t *testing.T) { - ts, l := NewLogtailTestHarness(t) - - for range logLines { - l.Write([]byte("log line")) - } - - // all of the "log line" messages usually arrive at once, but poll if needed. - body := "" - for i := 0; i <= logLines; i++ { - body += string(<-ts.uploaded) - count := strings.Count(body, "log line") - if count == logLines { - break - } - // if we never find count == logLines, the test will eventually time out. - } - - err := l.Shutdown(context.Background()) - if err != nil { - t.Error(err) - } -} - -func TestEncodeAndUploadMessages(t *testing.T) { - ts, l := NewLogtailTestHarness(t) - - tests := []struct { - name string - log string - want string - }{ - { - "plain text", - "log line", - "log line", - }, - { - "simple JSON", - `{"text":"log line"}`, - "log line", - }, - } - - for _, tt := range tests { - io.WriteString(l, tt.log) - body := <-ts.uploaded - - data := unmarshalOne(t, body) - got := data["text"] - if got != tt.want { - t.Errorf("%s: got %q; want %q", tt.name, got.(string), tt.want) - } - - ltail, ok := data["logtail"] - if ok { - logtailmap := ltail.(map[string]any) - _, ok = logtailmap["client_time"] - if !ok { - t.Errorf("%s: no client_time present", tt.name) - } - } else { - t.Errorf("%s: no logtail map present", tt.name) - } - } - - err := l.Shutdown(context.Background()) - if err != nil { - t.Error(err) - } -} - -func TestLoggerWriteLength(t *testing.T) { - lg := &Logger{ - clock: tstime.StdClock{}, - buffer: NewMemoryBuffer(1024), - } - inBuf := []byte("some text to encode") - n, err := lg.Write(inBuf) - if err != nil { - t.Error(err) - } - if n != len(inBuf) { - t.Errorf("logger.Write wrote %d bytes, expected %d", n, len(inBuf)) - } -} - -func TestParseAndRemoveLogLevel(t *testing.T) { - tests := []struct { - log string - wantLevel int - wantLog string - }{ - { - "no level", - 0, - "no level", - }, - { - "[v1] level 1", - 1, - "level 1", - }, - { - "level 1 [v1] ", - 1, - "level 1 ", - }, - { - "[v2] level 2", - 2, - "level 2", - }, - { - "level [v2] 2", - 2, - "level 2", - }, - { - "[v3] no level 3", - 0, - "[v3] no level 3", - }, - { - "some ignored text then [v\x00JSON]5{\"foo\":1234}", - 5, - `{"foo":1234}`, - }, - } - - for _, tt := range tests { - gotLevel, gotLog := parseAndRemoveLogLevel([]byte(tt.log)) - if gotLevel != tt.wantLevel { - t.Errorf("parseAndRemoveLogLevel(%q): got:%d; want %d", - tt.log, gotLevel, tt.wantLevel) - } - if string(gotLog) != tt.wantLog { - t.Errorf("parseAndRemoveLogLevel(%q): got:%q; want %q", - tt.log, gotLog, tt.wantLog) - } - } -} - -func unmarshalOne(t *testing.T, body []byte) map[string]any { - t.Helper() - var entries []map[string]any - err := json.Unmarshal(body, &entries) - if err != nil { - t.Error(err) - } - if len(entries) != 1 { - t.Fatalf("expected one entry, got %d", len(entries)) - } - return entries[0] -} - -type simpleMemBuf struct { - Buffer - buf bytes.Buffer -} - -func (b *simpleMemBuf) Write(p []byte) (n int, err error) { return b.buf.Write(p) } - -func TestEncode(t *testing.T) { - tests := []struct { - in string - want string - }{ - { - "normal", - `{"logtail":{"client_time":"1970-01-01T00:02:03.000000456Z","proc_id":7,"proc_seq":1},"text":"normal"}` + "\n", - }, - { - "and a [v1] level one", - `{"logtail":{"client_time":"1970-01-01T00:02:03.000000456Z","proc_id":7,"proc_seq":1},"v":1,"text":"and a level one"}` + "\n", - }, - { - "[v2] some verbose two", - `{"logtail":{"client_time":"1970-01-01T00:02:03.000000456Z","proc_id":7,"proc_seq":1},"v":2,"text":"some verbose two"}` + "\n", - }, - { - "{}", - `{"logtail":{"client_time":"1970-01-01T00:02:03.000000456Z","proc_id":7,"proc_seq":1}}` + "\n", - }, - { - `{"foo":"bar"}`, - `{"logtail":{"client_time":"1970-01-01T00:02:03.000000456Z","proc_id":7,"proc_seq":1},"foo":"bar"}` + "\n", - }, - { - "foo: [v\x00JSON]0{\"foo\":1}", - "{\"logtail\":{\"client_time\":\"1970-01-01T00:02:03.000000456Z\",\"proc_id\":7,\"proc_seq\":1},\"foo\":1}\n", - }, - { - "foo: [v\x00JSON]2{\"foo\":1}", - "{\"logtail\":{\"client_time\":\"1970-01-01T00:02:03.000000456Z\",\"proc_id\":7,\"proc_seq\":1},\"v\":2,\"foo\":1}\n", - }, - } - for _, tt := range tests { - buf := new(simpleMemBuf) - lg := &Logger{ - clock: tstest.NewClock(tstest.ClockOpts{Start: time.Unix(123, 456).UTC()}), - buffer: buf, - procID: 7, - procSequence: 1, - } - io.WriteString(lg, tt.in) - got := buf.buf.String() - if got != tt.want { - t.Errorf("for %q,\n got: %#q\nwant: %#q\n", tt.in, got, tt.want) - } - if err := json.Compact(new(bytes.Buffer), buf.buf.Bytes()); err != nil { - t.Errorf("invalid output JSON for %q: %s", tt.in, got) - } - } -} - -// Test that even if Logger.Write modifies the input buffer, we still return the -// length of the input buffer, not what we shrank it down to. Otherwise the -// caller will think we did a short write, violating the io.Writer contract. -func TestLoggerWriteResult(t *testing.T) { - buf := NewMemoryBuffer(100) - lg := &Logger{ - clock: tstest.NewClock(tstest.ClockOpts{Start: time.Unix(123, 0)}), - buffer: buf, - } - - const in = "[v1] foo" - n, err := lg.Write([]byte(in)) - if err != nil { - t.Fatal(err) - } - if got, want := n, len(in); got != want { - t.Errorf("Write = %v; want %v", got, want) - } - back, err := buf.TryReadLine() - if err != nil { - t.Fatal(err) - } - if got, want := string(back), `{"logtail":{"client_time":"1970-01-01T00:02:03Z"},"v":1,"text":"foo"}`+"\n"; got != want { - t.Errorf("mismatch.\n got: %#q\nwant: %#q", back, want) - } -} -func TestRedact(t *testing.T) { - envknob.Setenv("TS_OBSCURE_LOGGED_IPS", "true") - tests := []struct { - in string - want string - }{ - // tests for ipv4 addresses - { - "120.100.30.47", - "120.100.x.x", - }, - { - "192.167.0.1/65", - "192.167.x.x/65", - }, - { - "node [5Btdd] d:e89a3384f526d251 now using 10.0.0.222:41641 mtu=1360 tx=d81a8a35a0ce", - "node [5Btdd] d:e89a3384f526d251 now using 10.0.x.x:41641 mtu=1360 tx=d81a8a35a0ce", - }, - //tests for ipv6 addresses - { - "2001:0db8:85a3:0000:0000:8a2e:0370:7334", - "2001:0db8:x", - }, - { - "2345:0425:2CA1:0000:0000:0567:5673:23b5", - "2345:0425:x", - }, - { - "2601:645:8200:edf0::c9de/64", - "2601:645:x/64", - }, - { - "node [5Btdd] d:e89a3384f526d251 now using 2051:0000:140F::875B:131C mtu=1360 tx=d81a8a35a0ce", - "node [5Btdd] d:e89a3384f526d251 now using 2051:0000:x mtu=1360 tx=d81a8a35a0ce", - }, - { - "2601:645:8200:edf0::c9de/64 2601:645:8200:edf0:1ce9:b17d:71f5:f6a3/64", - "2601:645:x/64 2601:645:x/64", - }, - //tests for tailscale ip addresses - { - "100.64.5.6", - "100.64.5.6", - }, - { - "fd7a:115c:a1e0::/96", - "fd7a:115c:a1e0::/96", - }, - //tests for ipv6 and ipv4 together - { - "192.167.0.1 2001:0db8:85a3:0000:0000:8a2e:0370:7334", - "192.167.x.x 2001:0db8:x", - }, - { - "node [5Btdd] d:e89a3384f526d251 now using 10.0.0.222:41641 mtu=1360 tx=d81a8a35a0ce 2345:0425:2CA1::0567:5673:23b5", - "node [5Btdd] d:e89a3384f526d251 now using 10.0.x.x:41641 mtu=1360 tx=d81a8a35a0ce 2345:0425:x", - }, - { - "100.64.5.6 2091:0db8:85a3:0000:0000:8a2e:0370:7334", - "100.64.5.6 2091:0db8:x", - }, - { - "192.167.0.1 120.100.30.47 2041:0000:140F::875B:131B", - "192.167.x.x 120.100.x.x 2041:0000:x", - }, - { - "fd7a:115c:a1e0::/96 192.167.0.1 2001:0db8:85a3:0000:0000:8a2e:0370:7334", - "fd7a:115c:a1e0::/96 192.167.x.x 2001:0db8:x", - }, - } - - for _, tt := range tests { - gotBuf := redactIPs([]byte(tt.in)) - if string(gotBuf) != tt.want { - t.Errorf("for %q,\n got: %#q\nwant: %#q\n", tt.in, gotBuf, tt.want) - } - } -} - -func TestAppendMetadata(t *testing.T) { - var l Logger - l.clock = tstest.NewClock(tstest.ClockOpts{Start: time.Date(2000, 01, 01, 0, 0, 0, 0, time.UTC)}) - l.metricsDelta = func() string { return "metrics" } - - for _, tt := range []struct { - skipClientTime bool - skipMetrics bool - procID uint32 - procSeq uint64 - errDetail string - errData jsontext.Value - level int - want string - }{ - {want: `"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics",`}, - {skipClientTime: true, want: `"metrics":"metrics",`}, - {skipMetrics: true, want: `"logtail":{"client_time":"2000-01-01T00:00:00Z"},`}, - {skipClientTime: true, skipMetrics: true, want: ``}, - {skipClientTime: true, skipMetrics: true, procID: 1, want: `"logtail":{"proc_id":1},`}, - {skipClientTime: true, skipMetrics: true, procSeq: 2, want: `"logtail":{"proc_seq":2},`}, - {skipClientTime: true, skipMetrics: true, procID: 1, procSeq: 2, want: `"logtail":{"proc_id":1,"proc_seq":2},`}, - {skipMetrics: true, procID: 1, procSeq: 2, want: `"logtail":{"client_time":"2000-01-01T00:00:00Z","proc_id":1,"proc_seq":2},`}, - {skipClientTime: true, skipMetrics: true, errDetail: "error", want: `"logtail":{"error":{"detail":"error"}},`}, - {skipClientTime: true, skipMetrics: true, errData: jsontext.Value("null"), want: `"logtail":{"error":{"bad_data":null}},`}, - {skipClientTime: true, skipMetrics: true, level: 5, want: `"v":5,`}, - {procID: 1, procSeq: 2, errDetail: "error", errData: jsontext.Value(`["something","bad","happened"]`), level: 2, - want: `"logtail":{"client_time":"2000-01-01T00:00:00Z","proc_id":1,"proc_seq":2,"error":{"detail":"error","bad_data":["something","bad","happened"]}},"metrics":"metrics","v":2,`}, - } { - got := string(l.appendMetadata(nil, tt.skipClientTime, tt.skipMetrics, tt.procID, tt.procSeq, tt.errDetail, tt.errData, tt.level)) - if got != tt.want { - t.Errorf("appendMetadata(%v, %v, %v, %v, %v, %v, %v):\n\tgot %s\n\twant %s", tt.skipClientTime, tt.skipMetrics, tt.procID, tt.procSeq, tt.errDetail, tt.errData, tt.level, got, tt.want) - } - gotObj := "{" + strings.TrimSuffix(got, ",") + "}" - if !jsontext.Value(gotObj).IsValid() { - t.Errorf("`%s`.IsValid() = false, want true", gotObj) - } - } -} - -func TestAppendText(t *testing.T) { - var l Logger - l.clock = tstest.NewClock(tstest.ClockOpts{Start: time.Date(2000, 01, 01, 0, 0, 0, 0, time.UTC)}) - l.metricsDelta = func() string { return "metrics" } - l.lowMem = true - - for _, tt := range []struct { - text string - skipClientTime bool - procID uint32 - procSeq uint64 - level int - want string - }{ - {want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics"}`}, - {skipClientTime: true, want: `{"metrics":"metrics"}`}, - {skipClientTime: true, procID: 1, procSeq: 2, want: `{"logtail":{"proc_id":1,"proc_seq":2},"metrics":"metrics"}`}, - {text: "fizz buzz", want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics","text":"fizz buzz"}`}, - {text: "\b\f\n\r\t\"\\", want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics","text":"\b\f\n\r\t\"\\"}`}, - {text: "x" + strings.Repeat("😐", maxSize), want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics","text":"x` + strings.Repeat("😐", 1023) + `…+1044484"}`}, - } { - got := string(l.appendText(nil, []byte(tt.text), tt.skipClientTime, tt.procID, tt.procSeq, tt.level)) - if !strings.HasSuffix(got, "\n") { - t.Errorf("`%s` does not end with a newline", got) - } - got = got[:len(got)-1] - if got != tt.want { - t.Errorf("appendText(%v, %v, %v, %v, %v):\n\tgot %s\n\twant %s", tt.text[:min(len(tt.text), 256)], tt.skipClientTime, tt.procID, tt.procSeq, tt.level, got, tt.want) - } - if !jsontext.Value(got).IsValid() { - t.Errorf("`%s`.IsValid() = false, want true", got) - } - } -} - -func TestAppendTextOrJSON(t *testing.T) { - var l Logger - l.clock = tstest.NewClock(tstest.ClockOpts{Start: time.Date(2000, 01, 01, 0, 0, 0, 0, time.UTC)}) - l.metricsDelta = func() string { return "metrics" } - l.lowMem = true - - for _, tt := range []struct { - in string - level int - want string - }{ - {want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics"}`}, - {in: "[]", want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics","text":"[]"}`}, - {level: 1, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics","v":1}`}, - {in: `{}`, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"}}`}, - {in: `{}{}`, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"metrics":"metrics","text":"{}{}"}`}, - {in: "{\n\"fizz\"\n:\n\"buzz\"\n}", want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z"},"fizz":"buzz"}`}, - {in: `{ "logtail" : "duplicate" }`, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z","error":{"detail":"duplicate logtail member","bad_data":"duplicate"}}}`}, - {in: `{ "fizz" : "buzz" , "logtail" : "duplicate" }`, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z","error":{"detail":"duplicate logtail member","bad_data":"duplicate"}}, "fizz" : "buzz"}`}, - {in: `{ "logtail" : "duplicate" , "fizz" : "buzz" }`, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z","error":{"detail":"duplicate logtail member","bad_data":"duplicate"}} , "fizz" : "buzz"}`}, - {in: `{ "fizz" : "buzz" , "logtail" : "duplicate" , "wizz" : "wuzz" }`, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z","error":{"detail":"duplicate logtail member","bad_data":"duplicate"}}, "fizz" : "buzz" , "wizz" : "wuzz"}`}, - {in: `{"long":"` + strings.Repeat("a", maxSize) + `"}`, want: `{"logtail":{"client_time":"2000-01-01T00:00:00Z","error":{"detail":"entry too large: 262155 bytes","bad_data":"{\"long\":\"` + strings.Repeat("a", 43681) + `…+218465"}}}`}, - } { - got := string(l.appendTextOrJSONLocked(nil, []byte(tt.in), tt.level)) - if !strings.HasSuffix(got, "\n") { - t.Errorf("`%s` does not end with a newline", got) - } - got = got[:len(got)-1] - if got != tt.want { - t.Errorf("appendTextOrJSON(%v, %v):\n\tgot %s\n\twant %s", tt.in[:min(len(tt.in), 256)], tt.level, got, tt.want) - } - if !jsontext.Value(got).IsValid() { - t.Errorf("`%s`.IsValid() = false, want true", got) - } - } -} - -var sink []byte - -func TestAppendTextAllocs(t *testing.T) { - lg := &Logger{clock: tstime.StdClock{}} - inBuf := []byte("some text to encode") - procID := uint32(0x24d32ee9) - procSequence := uint64(0x12346) - must.Do(tstest.MinAllocsPerRun(t, 0, func() { - sink = lg.appendText(sink[:0], inBuf, false, procID, procSequence, 0) - })) -} - -func TestAppendJSONAllocs(t *testing.T) { - lg := &Logger{clock: tstime.StdClock{}} - inBuf := []byte(`{"fizz":"buzz"}`) - must.Do(tstest.MinAllocsPerRun(t, 1, func() { - sink = lg.appendTextOrJSONLocked(sink[:0], inBuf, 0) - })) -} - -type discardBuffer struct{ Buffer } - -func (discardBuffer) Write(p []byte) (n int, err error) { return n, nil } - -var testdataTextLog = []byte(`netcheck: report: udp=true v6=false v6os=true mapvarydest=false hair=false portmap= v4a=174.xxx.xxx.xxx:18168 derp=2 derpdist=1v4:82ms,2v4:18ms,3v4:214ms,4v4:171ms,5v4:196ms,7v4:124ms,8v4:149ms,9v4:56ms,10v4:32ms,11v4:196ms,12v4:71ms,13v4:48ms,14v4:166ms,16v4:85ms,17v4:25ms,18v4:153ms,19v4:176ms,20v4:193ms,21v4:84ms,22v4:182ms,24v4:73ms`) -var testdataJSONLog = []byte(`{"end":"2024-04-08T21:39:15.715291586Z","nodeId":"nQRJBE7CNTRL","physicalTraffic":[{"dst":"127.x.x.x:2","src":"100.x.x.x:0","txBytes":148,"txPkts":1},{"dst":"127.x.x.x:2","src":"100.x.x.x:0","txBytes":148,"txPkts":1},{"dst":"98.x.x.x:1025","rxBytes":640,"rxPkts":5,"src":"100.x.x.x:0","txBytes":640,"txPkts":5},{"dst":"24.x.x.x:49973","rxBytes":640,"rxPkts":5,"src":"100.x.x.x:0","txBytes":640,"txPkts":5},{"dst":"73.x.x.x:41641","rxBytes":732,"rxPkts":6,"src":"100.x.x.x:0","txBytes":820,"txPkts":7},{"dst":"75.x.x.x:1025","rxBytes":640,"rxPkts":5,"src":"100.x.x.x:0","txBytes":640,"txPkts":5},{"dst":"75.x.x.x:41641","rxBytes":640,"rxPkts":5,"src":"100.x.x.x:0","txBytes":640,"txPkts":5},{"dst":"174.x.x.x:35497","rxBytes":13008,"rxPkts":98,"src":"100.x.x.x:0","txBytes":26688,"txPkts":150},{"dst":"47.x.x.x:41641","rxBytes":640,"rxPkts":5,"src":"100.x.x.x:0","txBytes":640,"txPkts":5},{"dst":"64.x.x.x:41641","rxBytes":640,"rxPkts":5,"src":"100.x.x.x:0","txBytes":640,"txPkts":5}],"start":"2024-04-08T21:39:11.099495616Z","virtualTraffic":[{"dst":"100.x.x.x:33008","proto":6,"src":"100.x.x.x:22","txBytes":1260,"txPkts":10},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:32984","proto":6,"src":"100.x.x.x:22","txBytes":1340,"txPkts":10},{"dst":"100.x.x.x:32998","proto":6,"src":"100.x.x.x:22","txBytes":1020,"txPkts":10},{"dst":"100.x.x.x:32994","proto":6,"src":"100.x.x.x:22","txBytes":1260,"txPkts":10},{"dst":"100.x.x.x:32980","proto":6,"src":"100.x.x.x:22","txBytes":1260,"txPkts":10},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:32950","proto":6,"src":"100.x.x.x:22","txBytes":1340,"txPkts":10},{"dst":"100.x.x.x:22","proto":6,"src":"100.x.x.x:53332","txBytes":60,"txPkts":1},{"dst":"100.x.x.x:0","proto":1,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:32966","proto":6,"src":"100.x.x.x:22","txBytes":1260,"txPkts":10},{"dst":"100.x.x.x:22","proto":6,"src":"100.x.x.x:57882","txBytes":60,"txPkts":1},{"dst":"100.x.x.x:22","proto":6,"src":"100.x.x.x:53326","txBytes":60,"txPkts":1},{"dst":"100.x.x.x:22","proto":6,"src":"100.x.x.x:57892","txBytes":60,"txPkts":1},{"dst":"100.x.x.x:32934","proto":6,"src":"100.x.x.x:22","txBytes":8712,"txPkts":55},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:32942","proto":6,"src":"100.x.x.x:22","txBytes":1260,"txPkts":10},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:32964","proto":6,"src":"100.x.x.x:22","txBytes":1260,"txPkts":10},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:0","proto":1,"rxBytes":420,"rxPkts":5,"src":"100.x.x.x:0","txBytes":420,"txPkts":5},{"dst":"100.x.x.x:22","proto":6,"src":"100.x.x.x:37238","txBytes":60,"txPkts":1},{"dst":"100.x.x.x:22","proto":6,"src":"100.x.x.x:37252","txBytes":60,"txPkts":1}]}`) - -func BenchmarkWriteText(b *testing.B) { - var l Logger - l.clock = tstime.StdClock{} - l.buffer = discardBuffer{} - b.ReportAllocs() - for range b.N { - must.Get(l.Write(testdataTextLog)) - } -} - -func BenchmarkWriteJSON(b *testing.B) { - var l Logger - l.clock = tstime.StdClock{} - l.buffer = discardBuffer{} - b.ReportAllocs() - for range b.N { - must.Get(l.Write(testdataJSONLog)) - } -} diff --git a/metrics/fds_linux.go b/metrics/fds_linux.go index 34740c2bb1c74..9d0e8161a726f 100644 --- a/metrics/fds_linux.go +++ b/metrics/fds_linux.go @@ -7,8 +7,8 @@ import ( "io/fs" "sync" + "github.com/sagernet/tailscale/util/dirwalk" "go4.org/mem" - "tailscale.com/util/dirwalk" ) // counter is a reusable counter for counting file descriptors. diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go deleted file mode 100644 index 45bf39e56efd2..0000000000000 --- a/metrics/metrics_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package metrics - -import ( - "os" - "runtime" - "testing" - - "tailscale.com/tstest" -) - -func TestLabelMap(t *testing.T) { - var m LabelMap - m.GetIncrFunc("foo")(1) - m.GetIncrFunc("bar")(2) - if g, w := m.Get("foo").Value(), int64(1); g != w { - t.Errorf("foo = %v; want %v", g, w) - } - if g, w := m.Get("bar").Value(), int64(2); g != w { - t.Errorf("bar = %v; want %v", g, w) - } -} - -func TestCurrentFileDescriptors(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skipf("skipping on %v", runtime.GOOS) - } - n := CurrentFDs() - if n < 3 { - t.Fatalf("got %v; want >= 3", n) - } - - err := tstest.MinAllocsPerRun(t, 0, func() { - n = CurrentFDs() - }) - if err != nil { - t.Fatal(err) - } - - // Open some FDs. - const extra = 10 - for i := range extra { - f, err := os.Open("/proc/self/stat") - if err != nil { - t.Fatal(err) - } - defer f.Close() - t.Logf("fds for #%v = %v", i, CurrentFDs()) - } - - n2 := CurrentFDs() - if n2 < n+extra { - t.Errorf("fds changed from %v => %v, want to %v", n, n2, n+extra) - } -} - -func BenchmarkCurrentFileDescriptors(b *testing.B) { - b.ReportAllocs() - for range b.N { - _ = CurrentFDs() - } -} diff --git a/metrics/multilabelmap_test.go b/metrics/multilabelmap_test.go deleted file mode 100644 index 195696234e545..0000000000000 --- a/metrics/multilabelmap_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package metrics - -import ( - "bytes" - "encoding/json" - "expvar" - "fmt" - "io" - "testing" -) - -type L2 struct { - Foo string `prom:"foo"` - Bar string `prom:"bar"` -} - -func TestMultilabelMap(t *testing.T) { - m := new(MultiLabelMap[L2]) - m.Add(L2{"a", "b"}, 2) - m.Add(L2{"b", "c"}, 4) - m.Add(L2{"b", "b"}, 3) - m.Add(L2{"a", "a"}, 1) - - m.SetFloat(L2{"sf", "sf"}, 3.5) - m.SetFloat(L2{"sf", "sf"}, 5.5) - m.Set(L2{"sfunc", "sfunc"}, expvar.Func(func() any { return 3 })) - m.SetInt(L2{"si", "si"}, 3) - m.SetInt(L2{"si", "si"}, 5) - - cur := func() string { - var buf bytes.Buffer - m.Do(func(kv KeyValue[L2]) { - if buf.Len() > 0 { - buf.WriteString(",") - } - fmt.Fprintf(&buf, "%s/%s=%v", kv.Key.Foo, kv.Key.Bar, kv.Value) - }) - return buf.String() - } - - if g, w := cur(), "a/a=1,a/b=2,b/b=3,b/c=4,sf/sf=5.5,sfunc/sfunc=3,si/si=5"; g != w { - t.Errorf("got %q; want %q", g, w) - } - - var buf bytes.Buffer - m.WritePrometheus(&buf, "metricname") - const want = `metricname{foo="a",bar="a"} 1 -metricname{foo="a",bar="b"} 2 -metricname{foo="b",bar="b"} 3 -metricname{foo="b",bar="c"} 4 -metricname{foo="sf",bar="sf"} 5.5 -metricname{foo="sfunc",bar="sfunc"} 3 -metricname{foo="si",bar="si"} 5 -` - if got := buf.String(); got != want { - t.Errorf("promtheus output = %q; want %q", got, want) - } - - m.Delete(L2{"b", "b"}) - - if g, w := cur(), "a/a=1,a/b=2,b/c=4,sf/sf=5.5,sfunc/sfunc=3,si/si=5"; g != w { - t.Errorf("got %q; want %q", g, w) - } - - allocs := testing.AllocsPerRun(1000, func() { - m.Add(L2{"a", "a"}, 1) - }) - if allocs > 0 { - t.Errorf("allocs = %v; want 0", allocs) - } - m.Init() - if g, w := cur(), ""; g != w { - t.Errorf("got %q; want %q", g, w) - } - - writeAllocs := testing.AllocsPerRun(1000, func() { - m.WritePrometheus(io.Discard, "test") - }) - if writeAllocs > 0 { - t.Errorf("writeAllocs = %v; want 0", writeAllocs) - } -} - -func TestMultiLabelMapTypes(t *testing.T) { - type LabelTypes struct { - S string - B bool - I int - U uint - } - - m := new(MultiLabelMap[LabelTypes]) - m.Type = "counter" - m.Help = "some good stuff" - m.Add(LabelTypes{"a", true, -1, 2}, 3) - var buf bytes.Buffer - m.WritePrometheus(&buf, "metricname") - const want = `# TYPE metricname counter -# HELP metricname some good stuff -metricname{s="a",b="true",i="-1",u="2"} 3 -` - if got := buf.String(); got != want { - t.Errorf("got %q; want %q", got, want) - } - - writeAllocs := testing.AllocsPerRun(1000, func() { - m.WritePrometheus(io.Discard, "test") - }) - if writeAllocs > 0 { - t.Errorf("writeAllocs = %v; want 0", writeAllocs) - } -} - -func BenchmarkMultiLabelWriteAllocs(b *testing.B) { - b.ReportAllocs() - - m := new(MultiLabelMap[L2]) - m.Add(L2{"a", "b"}, 2) - m.Add(L2{"b", "c"}, 4) - m.Add(L2{"b", "b"}, 3) - m.Add(L2{"a", "a"}, 1) - - var w io.Writer = io.Discard - - b.ResetTimer() - for range b.N { - m.WritePrometheus(w, "test") - } -} - -func TestMultiLabelMapExpvar(t *testing.T) { - m := new(MultiLabelMap[L2]) - m.Add(L2{"a", "b"}, 2) - m.Add(L2{"b", "c"}, 4) - - em := new(expvar.Map) - em.Set("multi", m) - - // Ensure that the String method is valid JSON to ensure that it can be - // used by expvar. - encoded := []byte(em.String()) - if !json.Valid(encoded) { - t.Fatalf("invalid JSON: %s", encoded) - } - - t.Logf("em = %+v", em) -} diff --git a/net/art/art_test.go b/net/art/art_test.go deleted file mode 100644 index daf8553ca020d..0000000000000 --- a/net/art/art_test.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package art - -import ( - "os" - "testing" - - "tailscale.com/util/cibuild" -) - -func TestMain(m *testing.M) { - if cibuild.On() { - // Skip CI on GitHub for now - // TODO: https://github.com/tailscale/tailscale/issues/7866 - os.Exit(0) - } - os.Exit(m.Run()) -} diff --git a/net/art/stride_table_test.go b/net/art/stride_table_test.go deleted file mode 100644 index bff2bb7c507fd..0000000000000 --- a/net/art/stride_table_test.go +++ /dev/null @@ -1,411 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package art - -import ( - "bytes" - "fmt" - "math/rand" - "net/netip" - "runtime" - "sort" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestInversePrefix(t *testing.T) { - t.Parallel() - for i := range 256 { - for len := 0; len < 9; len++ { - addr := i & (0xFF << (8 - len)) - idx := prefixIndex(uint8(addr), len) - addr2, len2 := inversePrefixIndex(idx) - if addr2 != uint8(addr) || len2 != len { - t.Errorf("inverse(index(%d/%d)) != %d/%d", addr, len, addr2, len2) - } - } - } -} - -func TestHostIndex(t *testing.T) { - t.Parallel() - for i := range 256 { - got := hostIndex(uint8(i)) - want := prefixIndex(uint8(i), 8) - if got != want { - t.Errorf("hostIndex(%d) = %d, want %d", i, got, want) - } - } -} - -func TestStrideTableInsert(t *testing.T) { - t.Parallel() - // Verify that strideTable's lookup results after a bunch of inserts exactly - // match those of a naive implementation that just scans all prefixes on - // every lookup. The naive implementation is very slow, but its behavior is - // easy to verify by inspection. - - pfxs := shufflePrefixes(allPrefixes())[:100] - slow := slowTable[int]{pfxs} - fast := strideTable[int]{} - - if debugStrideInsert { - t.Logf("slow table:\n%s", slow.String()) - } - - for _, pfx := range pfxs { - fast.insert(pfx.addr, pfx.len, pfx.val) - if debugStrideInsert { - t.Logf("after insert %d/%d:\n%s", pfx.addr, pfx.len, fast.tableDebugString()) - } - } - - for i := range 256 { - addr := uint8(i) - slowVal, slowOK := slow.get(addr) - fastVal, fastOK := fast.get(addr) - if !getsEqual(fastVal, fastOK, slowVal, slowOK) { - t.Fatalf("strideTable.get(%d) = (%v, %v), want (%v, %v)", addr, fastVal, fastOK, slowVal, slowOK) - } - } -} - -func TestStrideTableInsertShuffled(t *testing.T) { - t.Parallel() - // The order in which routes are inserted into a route table does not - // influence the final shape of the table, as long as the same set of - // prefixes is being inserted. This test verifies that strideTable behaves - // this way. - // - // In addition to the basic shuffle test, we also check that this behavior - // is maintained if all inserted routes have the same value pointer. This - // shouldn't matter (the strideTable still needs to correctly account for - // each inserted route, regardless of associated value), but during initial - // development a subtle bug made the table corrupt itself in that setup, so - // this test includes a regression test for that. - - routes := shufflePrefixes(allPrefixes())[:100] - - zero := 0 - rt := strideTable[int]{} - // strideTable has a value interface, but internally has to keep - // track of distinct routes even if they all have the same - // value. rtZero uses the same value for all routes, and expects - // correct behavior. - rtZero := strideTable[int]{} - for _, route := range routes { - rt.insert(route.addr, route.len, route.val) - rtZero.insert(route.addr, route.len, zero) - } - - // Order of insertion should not affect the final shape of the stride table. - routes2 := append([]slowEntry[int](nil), routes...) // dup so we can print both slices on fail - for range 100 { - rand.Shuffle(len(routes2), func(i, j int) { routes2[i], routes2[j] = routes2[j], routes2[i] }) - rt2 := strideTable[int]{} - for _, route := range routes2 { - rt2.insert(route.addr, route.len, route.val) - } - if diff := cmp.Diff(rt.tableDebugString(), rt2.tableDebugString()); diff != "" { - t.Errorf("tables ended up different with different insertion order (-got+want):\n%s\n\nOrder 1: %v\nOrder 2: %v", diff, formatSlowEntriesShort(routes), formatSlowEntriesShort(routes2)) - } - - rtZero2 := strideTable[int]{} - for _, route := range routes2 { - rtZero2.insert(route.addr, route.len, zero) - } - if diff := cmp.Diff(rtZero.tableDebugString(), rtZero2.tableDebugString(), cmpDiffOpts...); diff != "" { - t.Errorf("tables with identical vals ended up different with different insertion order (-got+want):\n%s\n\nOrder 1: %v\nOrder 2: %v", diff, formatSlowEntriesShort(routes), formatSlowEntriesShort(routes2)) - } - } -} - -func TestStrideTableDelete(t *testing.T) { - t.Parallel() - // Compare route deletion to our reference slowTable. - pfxs := shufflePrefixes(allPrefixes())[:100] - slow := slowTable[int]{pfxs} - fast := strideTable[int]{} - - if debugStrideDelete { - t.Logf("slow table:\n%s", slow.String()) - } - - for _, pfx := range pfxs { - fast.insert(pfx.addr, pfx.len, pfx.val) - if debugStrideDelete { - t.Logf("after insert %d/%d:\n%s", pfx.addr, pfx.len, fast.tableDebugString()) - } - } - - toDelete := pfxs[:50] - for _, pfx := range toDelete { - slow.delete(pfx.addr, pfx.len) - fast.delete(pfx.addr, pfx.len) - } - - // Sanity check that slowTable seems to have done the right thing. - if cnt := len(slow.prefixes); cnt != 50 { - t.Fatalf("slowTable has %d entries after deletes, want 50", cnt) - } - - for i := range 256 { - addr := uint8(i) - slowVal, slowOK := slow.get(addr) - fastVal, fastOK := fast.get(addr) - if !getsEqual(fastVal, fastOK, slowVal, slowOK) { - t.Fatalf("strideTable.get(%d) = (%v, %v), want (%v, %v)", addr, fastVal, fastOK, slowVal, slowOK) - } - } -} - -func TestStrideTableDeleteShuffle(t *testing.T) { - t.Parallel() - // Same as TestStrideTableInsertShuffle, the order in which prefixes are - // deleted should not impact the final shape of the route table. - - routes := shufflePrefixes(allPrefixes())[:100] - toDelete := routes[:50] - - zero := 0 - rt := strideTable[int]{} - // strideTable has a value interface, but internally has to keep - // track of distinct routes even if they all have the same - // value. rtZero uses the same value for all routes, and expects - // correct behavior. - rtZero := strideTable[int]{} - for _, route := range routes { - rt.insert(route.addr, route.len, route.val) - rtZero.insert(route.addr, route.len, zero) - } - for _, route := range toDelete { - rt.delete(route.addr, route.len) - rtZero.delete(route.addr, route.len) - } - - // Order of deletion should not affect the final shape of the stride table. - toDelete2 := append([]slowEntry[int](nil), toDelete...) // dup so we can print both slices on fail - for range 100 { - rand.Shuffle(len(toDelete2), func(i, j int) { toDelete2[i], toDelete2[j] = toDelete2[j], toDelete2[i] }) - rt2 := strideTable[int]{} - for _, route := range routes { - rt2.insert(route.addr, route.len, route.val) - } - for _, route := range toDelete2 { - rt2.delete(route.addr, route.len) - } - if diff := cmp.Diff(rt.tableDebugString(), rt2.tableDebugString(), cmpDiffOpts...); diff != "" { - t.Errorf("tables ended up different with different deletion order (-got+want):\n%s\n\nOrder 1: %v\nOrder 2: %v", diff, formatSlowEntriesShort(toDelete), formatSlowEntriesShort(toDelete2)) - } - - rtZero2 := strideTable[int]{} - for _, route := range routes { - rtZero2.insert(route.addr, route.len, zero) - } - for _, route := range toDelete2 { - rtZero2.delete(route.addr, route.len) - } - if diff := cmp.Diff(rtZero.tableDebugString(), rtZero2.tableDebugString(), cmpDiffOpts...); diff != "" { - t.Errorf("tables with identical vals ended up different with different deletion order (-got+want):\n%s\n\nOrder 1: %v\nOrder 2: %v", diff, formatSlowEntriesShort(toDelete), formatSlowEntriesShort(toDelete2)) - } - } -} - -var strideRouteCount = []int{10, 50, 100, 200} - -// forCountAndOrdering runs the benchmark fn with different sets of routes. -// -// fn is called once for each combination of {num_routes, order}, where -// num_routes is the values in strideRouteCount, and order is the order of the -// routes in the list: random, largest prefix first (/0 to /8), and smallest -// prefix first (/8 to /0). -func forStrideCountAndOrdering(b *testing.B, fn func(b *testing.B, routes []slowEntry[int])) { - routes := shufflePrefixes(allPrefixes()) - for _, nroutes := range strideRouteCount { - b.Run(fmt.Sprint(nroutes), func(b *testing.B) { - runAndRecord := func(b *testing.B) { - b.ReportAllocs() - var startMem, endMem runtime.MemStats - runtime.ReadMemStats(&startMem) - fn(b, routes) - runtime.ReadMemStats(&endMem) - ops := float64(b.N) * float64(len(routes)) - allocs := float64(endMem.Mallocs - startMem.Mallocs) - bytes := float64(endMem.TotalAlloc - startMem.TotalAlloc) - b.ReportMetric(roundFloat64(allocs/ops), "allocs/op") - b.ReportMetric(roundFloat64(bytes/ops), "B/op") - } - - routes := append([]slowEntry[int](nil), routes[:nroutes]...) - b.Run("random_order", runAndRecord) - sort.Slice(routes, func(i, j int) bool { - if routes[i].len < routes[j].len { - return true - } - return routes[i].addr < routes[j].addr - }) - b.Run("largest_first", runAndRecord) - sort.Slice(routes, func(i, j int) bool { - if routes[j].len < routes[i].len { - return true - } - return routes[j].addr < routes[i].addr - }) - b.Run("smallest_first", runAndRecord) - }) - } -} - -func BenchmarkStrideTableInsertion(b *testing.B) { - forStrideCountAndOrdering(b, func(b *testing.B, routes []slowEntry[int]) { - val := 0 - for range b.N { - var rt strideTable[int] - for _, route := range routes { - rt.insert(route.addr, route.len, val) - } - } - inserts := float64(b.N) * float64(len(routes)) - elapsed := float64(b.Elapsed().Nanoseconds()) - elapsedSec := b.Elapsed().Seconds() - b.ReportMetric(elapsed/inserts, "ns/op") - b.ReportMetric(inserts/elapsedSec, "routes/s") - }) -} - -func BenchmarkStrideTableDeletion(b *testing.B) { - forStrideCountAndOrdering(b, func(b *testing.B, routes []slowEntry[int]) { - val := 0 - var rt strideTable[int] - for _, route := range routes { - rt.insert(route.addr, route.len, val) - } - - b.ResetTimer() - for range b.N { - rt2 := rt - for _, route := range routes { - rt2.delete(route.addr, route.len) - } - } - deletes := float64(b.N) * float64(len(routes)) - elapsed := float64(b.Elapsed().Nanoseconds()) - elapsedSec := b.Elapsed().Seconds() - b.ReportMetric(elapsed/deletes, "ns/op") - b.ReportMetric(deletes/elapsedSec, "routes/s") - }) -} - -var writeSink int - -func BenchmarkStrideTableGet(b *testing.B) { - // No need to forCountAndOrdering here, route lookup time is independent of - // the route count. - routes := shufflePrefixes(allPrefixes())[:100] - var rt strideTable[int] - for _, route := range routes { - rt.insert(route.addr, route.len, route.val) - } - - b.ResetTimer() - for i := range b.N { - writeSink, _ = rt.get(uint8(i)) - } - gets := float64(b.N) - elapsedSec := b.Elapsed().Seconds() - b.ReportMetric(gets/elapsedSec, "routes/s") -} - -// slowTable is an 8-bit routing table implemented as a set of prefixes that are -// explicitly scanned in full for every route lookup. It is very slow, but also -// reasonably easy to verify by inspection, and so a good comparison target for -// strideTable. -type slowTable[T any] struct { - prefixes []slowEntry[T] -} - -type slowEntry[T any] struct { - addr uint8 - len int - val T -} - -func (t *slowTable[T]) String() string { - pfxs := append([]slowEntry[T](nil), t.prefixes...) - sort.Slice(pfxs, func(i, j int) bool { - if pfxs[i].len != pfxs[j].len { - return pfxs[i].len < pfxs[j].len - } - return pfxs[i].addr < pfxs[j].addr - }) - var ret bytes.Buffer - for _, pfx := range pfxs { - fmt.Fprintf(&ret, "%3d/%d (%08b/%08b) = %v\n", pfx.addr, pfx.len, pfx.addr, pfxMask(pfx.len), pfx.val) - } - return ret.String() -} - -func (t *slowTable[T]) delete(addr uint8, prefixLen int) { - pfx := make([]slowEntry[T], 0, len(t.prefixes)) - for _, e := range t.prefixes { - if e.addr == addr && e.len == prefixLen { - continue - } - pfx = append(pfx, e) - } - t.prefixes = pfx -} - -func (t *slowTable[T]) get(addr uint8) (ret T, ok bool) { - var curLen = -1 - for _, e := range t.prefixes { - if addr&pfxMask(e.len) == e.addr && e.len >= curLen { - ret = e.val - curLen = e.len - } - } - return ret, curLen != -1 -} - -func pfxMask(pfxLen int) uint8 { - return 0xFF << (8 - pfxLen) -} - -func allPrefixes() []slowEntry[int] { - ret := make([]slowEntry[int], 0, lastHostIndex) - for i := 1; i < lastHostIndex+1; i++ { - a, l := inversePrefixIndex(i) - ret = append(ret, slowEntry[int]{a, l, i}) - } - return ret -} - -func shufflePrefixes(pfxs []slowEntry[int]) []slowEntry[int] { - rand.Shuffle(len(pfxs), func(i, j int) { pfxs[i], pfxs[j] = pfxs[j], pfxs[i] }) - return pfxs -} - -func formatSlowEntriesShort[T any](ents []slowEntry[T]) string { - var ret []string - for _, ent := range ents { - ret = append(ret, fmt.Sprintf("%d/%d", ent.addr, ent.len)) - } - return "[" + strings.Join(ret, " ") + "]" -} - -var cmpDiffOpts = []cmp.Option{ - cmp.Comparer(func(a, b netip.Prefix) bool { return a == b }), -} - -func getsEqual[T comparable](a T, aOK bool, b T, bOK bool) bool { - if !aOK && !bOK { - return true - } - if aOK != bOK { - return false - } - return a == b -} diff --git a/net/art/table_test.go b/net/art/table_test.go deleted file mode 100644 index a129c8484ddcd..0000000000000 --- a/net/art/table_test.go +++ /dev/null @@ -1,1218 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package art - -import ( - crand "crypto/rand" - "fmt" - "math/rand" - "net/netip" - "runtime" - "strconv" - "testing" - "time" -) - -func TestRegression(t *testing.T) { - // These tests are specific triggers for subtle correctness issues - // that came up during initial implementation. Even if they seem - // arbitrary, please do not clean them up. They are checking edge - // cases that are very easy to get wrong, and quite difficult for - // the other statistical tests to trigger promptly. - - t.Run("prefixes_aligned_on_stride_boundary", func(t *testing.T) { - // Regression test for computePrefixSplit called with equal - // arguments. - tbl := &Table[int]{} - slow := slowPrefixTable[int]{} - p := netip.MustParsePrefix - - tbl.Insert(p("226.205.197.0/24"), 1) - slow.insert(p("226.205.197.0/24"), 1) - tbl.Insert(p("226.205.0.0/16"), 2) - slow.insert(p("226.205.0.0/16"), 2) - - probe := netip.MustParseAddr("226.205.121.152") - got, gotOK := tbl.Get(probe) - want, wantOK := slow.get(probe) - if !getsEqual(got, gotOK, want, wantOK) { - t.Fatalf("got (%v, %v), want (%v, %v)", got, gotOK, want, wantOK) - } - }) - - t.Run("parent_prefix_inserted_in_different_orders", func(t *testing.T) { - // Regression test for the off-by-one correction applied - // within computePrefixSplit. - t1, t2 := &Table[int]{}, &Table[int]{} - p := netip.MustParsePrefix - - t1.Insert(p("136.20.0.0/16"), 1) - t1.Insert(p("136.20.201.62/32"), 2) - - t2.Insert(p("136.20.201.62/32"), 2) - t2.Insert(p("136.20.0.0/16"), 1) - - a := netip.MustParseAddr("136.20.54.139") - got1, ok1 := t1.Get(a) - got2, ok2 := t2.Get(a) - if !getsEqual(got1, ok1, got2, ok2) { - t.Errorf("Get(%q) is insertion order dependent: t1=(%v, %v), t2=(%v, %v)", a, got1, ok1, got2, ok2) - } - }) -} - -func TestComputePrefixSplit(t *testing.T) { - // These tests are partially redundant with other tests. Please - // keep them anyway. computePrefixSplit's behavior is remarkably - // subtle, and all the test cases listed below come from - // hard-earned debugging of malformed route tables. - - var tests = []struct { - // prefixA can be a /8, /16 or /24 (v4). - // prefixB can be anything /9 or more specific. - prefixA, prefixB string - lastCommon string - aStride, bStride uint8 - }{ - {"192.168.1.0/24", "192.168.5.5/32", "192.168.0.0/16", 1, 5}, - {"192.168.129.0/24", "192.168.128.0/17", "192.168.0.0/16", 129, 128}, - {"192.168.5.0/24", "192.168.0.0/16", "192.0.0.0/8", 168, 168}, - {"192.168.0.0/16", "192.168.0.0/16", "192.0.0.0/8", 168, 168}, - {"ff:aaaa:aaaa::1/128", "ff:aaaa::/120", "ff:aaaa::/32", 170, 0}, - } - - for _, test := range tests { - a, b := netip.MustParsePrefix(test.prefixA), netip.MustParsePrefix(test.prefixB) - gotLastCommon, gotAStride, gotBStride := computePrefixSplit(a, b) - if want := netip.MustParsePrefix(test.lastCommon); gotLastCommon != want || gotAStride != test.aStride || gotBStride != test.bStride { - t.Errorf("computePrefixSplit(%q, %q) = %s, %d, %d; want %s, %d, %d", a, b, gotLastCommon, gotAStride, gotBStride, want, test.aStride, test.bStride) - } - } -} - -func TestInsert(t *testing.T) { - tbl := &Table[int]{} - p := netip.MustParsePrefix - - // Create a new leaf strideTable, with compressed path - tbl.Insert(p("192.168.0.1/32"), 1) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", -1}, - {"192.168.0.3", -1}, - {"192.168.0.255", -1}, - {"192.168.1.1", -1}, - {"192.170.1.1", -1}, - {"192.180.0.1", -1}, - {"192.180.3.5", -1}, - {"10.0.0.5", -1}, - {"10.0.0.15", -1}, - }) - - // Insert into previous leaf, no tree changes - tbl.Insert(p("192.168.0.2/32"), 2) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", -1}, - {"192.168.0.255", -1}, - {"192.168.1.1", -1}, - {"192.170.1.1", -1}, - {"192.180.0.1", -1}, - {"192.180.3.5", -1}, - {"10.0.0.5", -1}, - {"10.0.0.15", -1}, - }) - - // Insert into previous leaf, unaligned prefix covering the /32s - tbl.Insert(p("192.168.0.0/26"), 7) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", 7}, - {"192.168.0.255", -1}, - {"192.168.1.1", -1}, - {"192.170.1.1", -1}, - {"192.180.0.1", -1}, - {"192.180.3.5", -1}, - {"10.0.0.5", -1}, - {"10.0.0.15", -1}, - }) - - // Create a different leaf elsewhere - tbl.Insert(p("10.0.0.0/27"), 3) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", 7}, - {"192.168.0.255", -1}, - {"192.168.1.1", -1}, - {"192.170.1.1", -1}, - {"192.180.0.1", -1}, - {"192.180.3.5", -1}, - {"10.0.0.5", 3}, - {"10.0.0.15", 3}, - }) - - // Insert that creates a new intermediate table and a new child - tbl.Insert(p("192.168.1.1/32"), 4) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", 7}, - {"192.168.0.255", -1}, - {"192.168.1.1", 4}, - {"192.170.1.1", -1}, - {"192.180.0.1", -1}, - {"192.180.3.5", -1}, - {"10.0.0.5", 3}, - {"10.0.0.15", 3}, - }) - - // Insert that creates a new intermediate table but no new child - tbl.Insert(p("192.170.0.0/16"), 5) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", 7}, - {"192.168.0.255", -1}, - {"192.168.1.1", 4}, - {"192.170.1.1", 5}, - {"192.180.0.1", -1}, - {"192.180.3.5", -1}, - {"10.0.0.5", 3}, - {"10.0.0.15", 3}, - }) - - // New leaf in a different subtree, so the next insert can test a - // variant of decompression. - tbl.Insert(p("192.180.0.1/32"), 8) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", 7}, - {"192.168.0.255", -1}, - {"192.168.1.1", 4}, - {"192.170.1.1", 5}, - {"192.180.0.1", 8}, - {"192.180.3.5", -1}, - {"10.0.0.5", 3}, - {"10.0.0.15", 3}, - }) - - // Insert that creates a new intermediate table but no new child, - // with an unaligned intermediate - tbl.Insert(p("192.180.0.0/21"), 9) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", 7}, - {"192.168.0.255", -1}, - {"192.168.1.1", 4}, - {"192.170.1.1", 5}, - {"192.180.0.1", 8}, - {"192.180.3.5", 9}, - {"10.0.0.5", 3}, - {"10.0.0.15", 3}, - }) - - // Insert a default route, those have their own codepath. - tbl.Insert(p("0.0.0.0/0"), 6) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.168.0.3", 7}, - {"192.168.0.255", 6}, - {"192.168.1.1", 4}, - {"192.170.1.1", 5}, - {"192.180.0.1", 8}, - {"192.180.3.5", 9}, - {"10.0.0.5", 3}, - {"10.0.0.15", 3}, - }) - - // Now all of the above again, but for IPv6. - - // Create a new leaf strideTable, with compressed path - tbl.Insert(p("ff:aaaa::1/128"), 1) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", -1}, - {"ff:aaaa::3", -1}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", -1}, - {"ff:aaaa:aaaa:bbbb::1", -1}, - {"ff:cccc::1", -1}, - {"ff:cccc::ff", -1}, - {"ffff:bbbb::5", -1}, - {"ffff:bbbb::15", -1}, - }) - - // Insert into previous leaf, no tree changes - tbl.Insert(p("ff:aaaa::2/128"), 2) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", -1}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", -1}, - {"ff:aaaa:aaaa:bbbb::1", -1}, - {"ff:cccc::1", -1}, - {"ff:cccc::ff", -1}, - {"ffff:bbbb::5", -1}, - {"ffff:bbbb::15", -1}, - }) - - // Insert into previous leaf, unaligned prefix covering the /128s - tbl.Insert(p("ff:aaaa::/125"), 7) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", 7}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", -1}, - {"ff:aaaa:aaaa:bbbb::1", -1}, - {"ff:cccc::1", -1}, - {"ff:cccc::ff", -1}, - {"ffff:bbbb::5", -1}, - {"ffff:bbbb::15", -1}, - }) - - // Create a different leaf elsewhere - tbl.Insert(p("ffff:bbbb::/120"), 3) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", 7}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", -1}, - {"ff:aaaa:aaaa:bbbb::1", -1}, - {"ff:cccc::1", -1}, - {"ff:cccc::ff", -1}, - {"ffff:bbbb::5", 3}, - {"ffff:bbbb::15", 3}, - }) - - // Insert that creates a new intermediate table and a new child - tbl.Insert(p("ff:aaaa:aaaa::1/128"), 4) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", 7}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", 4}, - {"ff:aaaa:aaaa:bbbb::1", -1}, - {"ff:cccc::1", -1}, - {"ff:cccc::ff", -1}, - {"ffff:bbbb::5", 3}, - {"ffff:bbbb::15", 3}, - }) - - // Insert that creates a new intermediate table but no new child - tbl.Insert(p("ff:aaaa:aaaa:bb00::/56"), 5) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", 7}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", 4}, - {"ff:aaaa:aaaa:bbbb::1", 5}, - {"ff:cccc::1", -1}, - {"ff:cccc::ff", -1}, - {"ffff:bbbb::5", 3}, - {"ffff:bbbb::15", 3}, - }) - - // New leaf in a different subtree, so the next insert can test a - // variant of decompression. - tbl.Insert(p("ff:cccc::1/128"), 8) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", 7}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", 4}, - {"ff:aaaa:aaaa:bbbb::1", 5}, - {"ff:cccc::1", 8}, - {"ff:cccc::ff", -1}, - {"ffff:bbbb::5", 3}, - {"ffff:bbbb::15", 3}, - }) - - // Insert that creates a new intermediate table but no new child, - // with an unaligned intermediate - tbl.Insert(p("ff:cccc::/37"), 9) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", 7}, - {"ff:aaaa::255", -1}, - {"ff:aaaa:aaaa::1", 4}, - {"ff:aaaa:aaaa:bbbb::1", 5}, - {"ff:cccc::1", 8}, - {"ff:cccc::ff", 9}, - {"ffff:bbbb::5", 3}, - {"ffff:bbbb::15", 3}, - }) - - // Insert a default route, those have their own codepath. - tbl.Insert(p("::/0"), 6) - checkRoutes(t, tbl, []tableTest{ - {"ff:aaaa::1", 1}, - {"ff:aaaa::2", 2}, - {"ff:aaaa::3", 7}, - {"ff:aaaa::255", 6}, - {"ff:aaaa:aaaa::1", 4}, - {"ff:aaaa:aaaa:bbbb::1", 5}, - {"ff:cccc::1", 8}, - {"ff:cccc::ff", 9}, - {"ffff:bbbb::5", 3}, - {"ffff:bbbb::15", 3}, - }) -} - -func TestDelete(t *testing.T) { - t.Parallel() - p := netip.MustParsePrefix - - t.Run("prefix_in_root", func(t *testing.T) { - // Add/remove prefix from root table. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - - tbl.Insert(p("10.0.0.0/8"), 1) - checkRoutes(t, tbl, []tableTest{ - {"10.0.0.1", 1}, - {"255.255.255.255", -1}, - }) - checkSize(t, tbl, 2) - tbl.Delete(p("10.0.0.0/8")) - checkRoutes(t, tbl, []tableTest{ - {"10.0.0.1", -1}, - {"255.255.255.255", -1}, - }) - checkSize(t, tbl, 2) - }) - - t.Run("prefix_in_leaf", func(t *testing.T) { - // Create, then delete a single leaf table. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - - tbl.Insert(p("192.168.0.1/32"), 1) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"255.255.255.255", -1}, - }) - checkSize(t, tbl, 3) - tbl.Delete(p("192.168.0.1/32")) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", -1}, - {"255.255.255.255", -1}, - }) - checkSize(t, tbl, 2) - }) - - t.Run("intermediate_no_routes", func(t *testing.T) { - // Create an intermediate with 2 children, then delete one leaf. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - tbl.Insert(p("192.168.0.1/32"), 1) - tbl.Insert(p("192.180.0.1/32"), 2) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.180.0.1", 2}, - {"192.40.0.1", -1}, - }) - checkSize(t, tbl, 5) // 2 roots, 1 intermediate, 2 leaves - tbl.Delete(p("192.180.0.1/32")) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.180.0.1", -1}, - {"192.40.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - }) - - t.Run("intermediate_with_route", func(t *testing.T) { - // Same, but the intermediate carries a route as well. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - tbl.Insert(p("192.168.0.1/32"), 1) - tbl.Insert(p("192.180.0.1/32"), 2) - tbl.Insert(p("192.0.0.0/10"), 3) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.180.0.1", 2}, - {"192.40.0.1", 3}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 5) // 2 roots, 1 intermediate, 2 leaves - tbl.Delete(p("192.180.0.1/32")) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.180.0.1", -1}, - {"192.40.0.1", 3}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 4) // 2 roots, 1 intermediate w/route, 1 leaf - }) - - t.Run("intermediate_many_leaves", func(t *testing.T) { - // Intermediate with 3 leaves, then delete one leaf. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - tbl.Insert(p("192.168.0.1/32"), 1) - tbl.Insert(p("192.180.0.1/32"), 2) - tbl.Insert(p("192.200.0.1/32"), 3) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.180.0.1", 2}, - {"192.200.0.1", 3}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 6) // 2 roots, 1 intermediate, 3 leaves - tbl.Delete(p("192.180.0.1/32")) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.180.0.1", -1}, - {"192.200.0.1", 3}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 5) // 2 roots, 1 intermediate, 2 leaves - }) - - t.Run("nosuchprefix_missing_child", func(t *testing.T) { - // Delete non-existent prefix, missing strideTable path. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - tbl.Insert(p("192.168.0.1/32"), 1) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - tbl.Delete(p("200.0.0.0/32")) // lookup miss in root - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - }) - - t.Run("nosuchprefix_wrong_turn", func(t *testing.T) { - // Delete non-existent prefix, strideTable path exists but - // with a wrong turn. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - tbl.Insert(p("192.168.0.1/32"), 1) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - tbl.Delete(p("192.40.0.0/32")) // finds wrong child - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - }) - - t.Run("nosuchprefix_not_in_leaf", func(t *testing.T) { - // Delete non-existent prefix, strideTable path exists but - // leaf doesn't contain route. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - tbl.Insert(p("192.168.0.1/32"), 1) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - tbl.Delete(p("192.168.0.5/32")) // right leaf, no route - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - }) - - t.Run("intermediate_with_deleted_route", func(t *testing.T) { - // Intermediate table loses its last route and becomes - // compactable. - tbl := &Table[int]{} - checkSize(t, tbl, 2) - tbl.Insert(p("192.168.0.1/32"), 1) - tbl.Insert(p("192.168.0.0/22"), 2) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", 2}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 4) // 2 roots, 1 intermediate w/route, 1 leaf - tbl.Delete(p("192.168.0.0/22")) - checkRoutes(t, tbl, []tableTest{ - {"192.168.0.1", 1}, - {"192.168.0.2", -1}, - {"192.255.0.1", -1}, - }) - checkSize(t, tbl, 3) // 2 roots, 1 leaf - }) - - t.Run("default_route", func(t *testing.T) { - // Default routes have a special case in the code. - tbl := &Table[int]{} - - tbl.Insert(p("0.0.0.0/0"), 1) - tbl.Delete(p("0.0.0.0/0")) - - checkRoutes(t, tbl, []tableTest{ - {"1.2.3.4", -1}, - }) - checkSize(t, tbl, 2) // 2 roots - }) -} - -func TestInsertCompare(t *testing.T) { - // Create large route tables repeatedly, and compare Table's - // behavior to a naive and slow but correct implementation. - t.Parallel() - pfxs := randomPrefixes(10_000) - - slow := slowPrefixTable[int]{pfxs} - fast := Table[int]{} - - for _, pfx := range pfxs { - fast.Insert(pfx.pfx, pfx.val) - } - - if debugInsert { - t.Log(fast.debugSummary()) - } - - seenVals4 := map[int]bool{} - seenVals6 := map[int]bool{} - for range 10_000 { - a := randomAddr() - slowVal, slowOK := slow.get(a) - fastVal, fastOK := fast.Get(a) - if !getsEqual(slowVal, slowOK, fastVal, fastOK) { - t.Fatalf("get(%q) = (%v, %v), want (%v, %v)", a, fastVal, fastOK, slowVal, slowOK) - } - if a.Is6() { - seenVals6[fastVal] = true - } else { - seenVals4[fastVal] = true - } - } - - // Empirically, 10k probes into 5k v4 prefixes and 5k v6 prefixes results in - // ~1k distinct values for v4 and ~300 for v6. distinct routes. This sanity - // check that we didn't just return a single route for everything should be - // very generous indeed. - if cnt := len(seenVals4); cnt < 10 { - t.Fatalf("saw %d distinct v4 route results, statistically expected ~1000", cnt) - } - if cnt := len(seenVals6); cnt < 10 { - t.Fatalf("saw %d distinct v6 route results, statistically expected ~300", cnt) - } -} - -func TestInsertShuffled(t *testing.T) { - // The order in which you insert prefixes into a route table - // should not matter, as long as you're inserting the same set of - // routes. Verify that this is true, because ART does execute - // vastly different code depending on the order of insertion, even - // if the end result is identical. - // - // If you're here because this package's tests are slow and you - // want to make them faster, please do not delete this test (or - // any test, really). It may seem excessive to test this, but - // these shuffle tests found a lot of very nasty edge cases during - // development, and you _really_ don't want to be debugging a - // faulty route table in production. - t.Parallel() - pfxs := randomPrefixes(1000) - var pfxs2 []slowPrefixEntry[int] - - defer func() { - if t.Failed() { - t.Logf("pre-shuffle: %#v", pfxs) - t.Logf("post-shuffle: %#v", pfxs2) - } - }() - - for range 10 { - pfxs2 := append([]slowPrefixEntry[int](nil), pfxs...) - rand.Shuffle(len(pfxs2), func(i, j int) { pfxs2[i], pfxs2[j] = pfxs2[j], pfxs2[i] }) - - addrs := make([]netip.Addr, 0, 10_000) - for range 10_000 { - addrs = append(addrs, randomAddr()) - } - - rt := Table[int]{} - rt2 := Table[int]{} - - for _, pfx := range pfxs { - rt.Insert(pfx.pfx, pfx.val) - } - for _, pfx := range pfxs2 { - rt2.Insert(pfx.pfx, pfx.val) - } - - for _, a := range addrs { - val1, ok1 := rt.Get(a) - val2, ok2 := rt2.Get(a) - if !getsEqual(val1, ok1, val2, ok2) { - t.Fatalf("get(%q) = (%v, %v), want (%v, %v)", a, val2, ok2, val1, ok1) - } - } - } -} - -func TestDeleteCompare(t *testing.T) { - // Create large route tables repeatedly, delete half of their - // prefixes, and compare Table's behavior to a naive and slow but - // correct implementation. - t.Parallel() - - const ( - numPrefixes = 10_000 // total prefixes to insert (test deletes 50% of them) - numPerFamily = numPrefixes / 2 - deleteCut = numPerFamily / 2 - numProbes = 10_000 // random addr lookups to do - ) - - // We have to do this little dance instead of just using allPrefixes, - // because we want pfxs and toDelete to be non-overlapping sets. - all4, all6 := randomPrefixes4(numPerFamily), randomPrefixes6(numPerFamily) - pfxs := append([]slowPrefixEntry[int](nil), all4[:deleteCut]...) - pfxs = append(pfxs, all6[:deleteCut]...) - toDelete := append([]slowPrefixEntry[int](nil), all4[deleteCut:]...) - toDelete = append(toDelete, all6[deleteCut:]...) - - defer func() { - if t.Failed() { - for _, pfx := range pfxs { - fmt.Printf("%q, ", pfx.pfx) - } - fmt.Println("") - for _, pfx := range toDelete { - fmt.Printf("%q, ", pfx.pfx) - } - fmt.Println("") - } - }() - - slow := slowPrefixTable[int]{pfxs} - fast := Table[int]{} - - for _, pfx := range pfxs { - fast.Insert(pfx.pfx, pfx.val) - } - - for _, pfx := range toDelete { - fast.Insert(pfx.pfx, pfx.val) - } - for _, pfx := range toDelete { - fast.Delete(pfx.pfx) - } - - seenVals4 := map[int]bool{} - seenVals6 := map[int]bool{} - for range numProbes { - a := randomAddr() - slowVal, slowOK := slow.get(a) - fastVal, fastOK := fast.Get(a) - if !getsEqual(slowVal, slowOK, fastVal, fastOK) { - t.Fatalf("get(%q) = (%v, %v), want (%v, %v)", a, fastVal, fastOK, slowVal, slowOK) - } - if a.Is6() { - seenVals6[fastVal] = true - } else { - seenVals4[fastVal] = true - } - } - // Empirically, 10k probes into 5k v4 prefixes and 5k v6 prefixes results in - // ~1k distinct values for v4 and ~300 for v6. distinct routes. This sanity - // check that we didn't just return a single route for everything should be - // very generous indeed. - if cnt := len(seenVals4); cnt < 10 { - t.Fatalf("saw %d distinct v4 route results, statistically expected ~1000", cnt) - } - if cnt := len(seenVals6); cnt < 10 { - t.Fatalf("saw %d distinct v6 route results, statistically expected ~300", cnt) - } -} - -func TestDeleteShuffled(t *testing.T) { - // The order in which you delete prefixes from a route table - // should not matter, as long as you're deleting the same set of - // routes. Verify that this is true, because ART does execute - // vastly different code depending on the order of deletions, even - // if the end result is identical. - // - // If you're here because this package's tests are slow and you - // want to make them faster, please do not delete this test (or - // any test, really). It may seem excessive to test this, but - // these shuffle tests found a lot of very nasty edge cases during - // development, and you _really_ don't want to be debugging a - // faulty route table in production. - t.Parallel() - - const ( - numPrefixes = 10_000 // prefixes to insert (test deletes 50% of them) - numPerFamily = numPrefixes / 2 - deleteCut = numPerFamily / 2 - numProbes = 10_000 // random addr lookups to do - ) - - // We have to do this little dance instead of just using allPrefixes, - // because we want pfxs and toDelete to be non-overlapping sets. - all4, all6 := randomPrefixes4(numPerFamily), randomPrefixes6(numPerFamily) - pfxs := append([]slowPrefixEntry[int](nil), all4[:deleteCut]...) - pfxs = append(pfxs, all6[:deleteCut]...) - toDelete := append([]slowPrefixEntry[int](nil), all4[deleteCut:]...) - toDelete = append(toDelete, all6[deleteCut:]...) - - rt := Table[int]{} - for _, pfx := range pfxs { - rt.Insert(pfx.pfx, pfx.val) - } - for _, pfx := range toDelete { - rt.Insert(pfx.pfx, pfx.val) - } - for _, pfx := range toDelete { - rt.Delete(pfx.pfx) - } - - for range 10 { - pfxs2 := append([]slowPrefixEntry[int](nil), pfxs...) - toDelete2 := append([]slowPrefixEntry[int](nil), toDelete...) - rand.Shuffle(len(toDelete2), func(i, j int) { toDelete2[i], toDelete2[j] = toDelete2[j], toDelete2[i] }) - rt2 := Table[int]{} - for _, pfx := range pfxs2 { - rt2.Insert(pfx.pfx, pfx.val) - } - for _, pfx := range toDelete2 { - rt2.Insert(pfx.pfx, pfx.val) - } - for _, pfx := range toDelete2 { - rt2.Delete(pfx.pfx) - } - - // Diffing a deep tree of tables gives cmp.Diff a nervous breakdown, so - // test for equivalence statistically with random probes instead. - for range numProbes { - a := randomAddr() - val1, ok1 := rt.Get(a) - val2, ok2 := rt2.Get(a) - if !getsEqual(val1, ok1, val2, ok2) { - t.Errorf("get(%q) = (%v, %v), want (%v, %v)", a, val2, ok2, val1, ok1) - } - } - } -} - -func TestDeleteIsReverseOfInsert(t *testing.T) { - // Insert N prefixes, then delete those same prefixes in reverse - // order. Each deletion should exactly undo the internal structure - // changes that each insert did. - const N = 100 - - var tab Table[int] - prefixes := randomPrefixes(N) - - defer func() { - if t.Failed() { - fmt.Printf("the prefixes that fail the test: %v\n", prefixes) - } - }() - - want := make([]string, 0, len(prefixes)) - for _, p := range prefixes { - want = append(want, tab.debugSummary()) - tab.Insert(p.pfx, p.val) - } - - for i := len(prefixes) - 1; i >= 0; i-- { - tab.Delete(prefixes[i].pfx) - if got := tab.debugSummary(); got != want[i] { - t.Fatalf("after delete %d, mismatch:\n\n got: %s\n\nwant: %s", i, got, want[i]) - } - } -} - -type tableTest struct { - // addr is an IP address string to look up in a route table. - addr string - // want is the expected >=0 value associated with the route, or -1 - // if we expect a lookup miss. - want int -} - -// checkRoutes verifies that the route lookups in tt return the -// expected results on tbl. -func checkRoutes(t *testing.T, tbl *Table[int], tt []tableTest) { - t.Helper() - for _, tc := range tt { - v, ok := tbl.Get(netip.MustParseAddr(tc.addr)) - if !ok && tc.want != -1 { - t.Errorf("lookup %q got (%v, %v), want (_, false)", tc.addr, v, ok) - } - if ok && v != tc.want { - t.Errorf("lookup %q got (%v, %v), want (%v, true)", tc.addr, v, ok, tc.want) - } - } -} - -// 100k routes for IPv6, at the current size of strideTable and strideEntry, is -// in the ballpark of 4GiB if you assume worst-case prefix distribution. Future -// optimizations will knock down the memory consumption by over an order of -// magnitude, so for now just skip the 100k benchmarks to stay well away of -// OOMs. -// -// TODO(go/bug/7781): reenable larger table tests once memory utilization is -// optimized. -var benchRouteCount = []int{10, 100, 1000, 10_000} //, 100_000} - -// forFamilyAndCount runs the benchmark fn with different sets of -// routes. -// -// fn is called once for each combination of {addr_family, num_routes}, -// where addr_family is ipv4 or ipv6, num_routes is the values in -// benchRouteCount. -func forFamilyAndCount(b *testing.B, fn func(b *testing.B, routes []slowPrefixEntry[int])) { - for _, fam := range []string{"ipv4", "ipv6"} { - rng := randomPrefixes4 - if fam == "ipv6" { - rng = randomPrefixes6 - } - b.Run(fam, func(b *testing.B) { - for _, nroutes := range benchRouteCount { - routes := rng(nroutes) - b.Run(fmt.Sprint(nroutes), func(b *testing.B) { - fn(b, routes) - }) - } - }) - } -} - -func BenchmarkTableInsertion(b *testing.B) { - forFamilyAndCount(b, func(b *testing.B, routes []slowPrefixEntry[int]) { - b.StopTimer() - b.ResetTimer() - var startMem, endMem runtime.MemStats - runtime.ReadMemStats(&startMem) - b.StartTimer() - for range b.N { - var rt Table[int] - for _, route := range routes { - rt.Insert(route.pfx, route.val) - } - } - b.StopTimer() - runtime.ReadMemStats(&endMem) - inserts := float64(b.N) * float64(len(routes)) - allocs := float64(endMem.Mallocs - startMem.Mallocs) - bytes := float64(endMem.TotalAlloc - startMem.TotalAlloc) - elapsed := float64(b.Elapsed().Nanoseconds()) - elapsedSec := b.Elapsed().Seconds() - b.ReportMetric(elapsed/inserts, "ns/op") - b.ReportMetric(inserts/elapsedSec, "routes/s") - b.ReportMetric(roundFloat64(allocs/inserts), "avg-allocs/op") - b.ReportMetric(roundFloat64(bytes/inserts), "avg-B/op") - }) -} - -func BenchmarkTableDelete(b *testing.B) { - forFamilyAndCount(b, func(b *testing.B, routes []slowPrefixEntry[int]) { - // Collect memstats for one round of insertions, so we can remove it - // from the total at the end and get only the deletion alloc count. - insertAllocs, insertBytes := getMemCost(func() { - var rt Table[int] - for _, route := range routes { - rt.Insert(route.pfx, route.val) - } - }) - insertAllocs *= float64(b.N) - insertBytes *= float64(b.N) - - var t runningTimer - allocs, bytes := getMemCost(func() { - for range b.N { - var rt Table[int] - for _, route := range routes { - rt.Insert(route.pfx, route.val) - } - t.Start() - for _, route := range routes { - rt.Delete(route.pfx) - } - t.Stop() - } - }) - inserts := float64(b.N) * float64(len(routes)) - allocs -= insertAllocs - bytes -= insertBytes - elapsed := float64(t.Elapsed().Nanoseconds()) - elapsedSec := t.Elapsed().Seconds() - b.ReportMetric(elapsed/inserts, "ns/op") - b.ReportMetric(inserts/elapsedSec, "routes/s") - b.ReportMetric(roundFloat64(allocs/inserts), "avg-allocs/op") - b.ReportMetric(roundFloat64(bytes/inserts), "avg-B/op") - }) -} - -func BenchmarkTableGet(b *testing.B) { - forFamilyAndCount(b, func(b *testing.B, routes []slowPrefixEntry[int]) { - genAddr := randomAddr4 - if routes[0].pfx.Addr().Is6() { - genAddr = randomAddr6 - } - var rt Table[int] - for _, route := range routes { - rt.Insert(route.pfx, route.val) - } - addrAllocs, addrBytes := getMemCost(func() { - // Have to run genAddr more than once, otherwise the reported - // cost is 16 bytes - presumably due to some amortized costs in - // the memory allocator? Either way, empirically 100 iterations - // reliably reports the correct cost. - for range 100 { - _ = genAddr() - } - }) - addrAllocs /= 100 - addrBytes /= 100 - var t runningTimer - allocs, bytes := getMemCost(func() { - for range b.N { - addr := genAddr() - t.Start() - writeSink, _ = rt.Get(addr) - t.Stop() - } - }) - b.ReportAllocs() // Enables the output, but we report manually below - allocs -= (addrAllocs * float64(b.N)) - bytes -= (addrBytes * float64(b.N)) - lookups := float64(b.N) - elapsed := float64(t.Elapsed().Nanoseconds()) - elapsedSec := float64(t.Elapsed().Seconds()) - b.ReportMetric(elapsed/lookups, "ns/op") - b.ReportMetric(lookups/elapsedSec, "addrs/s") - b.ReportMetric(allocs/lookups, "allocs/op") - b.ReportMetric(bytes/lookups, "B/op") - - }) -} - -// getMemCost runs fn 100 times and returns the number of allocations and bytes -// allocated by each call to fn. -// -// Note that if your fn allocates very little memory (less than ~16 bytes), you -// should make fn run its workload ~100 times and divide the results of -// getMemCost yourself. Otherwise, the byte count you get will be rounded up due -// to the memory allocator's bucketing granularity. -func getMemCost(fn func()) (allocs, bytes float64) { - var start, end runtime.MemStats - runtime.ReadMemStats(&start) - fn() - runtime.ReadMemStats(&end) - return float64(end.Mallocs - start.Mallocs), float64(end.TotalAlloc - start.TotalAlloc) -} - -// runningTimer is a timer that keeps track of the cumulative time it's spent -// running since creation. A newly created runningTimer is stopped. -// -// This timer exists because some of our benchmarks have to interleave costly -// ancillary logic in each benchmark iteration, rather than being able to -// front-load all the work before a single b.ResetTimer(). -// -// As it turns out, b.StartTimer() and b.StopTimer() are expensive function -// calls, because they do costly memory allocation accounting on every call. -// Starting and stopping the benchmark timer in every b.N loop iteration slows -// the benchmarks down by orders of magnitude. -// -// So, rather than rely on testing.B's timing facility, we use this very -// lightweight timer combined with getMemCost to do our own accounting more -// efficiently. -type runningTimer struct { - cumulative time.Duration - start time.Time -} - -func (t *runningTimer) Start() { - t.Stop() - t.start = time.Now() -} - -func (t *runningTimer) Stop() { - if t.start.IsZero() { - return - } - t.cumulative += time.Since(t.start) - t.start = time.Time{} -} - -func (t *runningTimer) Elapsed() time.Duration { - return t.cumulative -} - -func checkSize(t *testing.T, tbl *Table[int], want int) { - t.Helper() - if got := tbl.numStrides(); got != want { - t.Errorf("wrong table size, got %d strides want %d", got, want) - } -} - -func (t *Table[T]) numStrides() int { - seen := map[*strideTable[T]]bool{} - return t.numStridesRec(seen, &t.v4) + t.numStridesRec(seen, &t.v6) -} - -func (t *Table[T]) numStridesRec(seen map[*strideTable[T]]bool, st *strideTable[T]) int { - ret := 1 - if st.childRefs == 0 { - return ret - } - for _, c := range st.children { - if c == nil || seen[c] { - continue - } - seen[c] = true - ret += t.numStridesRec(seen, c) - } - return ret -} - -// slowPrefixTable is a routing table implemented as a set of prefixes that are -// explicitly scanned in full for every route lookup. It is very slow, but also -// reasonably easy to verify by inspection, and so a good correctness reference -// for Table. -type slowPrefixTable[T any] struct { - prefixes []slowPrefixEntry[T] -} - -type slowPrefixEntry[T any] struct { - pfx netip.Prefix - val T -} - -func (t *slowPrefixTable[T]) insert(pfx netip.Prefix, val T) { - pfx = pfx.Masked() - for i, ent := range t.prefixes { - if ent.pfx == pfx { - t.prefixes[i].val = val - return - } - } - t.prefixes = append(t.prefixes, slowPrefixEntry[T]{pfx, val}) -} - -func (t *slowPrefixTable[T]) get(addr netip.Addr) (ret T, ok bool) { - bestLen := -1 - - for _, pfx := range t.prefixes { - if pfx.pfx.Contains(addr) && pfx.pfx.Bits() > bestLen { - ret = pfx.val - bestLen = pfx.pfx.Bits() - } - } - return ret, bestLen != -1 -} - -// randomPrefixes returns n randomly generated prefixes and associated values, -// distributed equally between IPv4 and IPv6. -func randomPrefixes(n int) []slowPrefixEntry[int] { - pfxs := randomPrefixes4(n / 2) - pfxs = append(pfxs, randomPrefixes6(n-len(pfxs))...) - return pfxs -} - -// randomPrefixes4 returns n randomly generated IPv4 prefixes and associated values. -func randomPrefixes4(n int) []slowPrefixEntry[int] { - pfxs := map[netip.Prefix]bool{} - - for len(pfxs) < n { - len := rand.Intn(33) - pfx, err := randomAddr4().Prefix(len) - if err != nil { - panic(err) - } - pfxs[pfx] = true - } - - ret := make([]slowPrefixEntry[int], 0, len(pfxs)) - for pfx := range pfxs { - ret = append(ret, slowPrefixEntry[int]{pfx, rand.Int()}) - } - - return ret -} - -// randomPrefixes6 returns n randomly generated IPv4 prefixes and associated values. -func randomPrefixes6(n int) []slowPrefixEntry[int] { - pfxs := map[netip.Prefix]bool{} - - for len(pfxs) < n { - len := rand.Intn(129) - pfx, err := randomAddr6().Prefix(len) - if err != nil { - panic(err) - } - pfxs[pfx] = true - } - - ret := make([]slowPrefixEntry[int], 0, len(pfxs)) - for pfx := range pfxs { - ret = append(ret, slowPrefixEntry[int]{pfx, rand.Int()}) - } - - return ret -} - -// randomAddr returns a randomly generated IP address. -func randomAddr() netip.Addr { - if rand.Intn(2) == 1 { - return randomAddr6() - } else { - return randomAddr4() - } -} - -// randomAddr4 returns a randomly generated IPv4 address. -func randomAddr4() netip.Addr { - var b [4]byte - if _, err := crand.Read(b[:]); err != nil { - panic(err) - } - return netip.AddrFrom4(b) -} - -// randomAddr6 returns a randomly generated IPv6 address. -func randomAddr6() netip.Addr { - var b [16]byte - if _, err := crand.Read(b[:]); err != nil { - panic(err) - } - return netip.AddrFrom16(b) -} - -// roundFloat64 rounds f to 2 decimal places, for display. -// -// It round-trips through a float->string->float conversion, so should not be -// used in a performance critical setting. -func roundFloat64(f float64) float64 { - s := fmt.Sprintf("%.2f", f) - ret, err := strconv.ParseFloat(s, 64) - if err != nil { - panic(err) - } - return ret -} diff --git a/net/captivedetection/captivedetection.go b/net/captivedetection/captivedetection.go index 7d598d853349d..dcb12cacd62ae 100644 --- a/net/captivedetection/captivedetection.go +++ b/net/captivedetection/captivedetection.go @@ -16,9 +16,9 @@ import ( "syscall" "time" - "tailscale.com/net/netmon" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" ) // Detector checks whether the system is behind a captive portal. diff --git a/net/captivedetection/captivedetection_test.go b/net/captivedetection/captivedetection_test.go deleted file mode 100644 index 29a197d31f263..0000000000000 --- a/net/captivedetection/captivedetection_test.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package captivedetection - -import ( - "context" - "runtime" - "sync" - "sync/atomic" - "testing" - - "tailscale.com/net/netmon" - "tailscale.com/syncs" - "tailscale.com/tstest/nettest" -) - -func TestAvailableEndpointsAlwaysAtLeastTwo(t *testing.T) { - endpoints := availableEndpoints(nil, 0, t.Logf, runtime.GOOS) - if len(endpoints) == 0 { - t.Errorf("Expected non-empty AvailableEndpoints, got an empty slice instead") - } - if len(endpoints) == 1 { - t.Errorf("Expected at least two AvailableEndpoints for redundancy, got only one instead") - } - for _, e := range endpoints { - if e.URL.Scheme != "http" { - t.Errorf("Expected HTTP URL in Endpoint, got HTTPS") - } - } -} - -func TestDetectCaptivePortalReturnsFalse(t *testing.T) { - d := NewDetector(t.Logf) - found := d.Detect(context.Background(), netmon.NewStatic(), nil, 0) - if found { - t.Errorf("DetectCaptivePortal returned true, expected false.") - } -} - -func TestEndpointsAreUpAndReturnExpectedResponse(t *testing.T) { - nettest.SkipIfNoNetwork(t) - - d := NewDetector(t.Logf) - endpoints := availableEndpoints(nil, 0, t.Logf, runtime.GOOS) - t.Logf("testing %d endpoints", len(endpoints)) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - var good atomic.Bool - - var wg sync.WaitGroup - sem := syncs.NewSemaphore(5) - for _, e := range endpoints { - wg.Add(1) - go func(endpoint Endpoint) { - defer wg.Done() - - if !sem.AcquireContext(ctx) { - return - } - defer sem.Release() - - found, err := d.verifyCaptivePortalEndpoint(ctx, endpoint, 0) - if err != nil && ctx.Err() == nil { - t.Logf("verifyCaptivePortalEndpoint failed with endpoint %v: %v", endpoint, err) - } - if found { - t.Logf("verifyCaptivePortalEndpoint with endpoint %v says we're behind a captive portal, but we aren't", endpoint) - return - } - good.Store(true) - t.Logf("endpoint good: %v", endpoint) - cancel() - }(e) - } - - wg.Wait() - - if !good.Load() { - t.Errorf("no good endpoints found") - } -} diff --git a/net/captivedetection/endpoints.go b/net/captivedetection/endpoints.go index 450ed4a1cae4a..4bc19b2496ddc 100644 --- a/net/captivedetection/endpoints.go +++ b/net/captivedetection/endpoints.go @@ -11,10 +11,10 @@ import ( "net/url" "slices" + "github.com/sagernet/tailscale/net/dnsfallback" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" "go4.org/mem" - "tailscale.com/net/dnsfallback" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" ) // EndpointProvider is an enum that represents the source of an Endpoint. diff --git a/net/captivedetection/rawconn.go b/net/captivedetection/rawconn.go index a7197d9df2577..e81869469678b 100644 --- a/net/captivedetection/rawconn.go +++ b/net/captivedetection/rawconn.go @@ -8,7 +8,7 @@ package captivedetection import ( "syscall" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // setSocketInterfaceIndex sets the IP_BOUND_IF socket option on the given RawConn. diff --git a/net/captivedetection/rawconn_apple.go b/net/captivedetection/rawconn_apple.go index 12b4446e62eb8..d34a93d769705 100644 --- a/net/captivedetection/rawconn_apple.go +++ b/net/captivedetection/rawconn_apple.go @@ -8,8 +8,8 @@ package captivedetection import ( "syscall" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/sys/unix" - "tailscale.com/types/logger" ) // setSocketInterfaceIndex sets the IP_BOUND_IF socket option on the given RawConn. diff --git a/net/connstats/stats.go b/net/connstats/stats.go index 4e6d8e109aaad..df82670480295 100644 --- a/net/connstats/stats.go +++ b/net/connstats/stats.go @@ -11,10 +11,10 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/netlogtype" "golang.org/x/sync/errgroup" - "tailscale.com/net/packet" - "tailscale.com/net/tsaddr" - "tailscale.com/types/netlogtype" ) // Statistics maintains counters for every connection. diff --git a/net/connstats/stats_test.go b/net/connstats/stats_test.go deleted file mode 100644 index ae0bca8a5f008..0000000000000 --- a/net/connstats/stats_test.go +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package connstats - -import ( - "context" - "encoding/binary" - "fmt" - "math/rand" - "net/netip" - "runtime" - "sync" - "testing" - "time" - - qt "github.com/frankban/quicktest" - "tailscale.com/cmd/testwrapper/flakytest" - "tailscale.com/types/ipproto" - "tailscale.com/types/netlogtype" -) - -func testPacketV4(proto ipproto.Proto, srcAddr, dstAddr [4]byte, srcPort, dstPort, size uint16) (out []byte) { - var ipHdr [20]byte - ipHdr[0] = 4<<4 | 5 - binary.BigEndian.PutUint16(ipHdr[2:], size) - ipHdr[9] = byte(proto) - *(*[4]byte)(ipHdr[12:]) = srcAddr - *(*[4]byte)(ipHdr[16:]) = dstAddr - out = append(out, ipHdr[:]...) - switch proto { - case ipproto.TCP: - var tcpHdr [20]byte - binary.BigEndian.PutUint16(tcpHdr[0:], srcPort) - binary.BigEndian.PutUint16(tcpHdr[2:], dstPort) - out = append(out, tcpHdr[:]...) - case ipproto.UDP: - var udpHdr [8]byte - binary.BigEndian.PutUint16(udpHdr[0:], srcPort) - binary.BigEndian.PutUint16(udpHdr[2:], dstPort) - out = append(out, udpHdr[:]...) - default: - panic(fmt.Sprintf("unknown proto: %d", proto)) - } - return append(out, make([]byte, int(size)-len(out))...) -} - -// TestInterval ensures that we receive at least one call to `dump` using only -// maxPeriod. -func TestInterval(t *testing.T) { - c := qt.New(t) - - const maxPeriod = 10 * time.Millisecond - const maxConns = 2048 - - gotDump := make(chan struct{}, 1) - stats := NewStatistics(maxPeriod, maxConns, func(_, _ time.Time, _, _ map[netlogtype.Connection]netlogtype.Counts) { - select { - case gotDump <- struct{}{}: - default: - } - }) - defer stats.Shutdown(context.Background()) - - srcAddr := netip.AddrFrom4([4]byte{192, 168, 0, byte(rand.Intn(16))}) - dstAddr := netip.AddrFrom4([4]byte{192, 168, 0, byte(rand.Intn(16))}) - srcPort := uint16(rand.Intn(16)) - dstPort := uint16(rand.Intn(16)) - size := uint16(64 + rand.Intn(1024)) - p := testPacketV4(ipproto.TCP, srcAddr.As4(), dstAddr.As4(), srcPort, dstPort, size) - stats.UpdateRxVirtual(p) - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - select { - case <-ctx.Done(): - c.Fatal("didn't receive dump within context deadline") - case <-gotDump: - } -} - -func TestConcurrent(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/7030") - c := qt.New(t) - - const maxPeriod = 10 * time.Millisecond - const maxConns = 10 - virtualAggregate := make(map[netlogtype.Connection]netlogtype.Counts) - stats := NewStatistics(maxPeriod, maxConns, func(start, end time.Time, virtual, physical map[netlogtype.Connection]netlogtype.Counts) { - c.Assert(start.IsZero(), qt.IsFalse) - c.Assert(end.IsZero(), qt.IsFalse) - c.Assert(end.Before(start), qt.IsFalse) - c.Assert(len(virtual) > 0 && len(virtual) <= maxConns, qt.IsTrue) - c.Assert(len(physical) == 0, qt.IsTrue) - for conn, cnts := range virtual { - virtualAggregate[conn] = virtualAggregate[conn].Add(cnts) - } - }) - defer stats.Shutdown(context.Background()) - var wants []map[netlogtype.Connection]netlogtype.Counts - gots := make([]map[netlogtype.Connection]netlogtype.Counts, runtime.NumCPU()) - var group sync.WaitGroup - for i := range gots { - group.Add(1) - go func(i int) { - defer group.Done() - gots[i] = make(map[netlogtype.Connection]netlogtype.Counts) - rn := rand.New(rand.NewSource(time.Now().UnixNano())) - var p []byte - var t netlogtype.Connection - for j := 0; j < 1000; j++ { - delay := rn.Intn(10000) - if p == nil || rn.Intn(64) == 0 { - proto := ipproto.TCP - if rn.Intn(2) == 0 { - proto = ipproto.UDP - } - srcAddr := netip.AddrFrom4([4]byte{192, 168, 0, byte(rand.Intn(16))}) - dstAddr := netip.AddrFrom4([4]byte{192, 168, 0, byte(rand.Intn(16))}) - srcPort := uint16(rand.Intn(16)) - dstPort := uint16(rand.Intn(16)) - size := uint16(64 + rand.Intn(1024)) - p = testPacketV4(proto, srcAddr.As4(), dstAddr.As4(), srcPort, dstPort, size) - t = netlogtype.Connection{Proto: proto, Src: netip.AddrPortFrom(srcAddr, srcPort), Dst: netip.AddrPortFrom(dstAddr, dstPort)} - } - t2 := t - receive := rn.Intn(2) == 0 - if receive { - t2.Src, t2.Dst = t2.Dst, t2.Src - } - - cnts := gots[i][t2] - if receive { - stats.UpdateRxVirtual(p) - cnts.RxPackets++ - cnts.RxBytes += uint64(len(p)) - } else { - cnts.TxPackets++ - cnts.TxBytes += uint64(len(p)) - stats.UpdateTxVirtual(p) - } - gots[i][t2] = cnts - time.Sleep(time.Duration(rn.Intn(1 + delay))) - } - }(i) - } - group.Wait() - c.Assert(stats.Shutdown(context.Background()), qt.IsNil) - wants = append(wants, virtualAggregate) - - got := make(map[netlogtype.Connection]netlogtype.Counts) - want := make(map[netlogtype.Connection]netlogtype.Counts) - mergeMaps(got, gots...) - mergeMaps(want, wants...) - c.Assert(got, qt.DeepEquals, want) -} - -func mergeMaps(dst map[netlogtype.Connection]netlogtype.Counts, srcs ...map[netlogtype.Connection]netlogtype.Counts) { - for _, src := range srcs { - for conn, cnts := range src { - dst[conn] = dst[conn].Add(cnts) - } - } -} - -func Benchmark(b *testing.B) { - // TODO: Test IPv6 packets? - b.Run("SingleRoutine/SameConn", func(b *testing.B) { - p := testPacketV4(ipproto.UDP, [4]byte{192, 168, 0, 1}, [4]byte{192, 168, 0, 2}, 123, 456, 789) - b.ResetTimer() - b.ReportAllocs() - for range b.N { - s := NewStatistics(0, 0, nil) - for j := 0; j < 1e3; j++ { - s.UpdateTxVirtual(p) - } - } - }) - b.Run("SingleRoutine/UniqueConns", func(b *testing.B) { - p := testPacketV4(ipproto.UDP, [4]byte{}, [4]byte{}, 0, 0, 789) - b.ResetTimer() - b.ReportAllocs() - for range b.N { - s := NewStatistics(0, 0, nil) - for j := 0; j < 1e3; j++ { - binary.BigEndian.PutUint32(p[20:], uint32(j)) // unique port combination - s.UpdateTxVirtual(p) - } - } - }) - b.Run("MultiRoutine/SameConn", func(b *testing.B) { - p := testPacketV4(ipproto.UDP, [4]byte{192, 168, 0, 1}, [4]byte{192, 168, 0, 2}, 123, 456, 789) - b.ResetTimer() - b.ReportAllocs() - for range b.N { - s := NewStatistics(0, 0, nil) - var group sync.WaitGroup - for j := 0; j < runtime.NumCPU(); j++ { - group.Add(1) - go func() { - defer group.Done() - for k := 0; k < 1e3; k++ { - s.UpdateTxVirtual(p) - } - }() - } - group.Wait() - } - }) - b.Run("MultiRoutine/UniqueConns", func(b *testing.B) { - ps := make([][]byte, runtime.NumCPU()) - for i := range ps { - ps[i] = testPacketV4(ipproto.UDP, [4]byte{192, 168, 0, 1}, [4]byte{192, 168, 0, 2}, 0, 0, 789) - } - b.ResetTimer() - b.ReportAllocs() - for range b.N { - s := NewStatistics(0, 0, nil) - var group sync.WaitGroup - for j := 0; j < runtime.NumCPU(); j++ { - group.Add(1) - go func(j int) { - defer group.Done() - p := ps[j] - j *= 1e3 - for k := 0; k < 1e3; k++ { - binary.BigEndian.PutUint32(p[20:], uint32(j+k)) // unique port combination - s.UpdateTxVirtual(p) - } - }(j) - } - group.Wait() - } - }) -} diff --git a/net/dns/config.go b/net/dns/config.go index 67d3d753c9a8d..bae521acd1221 100644 --- a/net/dns/config.go +++ b/net/dns/config.go @@ -10,11 +10,11 @@ import ( "net/netip" "sort" - "tailscale.com/net/dns/publicdns" - "tailscale.com/net/dns/resolver" - "tailscale.com/net/tsaddr" - "tailscale.com/types/dnstype" - "tailscale.com/util/dnsname" + "github.com/sagernet/tailscale/net/dns/publicdns" + "github.com/sagernet/tailscale/net/dns/resolver" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/util/dnsname" ) // Config is a DNS configuration. diff --git a/net/dns/debian_resolvconf.go b/net/dns/debian_resolvconf.go index 3ffc796e06d1b..59f27d08cfadb 100644 --- a/net/dns/debian_resolvconf.go +++ b/net/dns/debian_resolvconf.go @@ -14,8 +14,8 @@ import ( "os/exec" "path/filepath" - "tailscale.com/atomicfile" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/types/logger" ) //go:embed resolvconf-workaround.sh diff --git a/net/dns/direct.go b/net/dns/direct.go index aaff18fcb7848..3a9288a8c16ef 100644 --- a/net/dns/direct.go +++ b/net/dns/direct.go @@ -21,12 +21,12 @@ import ( "sync" "time" - "tailscale.com/health" - "tailscale.com/net/dns/resolvconffile" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" - "tailscale.com/util/dnsname" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dns/resolvconffile" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/version/distro" ) // writeResolvConf writes DNS configuration in resolv.conf format to the given writer. diff --git a/net/dns/direct_linux.go b/net/dns/direct_linux.go index bdeefb352498b..18b007fb59f31 100644 --- a/net/dns/direct_linux.go +++ b/net/dns/direct_linux.go @@ -8,7 +8,7 @@ import ( "context" "github.com/illarion/gonotify/v2" - "tailscale.com/health" + "github.com/sagernet/tailscale/health" ) func (m *directManager) runFileWatcher() { diff --git a/net/dns/direct_test.go b/net/dns/direct_test.go deleted file mode 100644 index 07202502e231e..0000000000000 --- a/net/dns/direct_test.go +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dns - -import ( - "context" - "errors" - "fmt" - "io/fs" - "net/netip" - "os" - "path/filepath" - "strings" - "syscall" - "testing" - - qt "github.com/frankban/quicktest" - "tailscale.com/util/dnsname" -) - -func TestDirectManager(t *testing.T) { - tmp := t.TempDir() - if err := os.MkdirAll(filepath.Join(tmp, "etc"), 0700); err != nil { - t.Fatal(err) - } - testDirect(t, directFS{prefix: tmp}) -} - -type boundResolvConfFS struct { - directFS -} - -func (fs boundResolvConfFS) Rename(old, new string) error { - if old == "/etc/resolv.conf" || new == "/etc/resolv.conf" { - return errors.New("cannot move to/from /etc/resolv.conf") - } - return fs.directFS.Rename(old, new) -} - -func (fs boundResolvConfFS) Remove(name string) error { - if name == "/etc/resolv.conf" { - return errors.New("cannot remove /etc/resolv.conf") - } - return fs.directFS.Remove(name) -} - -func TestDirectBrokenRename(t *testing.T) { - tmp := t.TempDir() - if err := os.MkdirAll(filepath.Join(tmp, "etc"), 0700); err != nil { - t.Fatal(err) - } - testDirect(t, boundResolvConfFS{directFS{prefix: tmp}}) -} - -func testDirect(t *testing.T, fs wholeFileFS) { - const orig = "nameserver 9.9.9.9 # orig" - resolvPath := "/etc/resolv.conf" - backupPath := "/etc/resolv.pre-tailscale-backup.conf" - - if err := fs.WriteFile(resolvPath, []byte(orig), 0644); err != nil { - t.Fatal(err) - } - - readFile := func(t *testing.T, path string) string { - t.Helper() - b, err := fs.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return string(b) - } - assertBaseState := func(t *testing.T) { - if got := readFile(t, resolvPath); got != orig { - t.Fatalf("resolv.conf:\n%s, want:\n%s", got, orig) - } - if _, err := fs.Stat(backupPath); !os.IsNotExist(err) { - t.Fatalf("resolv.conf backup: want it to be gone but: %v", err) - } - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - m := directManager{logf: t.Logf, fs: fs, ctx: ctx, ctxClose: cancel} - if err := m.SetDNS(OSConfig{ - Nameservers: []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("8.8.4.4")}, - SearchDomains: []dnsname.FQDN{"ts.net.", "ts-dns.test."}, - MatchDomains: []dnsname.FQDN{"ignored."}, - }); err != nil { - t.Fatal(err) - } - want := `# resolv.conf(5) file generated by tailscale -# For more info, see https://tailscale.com/s/resolvconf-overwrite -# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN - -nameserver 8.8.8.8 -nameserver 8.8.4.4 -search ts.net ts-dns.test -` - if got := readFile(t, resolvPath); got != want { - t.Fatalf("resolv.conf:\n%s, want:\n%s", got, want) - } - if got := readFile(t, backupPath); got != orig { - t.Fatalf("resolv.conf backup:\n%s, want:\n%s", got, orig) - } - - // Test that a nil OSConfig cleans up resolv.conf. - if err := m.SetDNS(OSConfig{}); err != nil { - t.Fatal(err) - } - assertBaseState(t) - - // Test that Close cleans up resolv.conf. - if err := m.SetDNS(OSConfig{Nameservers: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}); err != nil { - t.Fatal(err) - } - if err := m.Close(); err != nil { - t.Fatal(err) - } - assertBaseState(t) -} - -type brokenRemoveFS struct { - directFS -} - -func (b brokenRemoveFS) Rename(old, new string) error { - return errors.New("nyaaah I'm a silly container!") -} - -func (b brokenRemoveFS) Remove(name string) error { - if strings.Contains(name, "/etc/resolv.conf") { - return fmt.Errorf("Faking remove failure: %q", &fs.PathError{Err: syscall.EBUSY}) - } - return b.directFS.Remove(name) -} - -func TestDirectBrokenRemove(t *testing.T) { - tmp := t.TempDir() - if err := os.MkdirAll(filepath.Join(tmp, "etc"), 0700); err != nil { - t.Fatal(err) - } - testDirect(t, brokenRemoveFS{directFS{prefix: tmp}}) -} - -func TestReadResolve(t *testing.T) { - c := qt.New(t) - tests := []struct { - in string - want OSConfig - wantErr bool - }{ - {in: `nameserver 192.168.0.100`, - want: OSConfig{ - Nameservers: []netip.Addr{ - netip.MustParseAddr("192.168.0.100"), - }, - }, - }, - {in: `nameserver 192.168.0.100 # comment`, - want: OSConfig{ - Nameservers: []netip.Addr{ - netip.MustParseAddr("192.168.0.100"), - }, - }, - }, - {in: `nameserver 192.168.0.100#`, - want: OSConfig{ - Nameservers: []netip.Addr{ - netip.MustParseAddr("192.168.0.100"), - }, - }, - }, - {in: `nameserver #192.168.0.100`, wantErr: true}, - {in: `nameserver`, wantErr: true}, - {in: `# nameserver 192.168.0.100`, want: OSConfig{}}, - {in: `nameserver192.168.0.100`, wantErr: true}, - - {in: `search tailscale.com`, - want: OSConfig{ - SearchDomains: []dnsname.FQDN{"tailscale.com."}, - }, - }, - {in: `search tailscale.com # comment`, - want: OSConfig{ - SearchDomains: []dnsname.FQDN{"tailscale.com."}, - }, - }, - {in: `searchtailscale.com`, wantErr: true}, - {in: `search`, wantErr: true}, - } - - for _, test := range tests { - cfg, err := readResolv(strings.NewReader(test.in)) - if test.wantErr { - c.Assert(err, qt.IsNotNil) - } else { - c.Assert(err, qt.IsNil) - } - c.Assert(cfg, qt.DeepEquals, test.want) - } -} diff --git a/net/dns/direct_unix_test.go b/net/dns/direct_unix_test.go deleted file mode 100644 index bffa6ade943c8..0000000000000 --- a/net/dns/direct_unix_test.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build unix - -package dns - -import ( - "context" - "os" - "path/filepath" - "syscall" - "testing" -) - -func TestWriteFileUmask(t *testing.T) { - // Set a umask that disallows world-readable files for the duration of - // this test. - oldUmask := syscall.Umask(0027) - defer syscall.Umask(oldUmask) - - tmp := t.TempDir() - fs := directFS{prefix: tmp} - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - m := directManager{logf: t.Logf, fs: fs, ctx: ctx, ctxClose: cancel} - - const perms = 0644 - if err := m.atomicWriteFile(fs, "resolv.conf", []byte("nameserver 8.8.8.8\n"), perms); err != nil { - t.Fatal(err) - } - - // Ensure that the created file has the world-readable bit set. - fi, err := os.Stat(filepath.Join(tmp, "resolv.conf")) - if err != nil { - t.Fatal(err) - } - if got := fi.Mode().Perm(); got != perms { - t.Fatalf("file mode: got 0o%o, want 0o%o", got, perms) - } -} diff --git a/net/dns/ini_test.go b/net/dns/ini_test.go deleted file mode 100644 index 3afe7009caa27..0000000000000 --- a/net/dns/ini_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build windows - -package dns - -import ( - "reflect" - "testing" -) - -func TestParseIni(t *testing.T) { - var tests = []struct { - src string - want map[string]map[string]string - }{ - { - src: `# appended wsl.conf file -[automount] - enabled = true - root=/mnt/ -# added by tailscale -[network] # trailing comment -generateResolvConf = false # trailing comment`, - want: map[string]map[string]string{ - "automount": {"enabled": "true", "root": "/mnt/"}, - "network": {"generateResolvConf": "false"}, - }, - }, - } - for _, test := range tests { - got := parseIni(test.src) - if !reflect.DeepEqual(got, test.want) { - t.Errorf("for:\n%s\ngot: %v\nwant: %v", test.src, got, test.want) - } - } -} diff --git a/net/dns/manager.go b/net/dns/manager.go index 13cb2d84e1930..c47aef90f7907 100644 --- a/net/dns/manager.go +++ b/net/dns/manager.go @@ -19,18 +19,18 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dns/resolver" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tstime/rate" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/dnsname" xmaps "golang.org/x/exp/maps" - "tailscale.com/control/controlknobs" - "tailscale.com/health" - "tailscale.com/net/dns/resolver" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/syncs" - "tailscale.com/tstime/rate" - "tailscale.com/types/dnstype" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/dnsname" ) var ( diff --git a/net/dns/manager_darwin.go b/net/dns/manager_darwin.go index ccfafaa457f16..5c3666ce53032 100644 --- a/net/dns/manager_darwin.go +++ b/net/dns/manager_darwin.go @@ -7,13 +7,13 @@ import ( "bytes" "os" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dns/resolvconffile" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" "go4.org/mem" - "tailscale.com/control/controlknobs" - "tailscale.com/health" - "tailscale.com/net/dns/resolvconffile" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" - "tailscale.com/util/mak" ) // NewOSConfigurator creates a new OS configurator. diff --git a/net/dns/manager_default.go b/net/dns/manager_default.go index 11dea5ca888b1..fbaabc31b0b3a 100644 --- a/net/dns/manager_default.go +++ b/net/dns/manager_default.go @@ -6,9 +6,9 @@ package dns import ( - "tailscale.com/control/controlknobs" - "tailscale.com/health" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/types/logger" ) // NewOSConfigurator creates a new OS configurator. diff --git a/net/dns/manager_freebsd.go b/net/dns/manager_freebsd.go index 1ec9ea841d77a..9614221eafdd2 100644 --- a/net/dns/manager_freebsd.go +++ b/net/dns/manager_freebsd.go @@ -7,9 +7,9 @@ import ( "fmt" "os" - "tailscale.com/control/controlknobs" - "tailscale.com/health" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/types/logger" ) // NewOSConfigurator creates a new OS configurator. diff --git a/net/dns/manager_linux.go b/net/dns/manager_linux.go index 3ba3022b62757..e7d921e9e55bb 100644 --- a/net/dns/manager_linux.go +++ b/net/dns/manager_linux.go @@ -14,12 +14,12 @@ import ( "time" "github.com/godbus/dbus/v5" - "tailscale.com/control/controlknobs" - "tailscale.com/health" - "tailscale.com/net/netaddr" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/cmpver" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/cmpver" ) type kv struct { diff --git a/net/dns/manager_linux_test.go b/net/dns/manager_linux_test.go deleted file mode 100644 index 605344c062de9..0000000000000 --- a/net/dns/manager_linux_test.go +++ /dev/null @@ -1,487 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dns - -import ( - "errors" - "io/fs" - "os" - "strings" - "testing" - - "tailscale.com/tstest" - "tailscale.com/util/cmpver" -) - -func TestLinuxDNSMode(t *testing.T) { - tests := []struct { - name string - env newOSConfigEnv - wantLog string - want string - }{ - { - name: "no_obvious_resolv.conf_owner", - env: env(resolvDotConf("nameserver 10.0.0.1")), - wantLog: "dns: [rc=unknown ret=direct]", - want: "direct", - }, - { - name: "network_manager", - env: env( - resolvDotConf( - "# Managed by NetworkManager", - "nameserver 10.0.0.1")), - wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [10.0.0.1]\n" + - "dns: [rc=nm resolved=not-in-use ret=direct]", - want: "direct", - }, - { - name: "resolvconf_but_no_resolvconf_binary", - env: env(resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1")), - wantLog: "dns: [rc=resolvconf resolvconf=no ret=direct]", - want: "direct", - }, - { - name: "debian_resolvconf", - env: env( - resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1"), - resolvconf("debian")), - wantLog: "dns: [rc=resolvconf resolvconf=debian ret=debian-resolvconf]", - want: "debian-resolvconf", - }, - { - name: "openresolv", - env: env( - resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1"), - resolvconf("openresolv")), - wantLog: "dns: [rc=resolvconf resolvconf=openresolv ret=openresolv]", - want: "openresolv", - }, - { - name: "unknown_resolvconf_flavor", - env: env( - resolvDotConf("# Managed by resolvconf", "nameserver 10.0.0.1"), - resolvconf("daves-discount-resolvconf")), - wantLog: "[unexpected] got unknown flavor of resolvconf \"daves-discount-resolvconf\", falling back to direct manager\ndns: [rc=resolvconf resolvconf=daves-discount-resolvconf ret=direct]", - want: "direct", - }, - { - name: "resolved_alone_without_ping", - env: env(resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53")), - wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved resolved=file nm=no resolv-conf-mode=error ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - name: "resolved_alone_with_ping", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), - resolvedRunning()), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - name: "resolved_and_nsswitch_resolve", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 1.1.1.1"), - resolvedRunning(), - nsswitchDotConf("hosts: files resolve [!UNAVAIL=return] dns"), - ), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=nss nm=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - name: "resolved_and_nsswitch_dns", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 1.1.1.1"), - resolvedRunning(), - nsswitchDotConf("hosts: files dns resolve [!UNAVAIL=return]"), - ), - wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [1.1.1.1]\ndns: [resolved-ping=yes rc=resolved resolved=not-in-use ret=direct]", - want: "direct", - }, - { - name: "resolved_and_nsswitch_none", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 1.1.1.1"), - resolvedRunning(), - nsswitchDotConf("hosts:"), - ), - wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [1.1.1.1]\ndns: [resolved-ping=yes rc=resolved resolved=not-in-use ret=direct]", - want: "direct", - }, - { - name: "resolved_and_networkmanager_not_using_resolved", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), - resolvedRunning(), - nmRunning("1.2.3", false)), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - name: "resolved_and_mid_2020_networkmanager", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), - resolvedRunning(), - nmRunning("1.26.2", true)), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=yes nm-safe=yes ret=network-manager]", - want: "network-manager", - }, - { - name: "resolved_and_2021_networkmanager", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), - resolvedRunning(), - nmRunning("1.27.0", true)), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - name: "resolved_and_ancient_networkmanager", - env: env( - resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), - resolvedRunning(), - nmRunning("1.22.0", true)), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=yes nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - // Regression tests for extreme corner cases below. - { - // One user reported a configuration whose comment string - // alleged that it was managed by systemd-resolved, but it - // was actually a completely static config file pointing - // elsewhere. - name: "allegedly_resolved_but_not_in_resolv.conf", - env: env(resolvDotConf("# Managed by systemd-resolved", "nameserver 10.0.0.1")), - wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [10.0.0.1]\n" + - "dns: [rc=resolved resolved=not-in-use ret=direct]", - want: "direct", - }, - { - // We used to incorrectly decide that resolved wasn't in - // charge when handed this (admittedly weird and bugged) - // resolv.conf. - name: "resolved_with_duplicates_in_resolv.conf", - env: env( - resolvDotConf( - "# Managed by systemd-resolved", - "nameserver 127.0.0.53", - "nameserver 127.0.0.53"), - resolvedRunning()), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // More than one user has had resolvconf write a config that points to - // systemd-resolved. We're better off using systemd-resolved. - // regression test for https://github.com/tailscale/tailscale/issues/3026 - name: "allegedly_resolvconf_but_actually_systemd-resolved", - env: env(resolvDotConf( - "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by resolvconf(8)", - "# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN", - "# 127.0.0.53 is the systemd-resolved stub resolver.", - "# run \"systemd-resolve --status\" to see details about the actual nameservers.", - "nameserver 127.0.0.53"), - resolvedRunning()), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // More than one user has had resolvconf write a config that points to - // systemd-resolved. We're better off using systemd-resolved. - // and assuming that even if the ping doesn't show that env is correct - // regression test for https://github.com/tailscale/tailscale/issues/3026 - name: "allegedly_resolvconf_but_actually_systemd-resolved_but_no_ping", - env: env(resolvDotConf( - "# Dynamic resolv.conf(5) file for glibc resolver(3) generated by resolvconf(8)", - "# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN", - "# 127.0.0.53 is the systemd-resolved stub resolver.", - "# run \"systemd-resolve --status\" to see details about the actual nameservers.", - "nameserver 127.0.0.53")), - wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=resolved resolved=file nm=no resolv-conf-mode=error ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // regression test for https://github.com/tailscale/tailscale/issues/3304 - name: "networkmanager_but_pointing_at_systemd-resolved", - env: env(resolvDotConf( - "# Generated by NetworkManager", - "nameserver 127.0.0.53", - "options edns0 trust-ad"), - resolvedRunning(), - nmRunning("1.32.12", true)), - wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm-safe=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // regression test for https://github.com/tailscale/tailscale/issues/3304 - name: "networkmanager_but_pointing_at_systemd-resolved_but_no_resolved_ping", - env: env(resolvDotConf( - "# Generated by NetworkManager", - "nameserver 127.0.0.53", - "options edns0 trust-ad"), - nmRunning("1.32.12", true)), - wantLog: "dns: ResolvConfMode error: dbus property not found\ndns: [rc=nm resolved=file nm-resolved=yes nm-safe=no resolv-conf-mode=error ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // regression test for https://github.com/tailscale/tailscale/issues/3304 - name: "networkmanager_but_pointing_at_systemd-resolved_and_safe_nm", - env: env(resolvDotConf( - "# Generated by NetworkManager", - "nameserver 127.0.0.53", - "options edns0 trust-ad"), - resolvedRunning(), - nmRunning("1.26.3", true)), - wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm-safe=yes ret=network-manager]", - want: "network-manager", - }, - { - // regression test for https://github.com/tailscale/tailscale/issues/3304 - name: "networkmanager_but_pointing_at_systemd-resolved_and_no_networkmanager", - env: env(resolvDotConf( - "# Generated by NetworkManager", - "nameserver 127.0.0.53", - "options edns0 trust-ad"), - resolvedRunning()), - wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // regression test for https://github.com/tailscale/tailscale/issues/3531 - name: "networkmanager_but_systemd-resolved_with_search_domain", - env: env(resolvDotConf( - "# Generated by NetworkManager", - "search lan", - "nameserver 127.0.0.53"), - resolvedRunning()), - wantLog: "dns: [resolved-ping=yes rc=nm resolved=file nm-resolved=yes nm=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // Make sure that we ping systemd-resolved to let it start up and write its resolv.conf - // before we read its file. - env: env(resolvedStartOnPingAndThen( - resolvDotConf("# Managed by systemd-resolved", "nameserver 127.0.0.53"), - resolvedDbusProperty(), - )), - wantLog: "dns: [resolved-ping=yes rc=resolved resolved=file nm=no resolv-conf-mode=fortests ret=systemd-resolved]", - want: "systemd-resolved", - }, - { - // regression test for https://github.com/tailscale/tailscale/issues/9687 - name: "networkmanager_endeavouros", - env: env(resolvDotConf( - "# Generated by NetworkManager", - "search example.com localdomain", - "nameserver 10.0.0.1"), - nmRunning("1.44.2", false)), - wantLog: "dns: resolvedIsActuallyResolver error: resolv.conf doesn't point to systemd-resolved; points to [10.0.0.1]\n" + - "dns: [rc=nm resolved=not-in-use ret=direct]", - want: "direct", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var logBuf tstest.MemLogger - got, err := dnsMode(logBuf.Logf, nil, tt.env) - if err != nil { - t.Fatal(err) - } - if got != tt.want { - t.Errorf("got %s; want %s", got, tt.want) - } - if got := strings.TrimSpace(logBuf.String()); got != tt.wantLog { - t.Errorf("log output mismatch:\n got: %q\nwant: %q\n", got, tt.wantLog) - } - }) - } -} - -type memFS map[string]any // full path => string for regular files - -func (m memFS) Stat(name string) (isRegular bool, err error) { - v, ok := m[name] - if !ok { - return false, fs.ErrNotExist - } - if _, ok := v.(string); ok { - return true, nil - } - return false, nil -} - -func (m memFS) Chmod(name string, mode os.FileMode) error { panic("TODO") } -func (m memFS) Rename(oldName, newName string) error { panic("TODO") } -func (m memFS) Remove(name string) error { panic("TODO") } -func (m memFS) ReadFile(name string) ([]byte, error) { - v, ok := m[name] - if !ok { - return nil, fs.ErrNotExist - } - if s, ok := v.(string); ok { - return []byte(s), nil - } - panic("TODO") -} - -func (m memFS) Truncate(name string) error { - v, ok := m[name] - if !ok { - return fs.ErrNotExist - } - if s, ok := v.(string); ok { - m[name] = s[:0] - } - - return nil -} - -func (m memFS) WriteFile(name string, contents []byte, perm os.FileMode) error { - m[name] = string(contents) - return nil -} - -type dbusService struct { - name, path string - hook func() // if non-nil, run on ping -} - -type dbusProperty struct { - name, path string - iface, member string - hook func() (string, error) // what to return -} - -type envBuilder struct { - fs memFS - dbus []dbusService - dbusProperties []dbusProperty - nmUsingResolved bool - nmVersion string - resolvconfStyle string -} - -type envOption interface { - apply(*envBuilder) -} - -type envOpt func(*envBuilder) - -func (e envOpt) apply(b *envBuilder) { - e(b) -} - -func env(opts ...envOption) newOSConfigEnv { - b := &envBuilder{ - fs: memFS{}, - } - for _, opt := range opts { - opt.apply(b) - } - - return newOSConfigEnv{ - fs: b.fs, - dbusPing: func(name, path string) error { - for _, svc := range b.dbus { - if svc.name == name && svc.path == path { - if svc.hook != nil { - svc.hook() - } - return nil - } - } - return errors.New("dbus service not found") - }, - dbusReadString: func(name, path, iface, member string) (string, error) { - for _, svc := range b.dbusProperties { - if svc.name == name && svc.path == path && svc.iface == iface && svc.member == member { - return svc.hook() - } - } - return "", errors.New("dbus property not found") - }, - nmIsUsingResolved: func() error { - if !b.nmUsingResolved { - return errors.New("networkmanager not using resolved") - } - return nil - }, - nmVersionBetween: func(first, last string) (bool, error) { - outside := cmpver.Compare(b.nmVersion, first) < 0 || cmpver.Compare(b.nmVersion, last) > 0 - return !outside, nil - }, - resolvconfStyle: func() string { return b.resolvconfStyle }, - } -} - -func resolvDotConf(ss ...string) envOption { - return envOpt(func(b *envBuilder) { - b.fs["/etc/resolv.conf"] = strings.Join(ss, "\n") - }) -} - -func nsswitchDotConf(ss ...string) envOption { - return envOpt(func(b *envBuilder) { - b.fs["/etc/nsswitch.conf"] = strings.Join(ss, "\n") - }) -} - -// resolvedRunning returns an option that makes resolved reply to a dbusPing -// and the ResolvConfMode property. -func resolvedRunning() envOption { - return resolvedStartOnPingAndThen(resolvedDbusProperty()) -} - -// resolvedDbusProperty returns an option that responds to the ResolvConfMode -// property that resolved exposes. -func resolvedDbusProperty() envOption { - return setDbusProperty("org.freedesktop.resolve1", "/org/freedesktop/resolve1", "org.freedesktop.resolve1.Manager", "ResolvConfMode", "fortests") -} - -// resolvedStartOnPingAndThen returns an option that makes resolved be -// active but not yet running. On a dbus ping, it then applies the -// provided options. -func resolvedStartOnPingAndThen(opts ...envOption) envOption { - return envOpt(func(b *envBuilder) { - b.dbus = append(b.dbus, dbusService{ - name: "org.freedesktop.resolve1", - path: "/org/freedesktop/resolve1", - hook: func() { - for _, opt := range opts { - opt.apply(b) - } - }, - }) - }) -} - -func nmRunning(version string, usingResolved bool) envOption { - return envOpt(func(b *envBuilder) { - b.nmUsingResolved = usingResolved - b.nmVersion = version - b.dbus = append(b.dbus, dbusService{name: "org.freedesktop.NetworkManager", path: "/org/freedesktop/NetworkManager/DnsManager"}) - }) -} - -func resolvconf(s string) envOption { - return envOpt(func(b *envBuilder) { - b.resolvconfStyle = s - }) -} - -func setDbusProperty(name, path, iface, member, value string) envOption { - return envOpt(func(b *envBuilder) { - b.dbusProperties = append(b.dbusProperties, dbusProperty{ - name: name, - path: path, - iface: iface, - member: member, - hook: func() (string, error) { - return value, nil - }, - }) - }) -} diff --git a/net/dns/manager_openbsd.go b/net/dns/manager_openbsd.go index 1a1c4390c943f..e3bbdc7a2efe4 100644 --- a/net/dns/manager_openbsd.go +++ b/net/dns/manager_openbsd.go @@ -8,9 +8,9 @@ import ( "fmt" "os" - "tailscale.com/control/controlknobs" - "tailscale.com/health" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/types/logger" ) type kv struct { diff --git a/net/dns/manager_tcp_test.go b/net/dns/manager_tcp_test.go deleted file mode 100644 index f4c42791e9b5b..0000000000000 --- a/net/dns/manager_tcp_test.go +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dns - -import ( - "bytes" - "encoding/binary" - "errors" - "io" - "net" - "net/netip" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - dns "golang.org/x/net/dns/dnsmessage" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/tstest" - "tailscale.com/util/dnsname" -) - -func mkDNSRequest(domain dnsname.FQDN, tp dns.Type, modify func(*dns.Builder)) []byte { - var dnsHeader dns.Header - question := dns.Question{ - Name: dns.MustNewName(domain.WithTrailingDot()), - Type: tp, - Class: dns.ClassINET, - } - - builder := dns.NewBuilder(nil, dnsHeader) - if err := builder.StartQuestions(); err != nil { - panic(err) - } - if err := builder.Question(question); err != nil { - panic(err) - } - - if err := builder.StartAdditionals(); err != nil { - panic(err) - } - - if modify != nil { - modify(&builder) - } - payload, _ := builder.Finish() - - return payload -} - -func addEDNS(builder *dns.Builder) { - ednsHeader := dns.ResourceHeader{ - Name: dns.MustNewName("."), - Type: dns.TypeOPT, - Class: dns.Class(4095), - } - - if err := builder.OPTResource(ednsHeader, dns.OPTResource{}); err != nil { - panic(err) - } -} - -func mkLargeDNSRequest(domain dnsname.FQDN, tp dns.Type) []byte { - return mkDNSRequest(domain, tp, func(builder *dns.Builder) { - ednsHeader := dns.ResourceHeader{ - Name: dns.MustNewName("."), - Type: dns.TypeOPT, - Class: dns.Class(4095), - } - - if err := builder.OPTResource(ednsHeader, dns.OPTResource{ - Options: []dns.Option{{ - Code: 1234, - Data: bytes.Repeat([]byte("A"), maxReqSizeTCP), - }}, - }); err != nil { - panic(err) - } - }) -} - -func TestDNSOverTCP(t *testing.T) { - f := fakeOSConfigurator{ - SplitDNS: true, - BaseConfig: OSConfig{ - Nameservers: mustIPs("8.8.8.8"), - SearchDomains: fqdns("coffee.shop"), - }, - } - m := NewManager(t.Logf, &f, new(health.Tracker), tsdial.NewDialer(netmon.NewStatic()), nil, nil, "") - m.resolver.TestOnlySetHook(f.SetResolver) - m.Set(Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - Routes: upstreams("ts.com", ""), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }) - defer m.Down() - - c, s := net.Pipe() - defer s.Close() - go m.HandleTCPConn(s, netip.AddrPort{}) - defer c.Close() - - wantResults := map[dnsname.FQDN]string{ - "dave.ts.com.": "1.2.3.4", - "bradfitz.ts.com.": "2.3.4.5", - } - - for domain := range wantResults { - b := mkDNSRequest(domain, dns.TypeA, addEDNS) - binary.Write(c, binary.BigEndian, uint16(len(b))) - c.Write(b) - } - - results := map[dnsname.FQDN]string{} - for range len(wantResults) { - var respLength uint16 - if err := binary.Read(c, binary.BigEndian, &respLength); err != nil { - t.Fatalf("reading len: %v", err) - } - resp := make([]byte, int(respLength)) - if _, err := io.ReadFull(c, resp); err != nil { - t.Fatalf("reading data: %v", err) - } - - var parser dns.Parser - if _, err := parser.Start(resp); err != nil { - t.Errorf("parser.Start() failed: %v", err) - continue - } - q, err := parser.Question() - if err != nil { - t.Errorf("parser.Question(): %v", err) - continue - } - if err := parser.SkipAllQuestions(); err != nil { - t.Errorf("parser.SkipAllQuestions(): %v", err) - continue - } - ah, err := parser.AnswerHeader() - if err != nil { - t.Errorf("parser.AnswerHeader(): %v", err) - continue - } - if ah.Type != dns.TypeA { - t.Errorf("unexpected answer type: got %v, want %v", ah.Type, dns.TypeA) - continue - } - res, err := parser.AResource() - if err != nil { - t.Errorf("parser.AResource(): %v", err) - continue - } - results[dnsname.FQDN(q.Name.String())] = net.IP(res.A[:]).String() - } - c.Close() - - if diff := cmp.Diff(wantResults, results); diff != "" { - t.Errorf("wrong results (-got+want)\n%s", diff) - } -} - -func TestDNSOverTCP_TooLarge(t *testing.T) { - log := tstest.WhileTestRunningLogger(t) - - f := fakeOSConfigurator{ - SplitDNS: true, - BaseConfig: OSConfig{ - Nameservers: mustIPs("8.8.8.8"), - SearchDomains: fqdns("coffee.shop"), - }, - } - m := NewManager(log, &f, new(health.Tracker), tsdial.NewDialer(netmon.NewStatic()), nil, nil, "") - m.resolver.TestOnlySetHook(f.SetResolver) - m.Set(Config{ - Hosts: hosts("andrew.ts.com.", "1.2.3.4"), - Routes: upstreams("ts.com", ""), - SearchDomains: fqdns("tailscale.com"), - }) - defer m.Down() - - c, s := net.Pipe() - defer s.Close() - go m.HandleTCPConn(s, netip.AddrPort{}) - defer c.Close() - - var b []byte - domain := dnsname.FQDN("andrew.ts.com.") - - // Write a successful request, then a large one that will fail; this - // exercises the data race in tailscale/tailscale#6725 - b = mkDNSRequest(domain, dns.TypeA, addEDNS) - binary.Write(c, binary.BigEndian, uint16(len(b))) - if _, err := c.Write(b); err != nil { - t.Fatal(err) - } - - c.SetWriteDeadline(time.Now().Add(5 * time.Second)) - - b = mkLargeDNSRequest(domain, dns.TypeA) - if err := binary.Write(c, binary.BigEndian, uint16(len(b))); err != nil { - t.Fatal(err) - } - if _, err := c.Write(b); err != nil { - // It's possible that we get an error here, since the - // net.Pipe() implementation enforces synchronous reads. So, - // handleReads could read the size, then error, and this write - // fails. That's actually a success for this test! - if errors.Is(err, io.ErrClosedPipe) { - t.Logf("pipe (correctly) closed when writing large response") - return - } - - t.Fatal(err) - } - - t.Logf("reading responses") - c.SetReadDeadline(time.Now().Add(5 * time.Second)) - - // We expect an EOF now, since the connection will have been closed due - // to a too-large query. - var respLength uint16 - err := binary.Read(c, binary.BigEndian, &respLength) - if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) { - t.Errorf("expected EOF on large read; got %v", err) - } -} diff --git a/net/dns/manager_test.go b/net/dns/manager_test.go deleted file mode 100644 index 366e08bbf8644..0000000000000 --- a/net/dns/manager_test.go +++ /dev/null @@ -1,951 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dns - -import ( - "net/netip" - "runtime" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/control/controlknobs" - "tailscale.com/health" - "tailscale.com/net/dns/resolver" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/types/dnstype" - "tailscale.com/util/dnsname" -) - -type fakeOSConfigurator struct { - SplitDNS bool - BaseConfig OSConfig - - OSConfig OSConfig - ResolverConfig resolver.Config -} - -func (c *fakeOSConfigurator) SetDNS(cfg OSConfig) error { - if !c.SplitDNS && len(cfg.MatchDomains) > 0 { - panic("split DNS config passed to non-split OSConfigurator") - } - c.OSConfig = cfg - return nil -} - -func (c *fakeOSConfigurator) SetResolver(cfg resolver.Config) { - c.ResolverConfig = cfg -} - -func (c *fakeOSConfigurator) SupportsSplitDNS() bool { - return c.SplitDNS -} - -func (c *fakeOSConfigurator) GetBaseConfig() (OSConfig, error) { - return c.BaseConfig, nil -} - -func (c *fakeOSConfigurator) Close() error { return nil } - -func TestCompileHostEntries(t *testing.T) { - tests := []struct { - name string - cfg Config - want []*HostEntry - }{ - { - name: "empty", - }, - { - name: "no-search-domains", - cfg: Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{ - "a.b.c.": {netip.MustParseAddr("1.1.1.1")}, - }, - }, - }, - { - name: "search-domains", - cfg: Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{ - "a.foo.ts.net.": {netip.MustParseAddr("1.1.1.1")}, - "b.foo.ts.net.": {netip.MustParseAddr("1.1.1.2")}, - "c.foo.ts.net.": {netip.MustParseAddr("1.1.1.3")}, - "d.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.4")}, - "d.foo.ts.net.": {netip.MustParseAddr("1.1.1.4")}, - "e.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.5")}, - "random.example.com.": {netip.MustParseAddr("1.1.1.1")}, - "other.example.com.": {netip.MustParseAddr("1.1.1.2")}, - "othertoo.example.com.": {netip.MustParseAddr("1.1.5.2")}, - }, - SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, - }, - want: []*HostEntry{ - {Addr: netip.MustParseAddr("1.1.1.1"), Hosts: []string{"a.foo.ts.net.", "a"}}, - {Addr: netip.MustParseAddr("1.1.1.2"), Hosts: []string{"b.foo.ts.net.", "b"}}, - {Addr: netip.MustParseAddr("1.1.1.3"), Hosts: []string{"c.foo.ts.net.", "c"}}, - {Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.ts.net.", "d", "d.foo.beta.tailscale.net."}}, - {Addr: netip.MustParseAddr("1.1.1.5"), Hosts: []string{"e.foo.beta.tailscale.net.", "e"}}, - }, - }, - { - name: "only-exact-subdomain-match", - cfg: Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{ - "e.foo.ts.net.": {netip.MustParseAddr("1.1.1.5")}, - "e.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.5")}, - "e.ignored.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.6")}, - }, - SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, - }, - want: []*HostEntry{ - {Addr: netip.MustParseAddr("1.1.1.5"), Hosts: []string{"e.foo.ts.net.", "e", "e.foo.beta.tailscale.net."}}, - }, - }, - { - name: "unmatched-domains", - cfg: Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{ - "d.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.4")}, - "d.foo.ts.net.": {netip.MustParseAddr("1.1.1.4")}, - "random.example.com.": {netip.MustParseAddr("1.1.1.1")}, - "other.example.com.": {netip.MustParseAddr("1.1.1.2")}, - "othertoo.example.com.": {netip.MustParseAddr("1.1.5.2")}, - }, - SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, - }, - want: []*HostEntry{ - {Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.ts.net.", "d", "d.foo.beta.tailscale.net."}}, - }, - }, - { - name: "overlaps", - cfg: Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{ - "h1.foo.ts.net.": {netip.MustParseAddr("1.1.1.3")}, - "h1.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.2")}, - "h2.foo.ts.net.": {netip.MustParseAddr("1.1.1.1")}, - "h2.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.1")}, - "example.com": {netip.MustParseAddr("1.1.1.1")}, - }, - SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."}, - }, - want: []*HostEntry{ - {Addr: netip.MustParseAddr("1.1.1.2"), Hosts: []string{"h1.foo.beta.tailscale.net."}}, - {Addr: netip.MustParseAddr("1.1.1.3"), Hosts: []string{"h1.foo.ts.net.", "h1"}}, - {Addr: netip.MustParseAddr("1.1.1.1"), Hosts: []string{"h2.foo.ts.net.", "h2", "h2.foo.beta.tailscale.net."}}, - }, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := compileHostEntries(tc.cfg) - if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b netip.Addr) bool { - return a == b - })); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) - } - }) - } -} - -func TestManager(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skipf("test's assumptions break because of https://github.com/tailscale/corp/issues/1662") - } - - // Note: these tests assume that it's safe to switch the - // OSConfigurator's split-dns support on and off between Set - // calls. Empirically this is currently true, because we reprobe - // the support every time we generate configs. It would be - // reasonable to make this unsupported as well, in which case - // these tests will need tweaking. - tests := []struct { - name string - in Config - split bool - bs OSConfig - os OSConfig - rs resolver.Config - goos string // empty means "linux" - }{ - { - name: "empty", - }, - { - name: "search-only", - in: Config{ - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - os: OSConfig{ - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - }, - { - // Regression test for https://github.com/tailscale/tailscale/issues/1886 - name: "hosts-only", - in: Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - }, - rs: resolver.Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - }, - }, - { - // If Hosts are specified (i.e. ExtraRecords) that aren't a split - // DNS route and a global resolver is specified, then make - // everything go via 100.100.100.100. - name: "hosts-with-global-dns-uses-quad100", - split: true, - in: Config{ - DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"), - Hosts: hosts( - "foo.tld.", "1.2.3.4", - "bar.tld.", "2.3.4.5"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - }, - rs: resolver.Config{ - Hosts: hosts( - "foo.tld.", "1.2.3.4", - "bar.tld.", "2.3.4.5"), - Routes: upstreams(".", "1.1.1.1", "9.9.9.9"), - }, - }, - { - // This is the above hosts-with-global-dns-uses-quad100 test but - // verifying that if global DNS servers aren't set (the 1.1.1.1 and - // 9.9.9.9 above), then we don't configure 100.100.100.100 as the - // resolver. - name: "hosts-without-global-dns-not-use-quad100", - split: true, - in: Config{ - Hosts: hosts( - "foo.tld.", "1.2.3.4", - "bar.tld.", "2.3.4.5"), - }, - os: OSConfig{}, - rs: resolver.Config{ - Hosts: hosts( - "foo.tld.", "1.2.3.4", - "bar.tld.", "2.3.4.5"), - }, - }, - { - // This tests that ExtraRecords (foo.tld and bar.tld here) don't trigger forcing - // traffic through 100.100.100.100 if there's Split DNS support and the extra - // records are part of a split DNS route. - name: "hosts-with-extrarecord-hosts-with-routes-no-quad100", - split: true, - in: Config{ - Routes: upstreams( - "tld.", "4.4.4.4", - ), - Hosts: hosts( - "foo.tld.", "1.2.3.4", - "bar.tld.", "2.3.4.5"), - }, - os: OSConfig{ - Nameservers: mustIPs("4.4.4.4"), - MatchDomains: fqdns("tld."), - }, - rs: resolver.Config{ - Hosts: hosts( - "foo.tld.", "1.2.3.4", - "bar.tld.", "2.3.4.5"), - }, - }, - { - name: "corp", - in: Config{ - DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - os: OSConfig{ - Nameservers: mustIPs("1.1.1.1", "9.9.9.9"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - }, - { - name: "corp-split", - in: Config{ - DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("1.1.1.1", "9.9.9.9"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - }, - { - name: "corp-magic", - in: Config{ - DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - Routes: upstreams("ts.com", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams(".", "1.1.1.1", "9.9.9.9"), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - }, - { - name: "corp-magic-split", - in: Config{ - DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - Routes: upstreams("ts.com", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams(".", "1.1.1.1", "9.9.9.9"), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - }, - { - name: "corp-routes", - in: Config{ - DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"), - Routes: upstreams("corp.com", "2.2.2.2"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "1.1.1.1", "9.9.9.9", - "corp.com.", "2.2.2.2"), - }, - }, - { - name: "corp-routes-split", - in: Config{ - DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"), - Routes: upstreams("corp.com", "2.2.2.2"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "1.1.1.1", "9.9.9.9", - "corp.com.", "2.2.2.2"), - }, - }, - { - name: "routes", - in: Config{ - Routes: upstreams("corp.com", "2.2.2.2"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - bs: OSConfig{ - Nameservers: mustIPs("8.8.8.8"), - SearchDomains: fqdns("coffee.shop"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "8.8.8.8", - "corp.com.", "2.2.2.2"), - }, - }, - { - name: "routes-split", - in: Config{ - Routes: upstreams("corp.com", "2.2.2.2"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("2.2.2.2"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - MatchDomains: fqdns("corp.com"), - }, - }, - { - name: "routes-multi", - in: Config{ - Routes: upstreams( - "corp.com", "2.2.2.2", - "bigco.net", "3.3.3.3"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - bs: OSConfig{ - Nameservers: mustIPs("8.8.8.8"), - SearchDomains: fqdns("coffee.shop"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "8.8.8.8", - "corp.com.", "2.2.2.2", - "bigco.net.", "3.3.3.3"), - }, - }, - { - name: "routes-multi-split-linux", - in: Config{ - Routes: upstreams( - "corp.com", "2.2.2.2", - "bigco.net", "3.3.3.3"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - MatchDomains: fqdns("bigco.net", "corp.com"), - }, - rs: resolver.Config{ - Routes: upstreams( - "corp.com.", "2.2.2.2", - "bigco.net.", "3.3.3.3"), - }, - goos: "linux", - }, - { - // The `routes-multi-split-linux` test case above on Darwin should NOT result in a split - // DNS configuration. - // Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains - // without those domains also being SearchDomains. - name: "routes-multi-does-not-split-on-darwin", - in: Config{ - Routes: upstreams( - "corp.com", "2.2.2.2", - "bigco.net", "3.3.3.3"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: false, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "", - "corp.com.", "2.2.2.2", - "bigco.net.", "3.3.3.3"), - }, - goos: "darwin", - }, - { - // The `routes-multi-split-linux` test case above on iOS should NOT result in a split - // DNS configuration. - // Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains - // without those domains also being SearchDomains. - name: "routes-multi-does-not-split-on-ios", - in: Config{ - Routes: upstreams( - "corp.com", "2.2.2.2", - "bigco.net", "3.3.3.3"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: false, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "", - "corp.com.", "2.2.2.2", - "bigco.net.", "3.3.3.3"), - }, - goos: "ios", - }, - { - name: "magic", - in: Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - Routes: upstreams("ts.com", ""), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - bs: OSConfig{ - Nameservers: mustIPs("8.8.8.8"), - SearchDomains: fqdns("coffee.shop"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), - }, - rs: resolver.Config{ - Routes: upstreams(".", "8.8.8.8"), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - }, - { - name: "magic-split", - in: Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - Routes: upstreams("ts.com", ""), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - MatchDomains: fqdns("ts.com"), - }, - rs: resolver.Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - goos: "linux", - }, - { - // The `magic-split` test case above on Darwin should NOT result in a split DNS configuration. - // Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains - // without those domains also being SearchDomains. - name: "magic-split-does-not-split-on-darwin", - in: Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - Routes: upstreams("ts.com", ""), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: false, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams(".", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - goos: "darwin", - }, - { - // The `magic-split` test case above on iOS should NOT result in a split DNS configuration. - // Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains - // without those domains also being SearchDomains. - name: "magic-split-does-not-split-on-ios", - in: Config{ - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - Routes: upstreams("ts.com", ""), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: false, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams(".", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - goos: "ios", - }, - { - name: "routes-magic", - in: Config{ - Routes: upstreams("corp.com", "2.2.2.2", "ts.com", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - bs: OSConfig{ - Nameservers: mustIPs("8.8.8.8"), - SearchDomains: fqdns("coffee.shop"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"), - }, - rs: resolver.Config{ - Routes: upstreams( - "corp.com.", "2.2.2.2", - ".", "8.8.8.8"), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - }, - { - name: "routes-magic-split-linux", - in: Config{ - Routes: upstreams( - "corp.com", "2.2.2.2", - "ts.com", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - MatchDomains: fqdns("corp.com", "ts.com"), - }, - rs: resolver.Config{ - Routes: upstreams("corp.com.", "2.2.2.2"), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - goos: "linux", - }, - { - // The `routes-magic-split-linux` test case above on Darwin should NOT result in a - // split DNS configuration. - // Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains - // without those domains also being SearchDomains. - name: "routes-magic-does-not-split-on-darwin", - in: Config{ - Routes: upstreams( - "corp.com", "2.2.2.2", - "ts.com", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "", - "corp.com.", "2.2.2.2", - ), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - goos: "darwin", - }, - { - // The `routes-magic-split-linux` test case above on Darwin should NOT result in a - // split DNS configuration. - // Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains - // without those domains also being SearchDomains. - name: "routes-magic-does-not-split-on-ios", - in: Config{ - Routes: upstreams( - "corp.com", "2.2.2.2", - "ts.com", ""), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "", - "corp.com.", "2.2.2.2", - ), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - LocalDomains: fqdns("ts.com."), - }, - goos: "ios", - }, - { - name: "exit-node-forward", - in: Config{ - DefaultResolvers: mustRes("http://[fd7a:115c:a1e0:ab12:4843:cd96:6245:7a66]:2982/doh"), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("tailscale.com", "universe.tf"), - }, - rs: resolver.Config{ - Routes: upstreams(".", "http://[fd7a:115c:a1e0:ab12:4843:cd96:6245:7a66]:2982/doh"), - Hosts: hosts( - "dave.ts.com.", "1.2.3.4", - "bradfitz.ts.com.", "2.3.4.5"), - }, - }, - { - name: "corp-v6", - in: Config{ - DefaultResolvers: mustRes("1::1"), - }, - os: OSConfig{ - Nameservers: mustIPs("1::1"), - }, - }, - { - // This one's structurally the same as the previous one (corp-v6), but - // instead of 1::1 as the IPv6 address, it uses a NextDNS IPv6 address which - // is specially recognized. - name: "corp-v6-nextdns", - in: Config{ - DefaultResolvers: mustRes("2a07:a8c0::c3:a884"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - }, - rs: resolver.Config{ - Routes: upstreams(".", "2a07:a8c0::c3:a884"), - }, - }, - { - name: "nextdns-doh", - in: Config{ - DefaultResolvers: mustRes("https://dns.nextdns.io/c3a884"), - }, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - }, - rs: resolver.Config{ - Routes: upstreams(".", "https://dns.nextdns.io/c3a884"), - }, - }, - { - // on iOS exclusively, tests the split DNS behavior for battery life optimization added in - // https://github.com/tailscale/tailscale/pull/10576 - name: "ios-use-split-dns-when-no-custom-resolvers", - in: Config{ - Routes: upstreams("ts.net", "199.247.155.52", "optimistic-display.ts.net", ""), - SearchDomains: fqdns("optimistic-display.ts.net"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("optimistic-display.ts.net"), - MatchDomains: fqdns("ts.net"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "", - "ts.net", "199.247.155.52", - ), - LocalDomains: fqdns("optimistic-display.ts.net."), - }, - goos: "ios", - }, - { - // if using app connectors, the battery life optimization above should not be applied - name: "ios-dont-use-split-dns-when-app-connector-resolver-needed", - in: Config{ - Routes: upstreams( - "ts.net", "199.247.155.52", - "optimistic-display.ts.net", "", - "github.com", "https://dnsresolver.bigcorp.com/2f143"), - SearchDomains: fqdns("optimistic-display.ts.net"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("optimistic-display.ts.net"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "", - "github.com", "https://dnsresolver.bigcorp.com/2f143", - "ts.net", "199.247.155.52", - ), - LocalDomains: fqdns("optimistic-display.ts.net."), - }, - goos: "ios", - }, - { - // on darwin, verify that with the same config as in ios-use-split-dns-when-no-custom-resolvers, - // MatchDomains are NOT set. - name: "darwin-dont-use-split-dns-when-no-custom-resolvers", - in: Config{ - Routes: upstreams("ts.net", "199.247.155.52", "optimistic-display.ts.net", ""), - SearchDomains: fqdns("optimistic-display.ts.net"), - }, - split: true, - os: OSConfig{ - Nameservers: mustIPs("100.100.100.100"), - SearchDomains: fqdns("optimistic-display.ts.net"), - }, - rs: resolver.Config{ - Routes: upstreams( - ".", "", - "ts.net", "199.247.155.52", - ), - LocalDomains: fqdns("optimistic-display.ts.net."), - }, - goos: "darwin", - }, - } - - trIP := cmp.Transformer("ipStr", func(ip netip.Addr) string { return ip.String() }) - trIPPort := cmp.Transformer("ippStr", func(ipp netip.AddrPort) string { - if ipp.Port() == 53 { - return ipp.Addr().String() - } - return ipp.String() - }) - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - f := fakeOSConfigurator{ - SplitDNS: test.split, - BaseConfig: test.bs, - } - goos := test.goos - if goos == "" { - goos = "linux" - } - knobs := &controlknobs.Knobs{} - m := NewManager(t.Logf, &f, new(health.Tracker), tsdial.NewDialer(netmon.NewStatic()), nil, knobs, goos) - m.resolver.TestOnlySetHook(f.SetResolver) - - if err := m.Set(test.in); err != nil { - t.Fatalf("m.Set: %v", err) - } - if diff := cmp.Diff(f.OSConfig, test.os, trIP, trIPPort, cmpopts.EquateEmpty()); diff != "" { - t.Errorf("wrong OSConfig (-got+want)\n%s", diff) - } - if diff := cmp.Diff(f.ResolverConfig, test.rs, trIP, trIPPort, cmpopts.EquateEmpty()); diff != "" { - t.Errorf("wrong resolver.Config (-got+want)\n%s", diff) - } - }) - } -} - -func mustIPs(strs ...string) (ret []netip.Addr) { - for _, s := range strs { - ret = append(ret, netip.MustParseAddr(s)) - } - return ret -} - -func mustRes(strs ...string) (ret []*dnstype.Resolver) { - for _, s := range strs { - ret = append(ret, &dnstype.Resolver{Addr: s}) - } - return ret -} - -func fqdns(strs ...string) (ret []dnsname.FQDN) { - for _, s := range strs { - fqdn, err := dnsname.ToFQDN(s) - if err != nil { - panic(err) - } - ret = append(ret, fqdn) - } - return ret -} - -func hosts(strs ...string) (ret map[dnsname.FQDN][]netip.Addr) { - var key dnsname.FQDN - ret = map[dnsname.FQDN][]netip.Addr{} - for _, s := range strs { - if ip, err := netip.ParseAddr(s); err == nil { - if key == "" { - panic("IP provided before name") - } - ret[key] = append(ret[key], ip) - } else { - fqdn, err := dnsname.ToFQDN(s) - if err != nil { - panic(err) - } - key = fqdn - } - } - return ret -} - -func upstreams(strs ...string) (ret map[dnsname.FQDN][]*dnstype.Resolver) { - var key dnsname.FQDN - ret = map[dnsname.FQDN][]*dnstype.Resolver{} - for _, s := range strs { - if s == "" { - if key == "" { - panic("IPPort provided before suffix") - } - ret[key] = nil - } else if ipp, err := netip.ParseAddrPort(s); err == nil { - if key == "" { - panic("IPPort provided before suffix") - } - ret[key] = append(ret[key], &dnstype.Resolver{Addr: ipp.String()}) - } else if _, err := netip.ParseAddr(s); err == nil { - if key == "" { - panic("IPPort provided before suffix") - } - ret[key] = append(ret[key], &dnstype.Resolver{Addr: s}) - } else if strings.HasPrefix(s, "http") { - ret[key] = append(ret[key], &dnstype.Resolver{Addr: s}) - } else { - fqdn, err := dnsname.ToFQDN(s) - if err != nil { - panic(err) - } - key = fqdn - } - } - return ret -} diff --git a/net/dns/manager_windows.go b/net/dns/manager_windows.go index 250a2557350dd..9fb285b0d1cea 100644 --- a/net/dns/manager_windows.go +++ b/net/dns/manager_windows.go @@ -18,16 +18,16 @@ import ( "syscall" "time" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/winutil" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/atomicfile" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/types/logger" - "tailscale.com/util/dnsname" - "tailscale.com/util/winutil" ) const ( diff --git a/net/dns/manager_windows_test.go b/net/dns/manager_windows_test.go deleted file mode 100644 index 62c4dd9fbb740..0000000000000 --- a/net/dns/manager_windows_test.go +++ /dev/null @@ -1,634 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dns - -import ( - "bytes" - "context" - "fmt" - "math/rand" - "net/netip" - "strings" - "testing" - "time" - - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/registry" - "tailscale.com/util/dnsname" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/gp" -) - -const testGPRuleID = "{7B1B6151-84E6-41A3-8967-62F7F7B45687}" - -func TestHostFileNewLines(t *testing.T) { - in := []byte("#foo\r\n#bar\n#baz\n") - want := []byte("#foo\r\n#bar\r\n#baz\r\n") - - got, err := setTailscaleHosts(in, nil) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, want) { - t.Errorf("got %q, want %q\n", got, want) - } -} - -func TestManagerWindowsLocal(t *testing.T) { - if !isWindows10OrBetter() || !winutil.IsCurrentProcessElevated() { - t.Skipf("test requires running as elevated user on Windows 10+") - } - - runTest(t, true) -} - -func TestManagerWindowsGP(t *testing.T) { - if !isWindows10OrBetter() || !winutil.IsCurrentProcessElevated() { - t.Skipf("test requires running as elevated user on Windows 10+") - } - - checkGPNotificationsWork(t) - - // Make sure group policy is refreshed before this test exits but after we've - // cleaned everything else up. - defer gp.RefreshMachinePolicy(true) - - err := createFakeGPKey() - if err != nil { - t.Fatalf("Creating fake GP key: %v\n", err) - } - defer deleteFakeGPKey(t) - - runTest(t, false) -} - -func TestManagerWindowsGPCopy(t *testing.T) { - if !isWindows10OrBetter() || !winutil.IsCurrentProcessElevated() { - t.Skipf("test requires running as elevated user on Windows 10+") - } - - checkGPNotificationsWork(t) - - logf := func(format string, args ...any) { - t.Logf(format, args...) - } - - fakeInterface, err := windows.GenerateGUID() - if err != nil { - t.Fatalf("windows.GenerateGUID: %v\n", err) - } - - delIfKey, err := createFakeInterfaceKey(t, fakeInterface) - if err != nil { - t.Fatalf("createFakeInterfaceKey: %v\n", err) - } - defer delIfKey() - - cfg, err := NewOSConfigurator(logf, nil, nil, fakeInterface.String()) - if err != nil { - t.Fatalf("NewOSConfigurator: %v\n", err) - } - mgr := cfg.(*windowsManager) - defer mgr.Close() - - usingGP := mgr.nrptDB.writeAsGP - if usingGP { - t.Fatalf("usingGP %v, want %v\n", usingGP, false) - } - - regWatcher, err := newRegKeyWatcher() - if err != nil { - t.Fatalf("newRegKeyWatcher error %v\n", err) - } - - // Upon initialization of cfg, we should not have any NRPT rules - ensureNoRules(t) - - resolvers := []netip.Addr{netip.MustParseAddr("1.1.1.1")} - domains := genRandomSubdomains(t, 1) - - // 1. Populate local NRPT - err = mgr.setSplitDNS(resolvers, domains) - if err != nil { - t.Fatalf("setSplitDNS: %v\n", err) - } - - t.Logf("Validating that local NRPT is populated...\n") - validateRegistry(t, nrptBaseLocal, domains) - ensureNoRulesInSubkey(t, nrptBaseGP) - - // 2. Create fake GP key and refresh - t.Logf("Creating fake group policy key and refreshing...\n") - err = createFakeGPKey() - if err != nil { - t.Fatalf("createFakeGPKey: %v\n", err) - } - - err = regWatcher.watch() - if err != nil { - t.Fatalf("regWatcher.watch: %v\n", err) - } - - err = gp.RefreshMachinePolicy(true) - if err != nil { - t.Fatalf("testDoRefresh: %v\n", err) - } - - err = regWatcher.wait() - if err != nil { - t.Fatalf("regWatcher.wait: %v\n", err) - } - - // 3. Check that both local NRPT and GP NRPT are populated - t.Logf("Validating that group policy NRPT is populated...\n") - validateRegistry(t, nrptBaseLocal, domains) - validateRegistry(t, nrptBaseGP, domains) - - // 4. Delete fake GP key and refresh - t.Logf("Deleting fake group policy key and refreshing...\n") - deleteFakeGPKey(t) - - err = regWatcher.watch() - if err != nil { - t.Fatalf("regWatcher.watch: %v\n", err) - } - - err = gp.RefreshMachinePolicy(true) - if err != nil { - t.Fatalf("testDoRefresh: %v\n", err) - } - - err = regWatcher.wait() - if err != nil { - t.Fatalf("regWatcher.wait: %v\n", err) - } - - // 5. Check that local NRPT is populated and GP is empty - t.Logf("Validating that local NRPT is populated...\n") - validateRegistry(t, nrptBaseLocal, domains) - ensureNoRulesInSubkey(t, nrptBaseGP) - - // 6. Cleanup - t.Logf("Cleaning up...\n") - err = mgr.setSplitDNS(nil, domains) - if err != nil { - t.Fatalf("setSplitDNS: %v\n", err) - } - ensureNoRules(t) -} - -func checkGPNotificationsWork(t *testing.T) { - // Test to ensure that RegisterGPNotification work on this machine, - // otherwise this test will fail. - trk, err := newGPNotificationTracker() - if err != nil { - t.Skipf("newGPNotificationTracker error: %v\n", err) - } - defer trk.Close() - - err = gp.RefreshMachinePolicy(true) - if err != nil { - t.Fatalf("RefreshPolicyEx error: %v\n", err) - } - - timeout := uint32(10000) // Milliseconds - if !trk.DidRefreshTimeout(timeout) { - t.Skipf("GP notifications are not working on this machine\n") - } -} - -func runTest(t *testing.T, isLocal bool) { - logf := func(format string, args ...any) { - t.Logf(format, args...) - } - - fakeInterface, err := windows.GenerateGUID() - if err != nil { - t.Fatalf("windows.GenerateGUID: %v\n", err) - } - - delIfKey, err := createFakeInterfaceKey(t, fakeInterface) - if err != nil { - t.Fatalf("createFakeInterfaceKey: %v\n", err) - } - defer delIfKey() - - cfg, err := NewOSConfigurator(logf, nil, nil, fakeInterface.String()) - if err != nil { - t.Fatalf("NewOSConfigurator: %v\n", err) - } - mgr := cfg.(*windowsManager) - defer mgr.Close() - - usingGP := mgr.nrptDB.writeAsGP - if isLocal == usingGP { - t.Fatalf("usingGP %v, want %v\n", usingGP, !usingGP) - } - - // Upon initialization of cfg, we should not have any NRPT rules - ensureNoRules(t) - - resolvers := []netip.Addr{netip.MustParseAddr("1.1.1.1")} - - domains := genRandomSubdomains(t, 2*nrptMaxDomainsPerRule+1) - - cases := []int{ - 1, - 50, - 51, - 100, - 101, - 100, - 50, - 1, - 51, - } - - var regBaseValidate string - var regBaseEnsure string - if isLocal { - regBaseValidate = nrptBaseLocal - regBaseEnsure = nrptBaseGP - } else { - regBaseValidate = nrptBaseGP - regBaseEnsure = nrptBaseLocal - } - - var trk *gpNotificationTracker - if isLocal { - // (dblohm7) When isLocal == true, we keep trk active through the entire - // sequence of test cases, and then we verify that no policy notifications - // occurred. Because policy notifications are scoped to the entire computer, - // this check could potentially fail if another process concurrently modifies - // group policies while this test is running. I don't expect this to be an - // issue on any computer on which we run this test, but something to keep in - // mind if we start seeing flakiness around these GP notifications. - trk, err = newGPNotificationTracker() - if err != nil { - t.Fatalf("newGPNotificationTracker: %v\n", err) - } - defer trk.Close() - } - - runCase := func(n int) { - t.Logf("Test case: %d domains\n", n) - if !isLocal { - // When !isLocal, we want to check that a GP notification occurred for - // every single test case. - trk, err = newGPNotificationTracker() - if err != nil { - t.Fatalf("newGPNotificationTracker: %v\n", err) - } - defer trk.Close() - } - caseDomains := domains[:n] - err = mgr.setSplitDNS(resolvers, caseDomains) - if err != nil { - t.Fatalf("setSplitDNS: %v\n", err) - } - validateRegistry(t, regBaseValidate, caseDomains) - ensureNoRulesInSubkey(t, regBaseEnsure) - if !isLocal && !trk.DidRefresh(true) { - t.Fatalf("DidRefresh false, want true\n") - } - } - - for _, n := range cases { - runCase(n) - } - - if isLocal && trk.DidRefresh(false) { - t.Errorf("DidRefresh true, want false\n") - } - - t.Logf("Test case: nil resolver\n") - err = mgr.setSplitDNS(nil, domains) - if err != nil { - t.Fatalf("setSplitDNS: %v\n", err) - } - ensureNoRules(t) -} - -func createFakeGPKey() error { - keyStr := nrptBaseGP + `\` + testGPRuleID - key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, keyStr, registry.SET_VALUE) - if err != nil { - return fmt.Errorf("opening %s: %w", keyStr, err) - } - defer key.Close() - if err := key.SetDWordValue("Version", 1); err != nil { - return err - } - if err := key.SetStringsValue("Name", []string{"._setbygp_.example.com"}); err != nil { - return err - } - if err := key.SetStringValue("GenericDNSServers", "1.1.1.1"); err != nil { - return err - } - if err := key.SetDWordValue("ConfigOptions", nrptOverrideDNS); err != nil { - return err - } - return nil -} - -func deleteFakeGPKey(t *testing.T) { - keyName := nrptBaseGP + `\` + testGPRuleID - if err := registry.DeleteKey(registry.LOCAL_MACHINE, keyName); err != nil && err != registry.ErrNotExist { - t.Fatalf("Error deleting NRPT rule key %q: %v\n", keyName, err) - } - - isEmpty, err := isPolicyConfigSubkeyEmpty() - if err != nil { - t.Fatalf("isPolicyConfigSubkeyEmpty: %v", err) - } - - if !isEmpty { - return - } - - if err := registry.DeleteKey(registry.LOCAL_MACHINE, nrptBaseGP); err != nil { - t.Fatalf("Deleting DnsPolicyKey Subkey: %v", err) - } -} - -func createFakeInterfaceKey(t *testing.T, guid windows.GUID) (func(), error) { - basePaths := []winutil.RegistryPathPrefix{winutil.IPv4TCPIPInterfacePrefix, winutil.IPv6TCPIPInterfacePrefix} - keyPaths := make([]string, 0, len(basePaths)) - - guidStr := guid.String() - for _, basePath := range basePaths { - keyPath := string(basePath.WithSuffix(guidStr)) - key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, keyPath, registry.SET_VALUE) - if err != nil { - return nil, err - } - key.Close() - - keyPaths = append(keyPaths, keyPath) - } - - result := func() { - for _, keyPath := range keyPaths { - if err := registry.DeleteKey(registry.LOCAL_MACHINE, keyPath); err != nil { - t.Fatalf("deleting fake interface key \"%s\": %v\n", keyPath, err) - } - } - } - - return result, nil -} - -func ensureNoRules(t *testing.T) { - ruleIDs := winutil.GetRegStrings(nrptRuleIDValueName, nil) - if ruleIDs != nil { - t.Errorf("%s: %v, want nil\n", nrptRuleIDValueName, ruleIDs) - } - - for _, base := range []string{nrptBaseLocal, nrptBaseGP} { - ensureNoSingleRule(t, base) - } -} - -func ensureNoRulesInSubkey(t *testing.T, base string) { - ruleIDs := winutil.GetRegStrings(nrptRuleIDValueName, nil) - if ruleIDs == nil { - for _, base := range []string{nrptBaseLocal, nrptBaseGP} { - ensureNoSingleRule(t, base) - } - return - } - - for _, ruleID := range ruleIDs { - keyName := base + `\` + ruleID - key, err := registry.OpenKey(registry.LOCAL_MACHINE, keyName, registry.READ) - if err == nil { - key.Close() - } else if err != registry.ErrNotExist { - t.Fatalf("%s: %q, want %q\n", keyName, err, registry.ErrNotExist) - } - } - - if base == nrptBaseGP { - // When dealing with the group policy subkey, we want the base key to - // also be absent. - key, err := registry.OpenKey(registry.LOCAL_MACHINE, base, registry.READ) - if err == nil { - key.Close() - - isEmpty, err := isPolicyConfigSubkeyEmpty() - if err != nil { - t.Fatalf("isPolicyConfigSubkeyEmpty: %v", err) - } - if isEmpty { - t.Errorf("Unexpectedly found group policy key\n") - } - } else if err != registry.ErrNotExist { - t.Errorf("Group policy key error: %q, want %q\n", err, registry.ErrNotExist) - } - } -} - -func ensureNoSingleRule(t *testing.T, base string) { - singleKeyPath := base + `\` + nrptSingleRuleID - key, err := registry.OpenKey(registry.LOCAL_MACHINE, singleKeyPath, registry.READ) - if err == nil { - key.Close() - } - if err != registry.ErrNotExist { - t.Fatalf("%s: %q, want %q\n", singleKeyPath, err, registry.ErrNotExist) - } -} - -func validateRegistry(t *testing.T, nrptBase string, domains []dnsname.FQDN) { - q := len(domains) / nrptMaxDomainsPerRule - r := len(domains) % nrptMaxDomainsPerRule - numRules := q - if r > 0 { - numRules++ - } - - ruleIDs := winutil.GetRegStrings(nrptRuleIDValueName, nil) - if ruleIDs == nil { - ruleIDs = []string{nrptSingleRuleID} - } else if len(ruleIDs) != numRules { - t.Errorf("%s for %d domains: %d, want %d\n", nrptRuleIDValueName, len(domains), len(ruleIDs), numRules) - } - - for i, ruleID := range ruleIDs { - savedDomains, err := getSavedDomainsForRule(nrptBase, ruleID) - if err != nil { - t.Fatalf("getSavedDomainsForRule(%q, %q): %v\n", nrptBase, ruleID, err) - } - - start := i * nrptMaxDomainsPerRule - end := start + nrptMaxDomainsPerRule - if i == len(ruleIDs)-1 && r > 0 { - end = start + r - } - - checkDomains := domains[start:end] - if len(checkDomains) != len(savedDomains) { - t.Errorf("len(checkDomains) != len(savedDomains): %d, want %d\n", len(savedDomains), len(checkDomains)) - } - for j, cd := range checkDomains { - sd := strings.TrimPrefix(savedDomains[j], ".") - if string(cd.WithoutTrailingDot()) != sd { - t.Errorf("checkDomain differs savedDomain: %s, want %s\n", sd, cd.WithoutTrailingDot()) - } - } - } -} - -func getSavedDomainsForRule(base, ruleID string) ([]string, error) { - keyPath := base + `\` + ruleID - key, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.READ) - if err != nil { - return nil, err - } - defer key.Close() - result, _, err := key.GetStringsValue("Name") - return result, err -} - -func genRandomSubdomains(t *testing.T, n int) []dnsname.FQDN { - domains := make([]dnsname.FQDN, 0, n) - - seed := time.Now().UnixNano() - t.Logf("genRandomSubdomains(%d) seed: %v\n", n, seed) - - r := rand.New(rand.NewSource(seed)) - const charset = "abcdefghijklmnopqrstuvwxyz" - - for len(domains) < cap(domains) { - l := r.Intn(19) + 1 - b := make([]byte, l) - for i := range b { - b[i] = charset[r.Intn(len(charset))] - } - d := string(b) + ".example.com" - fqdn, err := dnsname.ToFQDN(d) - if err != nil { - t.Fatalf("dnsname.ToFQDN: %v\n", err) - } - domains = append(domains, fqdn) - } - - return domains -} - -var ( - libUserenv = windows.NewLazySystemDLL("userenv.dll") - procRegisterGPNotification = libUserenv.NewProc("RegisterGPNotification") - procUnregisterGPNotification = libUserenv.NewProc("UnregisterGPNotification") -) - -// gpNotificationTracker registers with the Windows policy engine and receives -// notifications when policy refreshes occur. -type gpNotificationTracker struct { - event windows.Handle -} - -func newGPNotificationTracker() (*gpNotificationTracker, error) { - var err error - evt, err := windows.CreateEvent(nil, 0, 0, nil) - if err != nil { - return nil, err - } - defer func() { - if err != nil { - windows.CloseHandle(evt) - } - }() - - ok, _, e := procRegisterGPNotification.Call( - uintptr(evt), - uintptr(1), // We want computer policy changes, not user policy changes. - ) - if ok == 0 { - err = e - return nil, err - } - - return &gpNotificationTracker{evt}, nil -} - -func (trk *gpNotificationTracker) DidRefresh(isExpected bool) bool { - // If we're not expecting a refresh event, then we need to use a timeout. - timeout := uint32(1000) // 1 second (in milliseconds) - if isExpected { - // Otherwise, since it is imperative that we see an event, we wait infinitely. - timeout = windows.INFINITE - } - - return trk.DidRefreshTimeout(timeout) -} - -func (trk *gpNotificationTracker) DidRefreshTimeout(timeout uint32) bool { - waitCode, _ := windows.WaitForSingleObject(trk.event, timeout) - return waitCode == windows.WAIT_OBJECT_0 -} - -func (trk *gpNotificationTracker) Close() error { - procUnregisterGPNotification.Call(uintptr(trk.event)) - windows.CloseHandle(trk.event) - trk.event = 0 - return nil -} - -type regKeyWatcher struct { - keyGP registry.Key - evtGP windows.Handle -} - -func newRegKeyWatcher() (result *regKeyWatcher, err error) { - // Monitor dnsBaseGP instead of nrptBaseGP, since the latter will be - // repeatedly created and destroyed throughout the course of the test. - keyGP, _, err := registry.CreateKey(registry.LOCAL_MACHINE, dnsBaseGP, registry.READ) - if err != nil { - return nil, err - } - defer func() { - if err != nil { - keyGP.Close() - } - }() - - evtGP, err := windows.CreateEvent(nil, 0, 0, nil) - if err != nil { - return nil, err - } - - return ®KeyWatcher{ - keyGP: keyGP, - evtGP: evtGP, - }, nil -} - -func (rw *regKeyWatcher) watch() error { - // We can make these waits thread-agnostic because the tests that use this code must already run on Windows 10+ - return windows.RegNotifyChangeKeyValue(windows.Handle(rw.keyGP), true, - windows.REG_NOTIFY_CHANGE_NAME|windows.REG_NOTIFY_THREAD_AGNOSTIC, rw.evtGP, true) -} - -func (rw *regKeyWatcher) wait() error { - waitCode, err := windows.WaitForSingleObject( - rw.evtGP, - 10000, // 10 seconds (as milliseconds) - ) - - switch waitCode { - case uint32(windows.WAIT_TIMEOUT): - return context.DeadlineExceeded - case windows.WAIT_FAILED: - return err - default: - return nil - } -} - -func (rw *regKeyWatcher) Close() error { - rw.keyGP.Close() - windows.CloseHandle(rw.evtGP) - return nil -} diff --git a/net/dns/nm.go b/net/dns/nm.go index adb33cdb7967a..7a04f6b605e39 100644 --- a/net/dns/nm.go +++ b/net/dns/nm.go @@ -15,8 +15,8 @@ import ( "github.com/godbus/dbus/v5" "github.com/josharian/native" - "tailscale.com/net/tsaddr" - "tailscale.com/util/dnsname" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/util/dnsname" ) const ( diff --git a/net/dns/nrpt_windows.go b/net/dns/nrpt_windows.go index 261ca337558ef..babd30631cb9f 100644 --- a/net/dns/nrpt_windows.go +++ b/net/dns/nrpt_windows.go @@ -9,13 +9,13 @@ import ( "sync" "sync/atomic" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/util/winutil/gp" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "tailscale.com/types/logger" - "tailscale.com/util/dnsname" - "tailscale.com/util/set" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/gp" ) const ( diff --git a/net/dns/openresolv.go b/net/dns/openresolv.go index 0b5c87a3b3534..c991be0d7f251 100644 --- a/net/dns/openresolv.go +++ b/net/dns/openresolv.go @@ -11,7 +11,7 @@ import ( "os/exec" "strings" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // openresolvManager manages DNS configuration using the openresolv diff --git a/net/dns/osconfig.go b/net/dns/osconfig.go index 842c5ac607853..6ce4368d0d76a 100644 --- a/net/dns/osconfig.go +++ b/net/dns/osconfig.go @@ -11,8 +11,8 @@ import ( "slices" "strings" - "tailscale.com/types/logger" - "tailscale.com/util/dnsname" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/dnsname" ) // An OSConfigurator applies DNS settings to the operating system. diff --git a/net/dns/osconfig_test.go b/net/dns/osconfig_test.go deleted file mode 100644 index c19db299f4b54..0000000000000 --- a/net/dns/osconfig_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dns - -import ( - "fmt" - "net/netip" - "reflect" - "testing" - - "tailscale.com/tstest" - "tailscale.com/util/dnsname" -) - -func TestOSConfigPrintable(t *testing.T) { - ocfg := OSConfig{ - Hosts: []*HostEntry{ - { - Addr: netip.AddrFrom4([4]byte{100, 1, 2, 3}), - Hosts: []string{"server", "client"}, - }, - { - Addr: netip.AddrFrom4([4]byte{100, 1, 2, 4}), - Hosts: []string{"otherhost"}, - }, - }, - Nameservers: []netip.Addr{ - netip.AddrFrom4([4]byte{8, 8, 8, 8}), - }, - SearchDomains: []dnsname.FQDN{ - dnsname.FQDN("foo.beta.tailscale.net."), - dnsname.FQDN("bar.beta.tailscale.net."), - }, - MatchDomains: []dnsname.FQDN{ - dnsname.FQDN("ts.com."), - }, - } - s := fmt.Sprintf("%+v", ocfg) - - const expected = `{Nameservers:[8.8.8.8] SearchDomains:[foo.beta.tailscale.net. bar.beta.tailscale.net.] MatchDomains:[ts.com.] Hosts:[&{Addr:100.1.2.3 Hosts:[server client]} &{Addr:100.1.2.4 Hosts:[otherhost]}]}` - if s != expected { - t.Errorf("format mismatch:\n got: %s\n want: %s", s, expected) - } -} - -func TestIsZero(t *testing.T) { - tstest.CheckIsZero[OSConfig](t, map[reflect.Type]any{ - reflect.TypeFor[dnsname.FQDN](): dnsname.FQDN("foo.bar."), - reflect.TypeFor[*HostEntry](): &HostEntry{ - Addr: netip.AddrFrom4([4]byte{100, 1, 2, 3}), - Hosts: []string{"foo", "bar"}, - }, - }) -} diff --git a/net/dns/publicdns/publicdns_test.go b/net/dns/publicdns/publicdns_test.go deleted file mode 100644 index 6efeb2c6f96c8..0000000000000 --- a/net/dns/publicdns/publicdns_test.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package publicdns - -import ( - "net/netip" - "reflect" - "testing" -) - -func TestInit(t *testing.T) { - for _, baseKey := range KnownDoHPrefixes() { - baseSet := DoHIPsOfBase(baseKey) - for _, addr := range baseSet { - back, only, ok := DoHEndpointFromIP(addr) - if !ok { - t.Errorf("DoHEndpointFromIP(%v) not mapped back to %v", addr, baseKey) - continue - } - if only { - t.Errorf("unexpected DoH only bit set for %v", addr) - } - if back != baseKey { - t.Errorf("Expected %v to map to %s, got %s", addr, baseKey, back) - } - } - } -} - -func TestDoHV6(t *testing.T) { - tests := []struct { - in string - firstIP netip.Addr - want bool - }{ - {"https://cloudflare-dns.com/dns-query", netip.MustParseAddr("2606:4700:4700::1111"), true}, - {"https://dns.google/dns-query", netip.MustParseAddr("2001:4860:4860::8888"), true}, - {"bogus", netip.Addr{}, false}, - } - for _, test := range tests { - t.Run(test.in, func(t *testing.T) { - ip, ok := DoHV6(test.in) - if ok != test.want || ip != test.firstIP { - t.Errorf("DohV6 got (%v: IPv6 %v) for %v, want (%v: IPv6 %v)", ip, ok, test.in, test.firstIP, test.want) - } - }) - } -} - -func TestDoHIPsOfBase(t *testing.T) { - ips := func(s ...string) (ret []netip.Addr) { - for _, ip := range s { - ret = append(ret, netip.MustParseAddr(ip)) - } - return - } - tests := []struct { - base string - want []netip.Addr - }{ - { - base: "https://cloudflare-dns.com/dns-query", - want: ips("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"), - }, - { - base: "https://dns.nextdns.io/", - want: ips(), - }, - { - base: "https://dns.nextdns.io/ff", - want: ips( - "45.90.28.0", - "45.90.30.0", - "2a07:a8c0::ff", - "2a07:a8c1::ff", - ), - }, - { - base: "https://dns.nextdns.io/c3a884", - want: ips( - "45.90.28.0", - "45.90.30.0", - "2a07:a8c0::c3:a884", - "2a07:a8c1::c3:a884", - ), - }, - { - base: "https://dns.nextdns.io/112233445566778899aabbcc", - want: ips( - "45.90.28.0", - "45.90.30.0", - "2a07:a8c0:1122:3344:5566:7788:99aa:bbcc", - "2a07:a8c1:1122:3344:5566:7788:99aa:bbcc", - ), - }, - { - base: "https://dns.nextdns.io/112233445566778899aabbccdd", - want: ips(), // nothing; profile length is over 12 bytes - }, - { - base: "https://dns.nextdns.io/c3a884/with/more/stuff", - want: ips( - "45.90.28.0", - "45.90.30.0", - "2a07:a8c0::c3:a884", - "2a07:a8c1::c3:a884", - ), - }, - { - base: "https://dns.nextdns.io/c3a884?with=query¶ms", - want: ips( - "45.90.28.0", - "45.90.30.0", - "2a07:a8c0::c3:a884", - "2a07:a8c1::c3:a884", - ), - }, - { - base: "https://dns.controld.com/hyq3ipr2ct", - want: ips( - "76.76.2.22", - "76.76.10.22", - "2606:1a40:0:6:7b5b:5949:35ad:0", - "2606:1a40:1:6:7b5b:5949:35ad:0", - ), - }, - { - base: "https://dns.controld.com/112233445566778899aabbcc", - want: ips( - "76.76.2.22", - "76.76.10.22", - "2606:1a40:0:ffff:ffff:ffff:ffff:0", - "2606:1a40:1:ffff:ffff:ffff:ffff:0", - ), - }, - { - base: "https://dns.controld.com/hyq3ipr2ct/test-host-name", - want: ips( - "76.76.2.22", - "76.76.10.22", - "2606:1a40:0:6:7b5b:5949:35ad:0", - "2606:1a40:1:6:7b5b:5949:35ad:0", - ), - }, - } - for _, tt := range tests { - got := DoHIPsOfBase(tt.base) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("DoHIPsOfBase(%q) = %v; want %v", tt.base, got, tt.want) - } - } -} diff --git a/net/dns/recursive/recursive.go b/net/dns/recursive/recursive.go index eb23004d88190..6f6a7294fac70 100644 --- a/net/dns/recursive/recursive.go +++ b/net/dns/recursive/recursive.go @@ -15,13 +15,13 @@ import ( "time" "github.com/miekg/dns" - "tailscale.com/envknob" - "tailscale.com/net/netns" - "tailscale.com/types/logger" - "tailscale.com/util/dnsname" - "tailscale.com/util/mak" - "tailscale.com/util/multierr" - "tailscale.com/util/slicesx" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/util/slicesx" ) const ( diff --git a/net/dns/recursive/recursive_test.go b/net/dns/recursive/recursive_test.go deleted file mode 100644 index d47e4cebf70f2..0000000000000 --- a/net/dns/recursive/recursive_test.go +++ /dev/null @@ -1,742 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package recursive - -import ( - "context" - "errors" - "flag" - "fmt" - "net" - "net/netip" - "reflect" - "strings" - "testing" - "time" - - "slices" - - "github.com/miekg/dns" - "tailscale.com/envknob" - "tailscale.com/tstest" -) - -const testDomain = "tailscale.com" - -// Recursively resolving the AWS console requires being able to handle CNAMEs, -// glue records, falling back from UDP to TCP for oversize queries, and more; -// it's a great integration test for DNS resolution and they can handle the -// traffic :) -const complicatedTestDomain = "console.aws.amazon.com" - -var flagNetworkAccess = flag.Bool("enable-network-access", false, "run tests that need external network access") - -func init() { - envknob.Setenv("TS_DEBUG_RECURSIVE_DNS", "true") -} - -func newResolver(tb testing.TB) *Resolver { - clock := tstest.NewClock(tstest.ClockOpts{ - Step: 50 * time.Millisecond, - }) - return &Resolver{ - Logf: tb.Logf, - timeNow: clock.Now, - } -} - -func TestResolve(t *testing.T) { - if !*flagNetworkAccess { - t.SkipNow() - } - - ctx := context.Background() - r := newResolver(t) - addrs, minTTL, err := r.Resolve(ctx, testDomain) - if err != nil { - t.Fatal(err) - } - - t.Logf("addrs: %+v", addrs) - t.Logf("minTTL: %v", minTTL) - if len(addrs) < 1 { - t.Fatalf("expected at least one address") - } - - if minTTL <= 10*time.Second || minTTL >= 24*time.Hour { - t.Errorf("invalid minimum TTL: %v", minTTL) - } - - var has4, has6 bool - for _, addr := range addrs { - has4 = has4 || addr.Is4() - has6 = has6 || addr.Is6() - } - - if !has4 { - t.Errorf("expected at least one IPv4 address") - } - if !has6 { - t.Errorf("expected at least one IPv6 address") - } -} - -func TestResolveComplicated(t *testing.T) { - if !*flagNetworkAccess { - t.SkipNow() - } - - ctx := context.Background() - r := newResolver(t) - addrs, minTTL, err := r.Resolve(ctx, complicatedTestDomain) - if err != nil { - t.Fatal(err) - } - - t.Logf("addrs: %+v", addrs) - t.Logf("minTTL: %v", minTTL) - if len(addrs) < 1 { - t.Fatalf("expected at least one address") - } - - if minTTL <= 10*time.Second || minTTL >= 24*time.Hour { - t.Errorf("invalid minimum TTL: %v", minTTL) - } -} - -func TestResolveNoIPv6(t *testing.T) { - if !*flagNetworkAccess { - t.SkipNow() - } - - r := newResolver(t) - r.NoIPv6 = true - - addrs, _, err := r.Resolve(context.Background(), testDomain) - if err != nil { - t.Fatal(err) - } - - t.Logf("addrs: %+v", addrs) - if len(addrs) < 1 { - t.Fatalf("expected at least one address") - } - - for _, addr := range addrs { - if addr.Is6() { - t.Errorf("got unexpected IPv6 address: %v", addr) - } - } -} - -func TestResolveFallbackToTCP(t *testing.T) { - var udpCalls, tcpCalls int - hook := func(nameserver netip.Addr, network string, req *dns.Msg) (*dns.Msg, error) { - if strings.HasPrefix(network, "udp") { - t.Logf("got %q query; returning truncated result", network) - udpCalls++ - resp := &dns.Msg{} - resp.SetReply(req) - resp.Truncated = true - return resp, nil - } - - t.Logf("got %q query; returning real result", network) - tcpCalls++ - resp := &dns.Msg{} - resp.SetReply(req) - resp.Answer = append(resp.Answer, &dns.A{ - Hdr: dns.RR_Header{ - Name: req.Question[0].Name, - Rrtype: req.Question[0].Qtype, - Class: dns.ClassINET, - Ttl: 300, - }, - A: net.IPv4(1, 2, 3, 4), - }) - return resp, nil - } - - r := newResolver(t) - r.testExchangeHook = hook - - ctx := context.Background() - resp, err := r.queryNameserverProto(ctx, 0, "tailscale.com", netip.MustParseAddr("9.9.9.9"), "udp", dns.Type(dns.TypeA)) - if err != nil { - t.Fatal(err) - } - - if len(resp.Answer) < 1 { - t.Fatalf("no answers in response: %v", resp) - } - rrA, ok := resp.Answer[0].(*dns.A) - if !ok { - t.Fatalf("invalid RR type: %T", resp.Answer[0]) - } - if !rrA.A.Equal(net.IPv4(1, 2, 3, 4)) { - t.Errorf("wanted A response 1.2.3.4, got: %v", rrA.A) - } - if tcpCalls != 1 { - t.Errorf("got %d, want 1 TCP calls", tcpCalls) - } - if udpCalls != 1 { - t.Errorf("got %d, want 1 UDP calls", udpCalls) - } - - // Verify that we're cached and re-run to fetch from the cache. - if len(r.queryCache) < 1 { - t.Errorf("wanted entries in the query cache") - } - - resp2, err := r.queryNameserverProto(ctx, 0, "tailscale.com", netip.MustParseAddr("9.9.9.9"), "udp", dns.Type(dns.TypeA)) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(resp, resp2) { - t.Errorf("expected equal responses; old=%+v new=%+v", resp, resp2) - } - - // We didn't make any more network requests since we loaded from the cache. - if tcpCalls != 1 { - t.Errorf("got %d, want 1 TCP calls", tcpCalls) - } - if udpCalls != 1 { - t.Errorf("got %d, want 1 UDP calls", udpCalls) - } -} - -func dnsIPRR(name string, addr netip.Addr) dns.RR { - if addr.Is4() { - return &dns.A{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 300, - }, - A: net.IP(addr.AsSlice()), - } - } - - return &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 300, - }, - AAAA: net.IP(addr.AsSlice()), - } -} - -func cnameRR(name, target string) dns.RR { - return &dns.CNAME{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: 300, - }, - Target: target, - } -} - -func nsRR(name, target string) dns.RR { - return &dns.NS{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 300, - }, - Ns: target, - } -} - -type mockReply struct { - name string - qtype dns.Type - resp *dns.Msg -} - -type replyMock struct { - tb testing.TB - replies map[netip.Addr][]mockReply -} - -func (r *replyMock) exchangeHook(nameserver netip.Addr, network string, req *dns.Msg) (*dns.Msg, error) { - if len(req.Question) != 1 { - r.tb.Fatalf("unsupported multiple or empty question: %v", req.Question) - } - question := req.Question[0] - - replies := r.replies[nameserver] - if len(replies) == 0 { - r.tb.Fatalf("no configured replies for nameserver: %v", nameserver) - } - - for _, reply := range replies { - if reply.name == question.Name && reply.qtype == dns.Type(question.Qtype) { - return reply.resp.Copy(), nil - } - } - - r.tb.Fatalf("no replies found for query %q of type %v to %v", question.Name, question.Qtype, nameserver) - panic("unreachable") -} - -// responses for mocking, shared between the following tests -var ( - rootServerAddr = netip.MustParseAddr("198.41.0.4") // a.root-servers.net. - comNSAddr = netip.MustParseAddr("192.5.6.30") // a.gtld-servers.net. - - // DNS response from the root nameservers for a .com nameserver - comRecord = &dns.Msg{ - Ns: []dns.RR{nsRR("com.", "a.gtld-servers.net.")}, - Extra: []dns.RR{dnsIPRR("a.gtld-servers.net.", comNSAddr)}, - } - - // Random Amazon nameservers that we use in glue records - amazonNS = netip.MustParseAddr("205.251.192.197") - amazonNSv6 = netip.MustParseAddr("2600:9000:5306:1600::1") - - // Nameservers for the tailscale.com domain - tailscaleNameservers = &dns.Msg{ - Ns: []dns.RR{ - nsRR("tailscale.com.", "ns-197.awsdns-24.com."), - nsRR("tailscale.com.", "ns-557.awsdns-05.net."), - nsRR("tailscale.com.", "ns-1558.awsdns-02.co.uk."), - nsRR("tailscale.com.", "ns-1359.awsdns-41.org."), - }, - Extra: []dns.RR{ - dnsIPRR("ns-197.awsdns-24.com.", amazonNS), - }, - } -) - -func TestBasicRecursion(t *testing.T) { - mock := &replyMock{ - tb: t, - replies: map[netip.Addr][]mockReply{ - // Query to the root server returns the .com server + a glue record - rootServerAddr: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: comRecord}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: comRecord}, - }, - - // Query to the ".com" server return the nameservers for tailscale.com - comNSAddr: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: tailscaleNameservers}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: tailscaleNameservers}, - }, - - // Query to the actual nameserver works. - amazonNS: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{ - dnsIPRR("tailscale.com.", netip.MustParseAddr("13.248.141.131")), - dnsIPRR("tailscale.com.", netip.MustParseAddr("76.223.15.28")), - }, - }}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{ - dnsIPRR("tailscale.com.", netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b")), - dnsIPRR("tailscale.com.", netip.MustParseAddr("2600:9000:a51d:27c1:1530:b9ef:2a6:b9e5")), - }, - }}, - }, - }, - } - - r := newResolver(t) - r.testExchangeHook = mock.exchangeHook - r.rootServers = []netip.Addr{rootServerAddr} - - // Query for tailscale.com, verify we get the right responses - ctx := context.Background() - addrs, minTTL, err := r.Resolve(ctx, "tailscale.com") - if err != nil { - t.Fatal(err) - } - wantAddrs := []netip.Addr{ - netip.MustParseAddr("13.248.141.131"), - netip.MustParseAddr("76.223.15.28"), - netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"), - netip.MustParseAddr("2600:9000:a51d:27c1:1530:b9ef:2a6:b9e5"), - } - slices.SortFunc(addrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) }) - slices.SortFunc(wantAddrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) }) - - if !reflect.DeepEqual(addrs, wantAddrs) { - t.Errorf("got addrs=%+v; want %+v", addrs, wantAddrs) - } - - const wantMinTTL = 5 * time.Minute - if minTTL != wantMinTTL { - t.Errorf("got minTTL=%+v; want %+v", minTTL, wantMinTTL) - } -} - -func TestNoAnswers(t *testing.T) { - mock := &replyMock{ - tb: t, - replies: map[netip.Addr][]mockReply{ - // Query to the root server returns the .com server + a glue record - rootServerAddr: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: comRecord}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: comRecord}, - }, - - // Query to the ".com" server return the nameservers for tailscale.com - comNSAddr: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: tailscaleNameservers}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: tailscaleNameservers}, - }, - - // Query to the actual nameserver returns no responses, authoritatively. - amazonNS: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{}, - }}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{}, - }}, - }, - }, - } - - r := &Resolver{ - Logf: t.Logf, - testExchangeHook: mock.exchangeHook, - rootServers: []netip.Addr{rootServerAddr}, - } - - // Query for tailscale.com, verify we get the right responses - _, _, err := r.Resolve(context.Background(), "tailscale.com") - if err == nil { - t.Fatalf("got no error, want error") - } - if !errors.Is(err, ErrAuthoritativeNoResponses) { - t.Fatalf("got err=%v, want %v", err, ErrAuthoritativeNoResponses) - } -} - -func TestRecursionCNAME(t *testing.T) { - mock := &replyMock{ - tb: t, - replies: map[netip.Addr][]mockReply{ - // Query to the root server returns the .com server + a glue record - rootServerAddr: { - {name: "subdomain.otherdomain.com.", qtype: dns.Type(dns.TypeA), resp: comRecord}, - {name: "subdomain.otherdomain.com.", qtype: dns.Type(dns.TypeAAAA), resp: comRecord}, - - {name: "subdomain.tailscale.com.", qtype: dns.Type(dns.TypeA), resp: comRecord}, - {name: "subdomain.tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: comRecord}, - }, - - // Query to the ".com" server return the nameservers for tailscale.com - comNSAddr: { - {name: "subdomain.otherdomain.com.", qtype: dns.Type(dns.TypeA), resp: tailscaleNameservers}, - {name: "subdomain.otherdomain.com.", qtype: dns.Type(dns.TypeAAAA), resp: tailscaleNameservers}, - - {name: "subdomain.tailscale.com.", qtype: dns.Type(dns.TypeA), resp: tailscaleNameservers}, - {name: "subdomain.tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: tailscaleNameservers}, - }, - - // Query to the actual nameserver works. - amazonNS: { - {name: "subdomain.otherdomain.com.", qtype: dns.Type(dns.TypeA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{cnameRR("subdomain.otherdomain.com.", "subdomain.tailscale.com.")}, - }}, - {name: "subdomain.otherdomain.com.", qtype: dns.Type(dns.TypeAAAA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{cnameRR("subdomain.otherdomain.com.", "subdomain.tailscale.com.")}, - }}, - - {name: "subdomain.tailscale.com.", qtype: dns.Type(dns.TypeA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{dnsIPRR("tailscale.com.", netip.MustParseAddr("13.248.141.131"))}, - }}, - {name: "subdomain.tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{dnsIPRR("tailscale.com.", netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"))}, - }}, - }, - }, - } - - r := &Resolver{ - Logf: t.Logf, - testExchangeHook: mock.exchangeHook, - rootServers: []netip.Addr{rootServerAddr}, - } - - // Query for tailscale.com, verify we get the right responses - addrs, minTTL, err := r.Resolve(context.Background(), "subdomain.otherdomain.com") - if err != nil { - t.Fatal(err) - } - wantAddrs := []netip.Addr{ - netip.MustParseAddr("13.248.141.131"), - netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"), - } - slices.SortFunc(addrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) }) - slices.SortFunc(wantAddrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) }) - - if !reflect.DeepEqual(addrs, wantAddrs) { - t.Errorf("got addrs=%+v; want %+v", addrs, wantAddrs) - } - - const wantMinTTL = 5 * time.Minute - if minTTL != wantMinTTL { - t.Errorf("got minTTL=%+v; want %+v", minTTL, wantMinTTL) - } -} - -func TestRecursionNoGlue(t *testing.T) { - coukNS := netip.MustParseAddr("213.248.216.1") - coukRecord := &dns.Msg{ - Ns: []dns.RR{nsRR("com.", "dns1.nic.uk.")}, - Extra: []dns.RR{dnsIPRR("dns1.nic.uk.", coukNS)}, - } - - intermediateNS := netip.MustParseAddr("205.251.193.66") // g-ns-322.awsdns-02.co.uk. - intermediateRecord := &dns.Msg{ - Ns: []dns.RR{nsRR("awsdns-02.co.uk.", "g-ns-322.awsdns-02.co.uk.")}, - Extra: []dns.RR{dnsIPRR("g-ns-322.awsdns-02.co.uk.", intermediateNS)}, - } - - const amazonNameserver = "ns-1558.awsdns-02.co.uk." - tailscaleNameservers := &dns.Msg{ - Ns: []dns.RR{ - nsRR("tailscale.com.", amazonNameserver), - }, - } - - tailscaleResponses := []mockReply{ - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{dnsIPRR("tailscale.com.", netip.MustParseAddr("13.248.141.131"))}, - }}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{dnsIPRR("tailscale.com.", netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"))}, - }}, - } - - mock := &replyMock{ - tb: t, - replies: map[netip.Addr][]mockReply{ - rootServerAddr: { - // Query to the root server returns the .com server + a glue record - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: comRecord}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: comRecord}, - - // Querying the .co.uk nameserver returns the .co.uk nameserver + a glue record. - {name: amazonNameserver, qtype: dns.Type(dns.TypeA), resp: coukRecord}, - {name: amazonNameserver, qtype: dns.Type(dns.TypeAAAA), resp: coukRecord}, - }, - - // Queries to the ".com" server return the nameservers - // for tailscale.com, which don't contain a glue - // record. - comNSAddr: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: tailscaleNameservers}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: tailscaleNameservers}, - }, - - // Queries to the ".co.uk" nameserver returns the - // address of the intermediate Amazon nameserver. - coukNS: { - {name: amazonNameserver, qtype: dns.Type(dns.TypeA), resp: intermediateRecord}, - {name: amazonNameserver, qtype: dns.Type(dns.TypeAAAA), resp: intermediateRecord}, - }, - - // Queries to the intermediate nameserver returns an - // answer for the final Amazon nameserver. - intermediateNS: { - {name: amazonNameserver, qtype: dns.Type(dns.TypeA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{dnsIPRR(amazonNameserver, amazonNS)}, - }}, - {name: amazonNameserver, qtype: dns.Type(dns.TypeAAAA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{dnsIPRR(amazonNameserver, amazonNSv6)}, - }}, - }, - - // Queries to the actual nameserver work and return - // responses to the query. - amazonNS: tailscaleResponses, - amazonNSv6: tailscaleResponses, - }, - } - - r := newResolver(t) - r.testExchangeHook = mock.exchangeHook - r.rootServers = []netip.Addr{rootServerAddr} - - // Query for tailscale.com, verify we get the right responses - addrs, minTTL, err := r.Resolve(context.Background(), "tailscale.com") - if err != nil { - t.Fatal(err) - } - wantAddrs := []netip.Addr{ - netip.MustParseAddr("13.248.141.131"), - netip.MustParseAddr("2600:9000:a602:b1e6:86d:8165:5e8c:295b"), - } - slices.SortFunc(addrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) }) - slices.SortFunc(wantAddrs, func(x, y netip.Addr) int { return strings.Compare(x.String(), y.String()) }) - - if !reflect.DeepEqual(addrs, wantAddrs) { - t.Errorf("got addrs=%+v; want %+v", addrs, wantAddrs) - } - - const wantMinTTL = 5 * time.Minute - if minTTL != wantMinTTL { - t.Errorf("got minTTL=%+v; want %+v", minTTL, wantMinTTL) - } -} - -func TestRecursionLimit(t *testing.T) { - mock := &replyMock{ - tb: t, - replies: map[netip.Addr][]mockReply{}, - } - - // Fill out a CNAME chain equal to our recursion limit; we won't get - // this far since each CNAME is more than 1 level "deep", but this - // ensures that we have more than the limit. - for i := range maxDepth + 1 { - curr := fmt.Sprintf("%d-tailscale.com.", i) - - tailscaleNameservers := &dns.Msg{ - Ns: []dns.RR{nsRR(curr, "ns-197.awsdns-24.com.")}, - Extra: []dns.RR{dnsIPRR("ns-197.awsdns-24.com.", amazonNS)}, - } - - // Query to the root server returns the .com server + a glue record - mock.replies[rootServerAddr] = append(mock.replies[rootServerAddr], - mockReply{name: curr, qtype: dns.Type(dns.TypeA), resp: comRecord}, - mockReply{name: curr, qtype: dns.Type(dns.TypeAAAA), resp: comRecord}, - ) - - // Query to the ".com" server return the nameservers for NN-tailscale.com - mock.replies[comNSAddr] = append(mock.replies[comNSAddr], - mockReply{name: curr, qtype: dns.Type(dns.TypeA), resp: tailscaleNameservers}, - mockReply{name: curr, qtype: dns.Type(dns.TypeAAAA), resp: tailscaleNameservers}, - ) - - // Queries to the nameserver return a CNAME for the n+1th server. - next := fmt.Sprintf("%d-tailscale.com.", i+1) - mock.replies[amazonNS] = append(mock.replies[amazonNS], - mockReply{ - name: curr, - qtype: dns.Type(dns.TypeA), - resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{cnameRR(curr, next)}, - }, - }, - mockReply{ - name: curr, - qtype: dns.Type(dns.TypeAAAA), - resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{cnameRR(curr, next)}, - }, - }, - ) - } - - r := newResolver(t) - r.testExchangeHook = mock.exchangeHook - r.rootServers = []netip.Addr{rootServerAddr} - - // Query for the first node in the chain, 0-tailscale.com, and verify - // we get a max-depth error. - ctx := context.Background() - _, _, err := r.Resolve(ctx, "0-tailscale.com") - if err == nil { - t.Fatal("expected error, got nil") - } else if !errors.Is(err, ErrMaxDepth) { - t.Fatalf("got err=%v, want ErrMaxDepth", err) - } -} - -func TestInvalidResponses(t *testing.T) { - mock := &replyMock{ - tb: t, - replies: map[netip.Addr][]mockReply{ - // Query to the root server returns the .com server + a glue record - rootServerAddr: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: comRecord}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: comRecord}, - }, - - // Query to the ".com" server return the nameservers for tailscale.com - comNSAddr: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: tailscaleNameservers}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: tailscaleNameservers}, - }, - - // Query to the actual nameserver returns an invalid IP address - amazonNS: { - {name: "tailscale.com.", qtype: dns.Type(dns.TypeA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - Answer: []dns.RR{&dns.A{ - Hdr: dns.RR_Header{ - Name: "tailscale.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 300, - }, - // Note: this is an IPv6 addr in an IPv4 response - A: net.IP(netip.MustParseAddr("2600:9000:a51d:27c1:1530:b9ef:2a6:b9e5").AsSlice()), - }}, - }}, - {name: "tailscale.com.", qtype: dns.Type(dns.TypeAAAA), resp: &dns.Msg{ - MsgHdr: dns.MsgHdr{Authoritative: true}, - // This an IPv4 response to an IPv6 query - Answer: []dns.RR{&dns.A{ - Hdr: dns.RR_Header{ - Name: "tailscale.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 300, - }, - A: net.IP(netip.MustParseAddr("13.248.141.131").AsSlice()), - }}, - }}, - }, - }, - } - - r := &Resolver{ - Logf: t.Logf, - testExchangeHook: mock.exchangeHook, - rootServers: []netip.Addr{rootServerAddr}, - } - - // Query for tailscale.com, verify we get no responses since the - // addresses are invalid. - _, _, err := r.Resolve(context.Background(), "tailscale.com") - if err == nil { - t.Fatalf("got no error, want error") - } - if !errors.Is(err, ErrAuthoritativeNoResponses) { - t.Fatalf("got err=%v, want %v", err, ErrAuthoritativeNoResponses) - } -} - -// TODO(andrew): test for more edge cases that aren't currently covered: -// * Nameservers that cross between IPv4 and IPv6 -// * Authoritative no replies after following CNAME -// * Authoritative no replies after following non-glue NS record -// * Error querying non-glue NS record followed by success diff --git a/net/dns/resolvconffile/resolvconffile.go b/net/dns/resolvconffile/resolvconffile.go index 753000f6d33da..1e1274a399563 100644 --- a/net/dns/resolvconffile/resolvconffile.go +++ b/net/dns/resolvconffile/resolvconffile.go @@ -19,7 +19,7 @@ import ( "os" "strings" - "tailscale.com/util/dnsname" + "github.com/sagernet/tailscale/util/dnsname" ) // Path is the canonical location of resolv.conf. diff --git a/net/dns/resolvconffile/resolvconffile_test.go b/net/dns/resolvconffile/resolvconffile_test.go deleted file mode 100644 index 4f5ddd599899a..0000000000000 --- a/net/dns/resolvconffile/resolvconffile_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package resolvconffile - -import ( - "net/netip" - "reflect" - "strings" - "testing" - - "tailscale.com/util/dnsname" -) - -func TestParse(t *testing.T) { - tests := []struct { - in string - want *Config - wantErr bool - }{ - {in: `nameserver 192.168.0.100`, - want: &Config{ - Nameservers: []netip.Addr{ - netip.MustParseAddr("192.168.0.100"), - }, - }, - }, - {in: `nameserver 192.168.0.100 # comment`, - want: &Config{ - Nameservers: []netip.Addr{ - netip.MustParseAddr("192.168.0.100"), - }, - }, - }, - {in: `nameserver 192.168.0.100#`, - want: &Config{ - Nameservers: []netip.Addr{ - netip.MustParseAddr("192.168.0.100"), - }, - }, - }, - {in: `nameserver #192.168.0.100`, wantErr: true}, - {in: `nameserver`, wantErr: true}, - {in: `# nameserver 192.168.0.100`, want: &Config{}}, - {in: `nameserver192.168.0.100`, wantErr: true}, - - {in: `search tailscale.com`, - want: &Config{ - SearchDomains: []dnsname.FQDN{"tailscale.com."}, - }, - }, - {in: `search tailscale.com # comment`, - want: &Config{ - SearchDomains: []dnsname.FQDN{"tailscale.com."}, - }, - }, - {in: `searchtailscale.com`, wantErr: true}, - {in: `search`, wantErr: true}, - - // Issue 6875: there can be multiple search domains, and even if they're - // over 253 bytes long total. - { - in: "search search-01.example search-02.example search-03.example search-04.example search-05.example search-06.example search-07.example search-08.example search-09.example search-10.example search-11.example search-12.example search-13.example search-14.example search-15.example\n", - want: &Config{ - SearchDomains: []dnsname.FQDN{ - "search-01.example.", - "search-02.example.", - "search-03.example.", - "search-04.example.", - "search-05.example.", - "search-06.example.", - "search-07.example.", - "search-08.example.", - "search-09.example.", - "search-10.example.", - "search-11.example.", - "search-12.example.", - "search-13.example.", - "search-14.example.", - "search-15.example.", - }, - }, - }, - } - - for _, tt := range tests { - cfg, err := Parse(strings.NewReader(tt.in)) - if tt.wantErr { - if err != nil { - continue - } - t.Errorf("missing error for %q", tt.in) - continue - } - if err != nil { - t.Errorf("unexpected error for %q: %v", tt.in, err) - continue - } - if !reflect.DeepEqual(cfg, tt.want) { - t.Errorf("got: %v\nwant: %v\n", cfg, tt.want) - } - } -} diff --git a/net/dns/resolvd.go b/net/dns/resolvd.go index ad1a99c111997..c030cf91e63f9 100644 --- a/net/dns/resolvd.go +++ b/net/dns/resolvd.go @@ -12,8 +12,8 @@ import ( "regexp" "strings" - "tailscale.com/net/dns/resolvconffile" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/dns/resolvconffile" + "github.com/sagernet/tailscale/types/logger" ) func newResolvdManager(logf logger.Logf, interfaceName string) (*resolvdManager, error) { diff --git a/net/dns/resolved.go b/net/dns/resolved.go index 1a7c8604101db..ccb1ff8d4d593 100644 --- a/net/dns/resolved.go +++ b/net/dns/resolved.go @@ -13,11 +13,11 @@ import ( "time" "github.com/godbus/dbus/v5" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/logtail/backoff" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/dnsname" "golang.org/x/sys/unix" - "tailscale.com/health" - "tailscale.com/logtail/backoff" - "tailscale.com/types/logger" - "tailscale.com/util/dnsname" ) // DBus entities we talk to. diff --git a/net/dns/resolver/debug.go b/net/dns/resolver/debug.go index da195d49d41e5..548c8a72be139 100644 --- a/net/dns/resolver/debug.go +++ b/net/dns/resolver/debug.go @@ -12,7 +12,7 @@ import ( "sync/atomic" "time" - "tailscale.com/health" + "github.com/sagernet/tailscale/health" ) func init() { diff --git a/net/dns/resolver/doh_test.go b/net/dns/resolver/doh_test.go deleted file mode 100644 index a9c28476166fc..0000000000000 --- a/net/dns/resolver/doh_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package resolver - -import ( - "context" - "flag" - "net/http" - "testing" - - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/net/dns/publicdns" -) - -var testDoH = flag.Bool("test-doh", false, "do real DoH tests against the network") - -const someDNSID = 123 // something non-zero as a test; in violation of spec's SHOULD of 0 - -func someDNSQuestion(t testing.TB) []byte { - b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ - OpCode: 0, // query - RecursionDesired: true, - ID: someDNSID, - }) - b.StartQuestions() // err - b.Question(dnsmessage.Question{ - Name: dnsmessage.MustNewName("tailscale.com."), - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - }) - msg, err := b.Finish() - if err != nil { - t.Fatal(err) - } - return msg -} - -func TestDoH(t *testing.T) { - if !*testDoH { - t.Skip("skipping manual test without --test-doh flag") - } - prefixes := publicdns.KnownDoHPrefixes() - if len(prefixes) == 0 { - t.Fatal("no known DoH") - } - - f := &forwarder{} - - for _, urlBase := range prefixes { - t.Run(urlBase, func(t *testing.T) { - c, ok := f.getKnownDoHClientForProvider(urlBase) - if !ok { - t.Fatal("expected DoH") - } - res, err := f.sendDoH(context.Background(), urlBase, c, someDNSQuestion(t)) - if err != nil { - t.Fatal(err) - } - c.Transport.(*http.Transport).CloseIdleConnections() - - var p dnsmessage.Parser - h, err := p.Start(res) - if err != nil { - t.Fatal(err) - } - if h.ID != someDNSID { - t.Errorf("response DNS ID = %v; want %v", h.ID, someDNSID) - } - - p.SkipAllQuestions() - aa, err := p.AllAnswers() - if err != nil { - t.Fatal(err) - } - if len(aa) == 0 { - t.Fatal("no answers") - } - for _, r := range aa { - t.Logf("got: %v", r.GoString()) - } - }) - } -} - -func TestDoHV6Fallback(t *testing.T) { - for _, base := range publicdns.KnownDoHPrefixes() { - for _, ip := range publicdns.DoHIPsOfBase(base) { - if ip.Is4() { - ip6, ok := publicdns.DoHV6(base) - if !ok { - t.Errorf("no v6 DoH known for %v", ip) - } else if !ip6.Is6() { - t.Errorf("dohV6(%q) returned non-v6 address %v", base, ip6) - } - } - } - } -} diff --git a/net/dns/resolver/forwarder.go b/net/dns/resolver/forwarder.go index c00dea1aea8c4..c8f839d795ff7 100644 --- a/net/dns/resolver/forwarder.go +++ b/net/dns/resolver/forwarder.go @@ -23,23 +23,23 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dns/publicdns" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/neterror" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/util/cloudenv" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/race" + "github.com/sagernet/tailscale/version" dns "golang.org/x/net/dns/dnsmessage" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/dns/publicdns" - "tailscale.com/net/dnscache" - "tailscale.com/net/neterror" - "tailscale.com/net/netmon" - "tailscale.com/net/sockstats" - "tailscale.com/net/tsdial" - "tailscale.com/types/dnstype" - "tailscale.com/types/logger" - "tailscale.com/types/nettype" - "tailscale.com/util/cloudenv" - "tailscale.com/util/dnsname" - "tailscale.com/util/race" - "tailscale.com/version" ) // headerBytes is the number of bytes in a DNS message header. diff --git a/net/dns/resolver/forwarder_test.go b/net/dns/resolver/forwarder_test.go deleted file mode 100644 index f3e592d4f1993..0000000000000 --- a/net/dns/resolver/forwarder_test.go +++ /dev/null @@ -1,878 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package resolver - -import ( - "bytes" - "context" - "encoding/binary" - "flag" - "fmt" - "io" - "net" - "net/netip" - "os" - "reflect" - "slices" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - dns "golang.org/x/net/dns/dnsmessage" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/tstest" - "tailscale.com/types/dnstype" -) - -func (rr resolverAndDelay) String() string { - return fmt.Sprintf("%v+%v", rr.name, rr.startDelay) -} - -func TestResolversWithDelays(t *testing.T) { - // query - q := func(ss ...string) (ipps []*dnstype.Resolver) { - for _, host := range ss { - ipps = append(ipps, &dnstype.Resolver{Addr: host}) - } - return - } - // output - o := func(ss ...string) (rr []resolverAndDelay) { - for _, s := range ss { - var d time.Duration - s, durStr, hasPlus := strings.Cut(s, "+") - if hasPlus { - var err error - d, err = time.ParseDuration(durStr) - if err != nil { - panic(fmt.Sprintf("parsing duration in %q: %v", s, err)) - } - } - rr = append(rr, resolverAndDelay{ - name: &dnstype.Resolver{Addr: s}, - startDelay: d, - }) - } - return - } - - tests := []struct { - name string - in []*dnstype.Resolver - want []resolverAndDelay - }{ - { - name: "unknown-no-delays", - in: q("1.2.3.4", "2.3.4.5"), - want: o("1.2.3.4", "2.3.4.5"), - }, - { - name: "google-all-ipv4", - in: q("8.8.8.8", "8.8.4.4"), - want: o("https://dns.google/dns-query", "8.8.8.8+0.5s", "8.8.4.4+0.7s"), - }, - { - name: "google-only-ipv6", - in: q("2001:4860:4860::8888", "2001:4860:4860::8844"), - want: o("https://dns.google/dns-query", "2001:4860:4860::8888+0.5s", "2001:4860:4860::8844+0.7s"), - }, - { - name: "google-all-four", - in: q("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844"), - want: o("https://dns.google/dns-query", "8.8.8.8+0.5s", "8.8.4.4+0.7s", "2001:4860:4860::8888+0.5s", "2001:4860:4860::8844+0.7s"), - }, - { - name: "quad9-one-v4-one-v6", - in: q("9.9.9.9", "2620:fe::fe"), - want: o("https://dns.quad9.net/dns-query", "9.9.9.9+0.5s", "2620:fe::fe+0.5s"), - }, - { - name: "nextdns-ipv6-expand", - in: q("2a07:a8c0::c3:a884"), - want: o("https://dns.nextdns.io/c3a884"), - }, - { - name: "nextdns-doh-input", - in: q("https://dns.nextdns.io/c3a884"), - want: o("https://dns.nextdns.io/c3a884"), - }, - { - name: "controld-ipv6-expand", - in: q("2606:1a40:0:6:7b5b:5949:35ad:0"), - want: o("https://dns.controld.com/hyq3ipr2ct"), - }, - { - name: "controld-doh-input", - in: q("https://dns.controld.com/hyq3ipr2ct"), - want: o("https://dns.controld.com/hyq3ipr2ct"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := resolversWithDelays(tt.in) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } - -} - -func TestGetRCode(t *testing.T) { - tests := []struct { - name string - packet []byte - want dns.RCode - }{ - { - name: "empty", - packet: []byte{}, - want: dns.RCode(5), - }, - { - name: "too-short", - packet: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - want: dns.RCode(5), - }, - { - name: "noerror", - packet: []byte{0xC4, 0xFE, 0x81, 0xA0, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01}, - want: dns.RCode(0), - }, - { - name: "refused", - packet: []byte{0xee, 0xa1, 0x81, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, - want: dns.RCode(5), - }, - { - name: "nxdomain", - packet: []byte{0x34, 0xf4, 0x81, 0x83, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01}, - want: dns.RCode(3), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := getRCode(tt.packet) - if got != tt.want { - t.Errorf("got %d; want %d", got, tt.want) - } - }) - } -} - -var testDNS = flag.Bool("test-dns", false, "run tests that require a working DNS server") - -func TestGetKnownDoHClientForProvider(t *testing.T) { - var fwd forwarder - c, ok := fwd.getKnownDoHClientForProvider("https://dns.google/dns-query") - if !ok { - t.Fatal("not found") - } - if !*testDNS { - t.Skip("skipping without --test-dns") - } - res, err := c.Head("https://dns.google/") - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - t.Logf("Got: %+v", res) -} - -func BenchmarkNameFromQuery(b *testing.B) { - builder := dns.NewBuilder(nil, dns.Header{}) - builder.StartQuestions() - builder.Question(dns.Question{ - Name: dns.MustNewName("foo.example."), - Type: dns.TypeA, - Class: dns.ClassINET, - }) - msg, err := builder.Finish() - if err != nil { - b.Fatal(err) - } - b.ResetTimer() - b.ReportAllocs() - for range b.N { - _, _, err := nameFromQuery(msg) - if err != nil { - b.Fatal(err) - } - } -} - -// Reproduces https://github.com/tailscale/tailscale/issues/2533 -// Fixed by https://github.com/tailscale/tailscale/commit/f414a9cc01f3264912513d07c0244ff4f3e4ba54 -// -// NOTE: fuzz tests act like unit tests when run without `-fuzz` -func FuzzClampEDNSSize(f *testing.F) { - // Empty DNS packet - f.Add([]byte{ - // query id - 0x12, 0x34, - // flags: standard query, recurse - 0x01, 0x20, - // num questions - 0x00, 0x00, - // num answers - 0x00, 0x00, - // num authority RRs - 0x00, 0x00, - // num additional RRs - 0x00, 0x00, - }) - - // Empty OPT - f.Add([]byte{ - // header - 0xaf, 0x66, 0x01, 0x20, 0x00, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x01, - // query - 0x06, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x03, 0x63, 0x6f, - 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, - // OPT - 0x00, // name: - 0x00, 0x29, // type: OPT - 0x10, 0x00, // UDP payload size - 0x00, // higher bits in extended RCODE - 0x00, // EDNS0 version - 0x80, 0x00, // "Z" field - 0x00, 0x00, // data length - }) - - // Query for "google.com" - f.Add([]byte{ - // header - 0xaf, 0x66, 0x01, 0x20, 0x00, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x01, - // query - 0x06, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x03, 0x63, 0x6f, - 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, - // OPT - 0x00, 0x00, 0x29, 0x10, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, - 0x0c, 0x00, 0x0a, 0x00, 0x08, 0x62, 0x18, 0x1a, 0xcb, 0x19, - 0xd7, 0xee, 0x23, - }) - - f.Fuzz(func(t *testing.T, data []byte) { - clampEDNSSize(data, maxResponseBytes) - }) -} - -type testDNSServerOptions struct { - SkipUDP bool - SkipTCP bool -} - -func runDNSServer(tb testing.TB, opts *testDNSServerOptions, response []byte, onRequest func(bool, []byte)) (port uint16) { - if opts != nil && opts.SkipUDP && opts.SkipTCP { - tb.Fatal("cannot skip both UDP and TCP servers") - } - - logf := tstest.WhileTestRunningLogger(tb) - - tcpResponse := make([]byte, len(response)+2) - binary.BigEndian.PutUint16(tcpResponse, uint16(len(response))) - copy(tcpResponse[2:], response) - - // Repeatedly listen until we can get the same port. - const tries = 25 - var ( - tcpLn *net.TCPListener - udpLn *net.UDPConn - err error - ) - for try := 0; try < tries; try++ { - if tcpLn != nil { - tcpLn.Close() - tcpLn = nil - } - - tcpLn, err = net.ListenTCP("tcp4", &net.TCPAddr{ - IP: net.IPv4(127, 0, 0, 1), - Port: 0, // Choose one - }) - if err != nil { - tb.Fatal(err) - } - udpLn, err = net.ListenUDP("udp4", &net.UDPAddr{ - IP: net.IPv4(127, 0, 0, 1), - Port: tcpLn.Addr().(*net.TCPAddr).Port, - }) - if err == nil { - break - } - } - if tcpLn == nil || udpLn == nil { - if tcpLn != nil { - tcpLn.Close() - } - if udpLn != nil { - udpLn.Close() - } - - // Skip instead of being fatal to avoid flaking on extremely - // heavily-loaded CI systems. - tb.Skipf("failed to listen on same port for TCP/UDP after %d tries", tries) - } - - port = uint16(tcpLn.Addr().(*net.TCPAddr).Port) - - handleConn := func(conn net.Conn) { - defer conn.Close() - - // Read the length header, then the buffer - var length uint16 - if err := binary.Read(conn, binary.BigEndian, &length); err != nil { - logf("error reading length header: %v", err) - return - } - req := make([]byte, length) - n, err := io.ReadFull(conn, req) - if err != nil { - logf("error reading query: %v", err) - return - } - req = req[:n] - onRequest(true, req) - - // Write response - if _, err := conn.Write(tcpResponse); err != nil { - logf("error writing response: %v", err) - return - } - } - - var wg sync.WaitGroup - - if opts == nil || !opts.SkipTCP { - wg.Add(1) - go func() { - defer wg.Done() - for { - conn, err := tcpLn.Accept() - if err != nil { - return - } - go handleConn(conn) - } - }() - } - - handleUDP := func(addr netip.AddrPort, req []byte) { - onRequest(false, req) - if _, err := udpLn.WriteToUDPAddrPort(response, addr); err != nil { - logf("error writing response: %v", err) - } - } - - if opts == nil || !opts.SkipUDP { - wg.Add(1) - go func() { - defer wg.Done() - for { - buf := make([]byte, 65535) - n, addr, err := udpLn.ReadFromUDPAddrPort(buf) - if err != nil { - return - } - buf = buf[:n] - go handleUDP(addr, buf) - } - }() - } - - tb.Cleanup(func() { - tcpLn.Close() - udpLn.Close() - logf("waiting for listeners to finish...") - wg.Wait() - }) - return -} - -func enableDebug(tb testing.TB) { - const debugKnob = "TS_DEBUG_DNS_FORWARD_SEND" - oldVal := os.Getenv(debugKnob) - envknob.Setenv(debugKnob, "true") - tb.Cleanup(func() { envknob.Setenv(debugKnob, oldVal) }) -} - -func makeLargeResponse(tb testing.TB, domain string) (request, response []byte) { - name := dns.MustNewName(domain) - - builder := dns.NewBuilder(nil, dns.Header{Response: true}) - builder.StartQuestions() - builder.Question(dns.Question{ - Name: name, - Type: dns.TypeA, - Class: dns.ClassINET, - }) - builder.StartAnswers() - for i := range 120 { - builder.AResource(dns.ResourceHeader{ - Name: name, - Class: dns.ClassINET, - TTL: 300, - }, dns.AResource{ - A: [4]byte{127, 0, 0, byte(i)}, - }) - } - - var err error - response, err = builder.Finish() - if err != nil { - tb.Fatal(err) - } - if len(response) <= maxResponseBytes { - tb.Fatalf("got len(largeResponse)=%d, want > %d", len(response), maxResponseBytes) - } - - // Our request is a single A query for the domain in the answer, above. - builder = dns.NewBuilder(nil, dns.Header{}) - builder.StartQuestions() - builder.Question(dns.Question{ - Name: dns.MustNewName(domain), - Type: dns.TypeA, - Class: dns.ClassINET, - }) - request, err = builder.Finish() - if err != nil { - tb.Fatal(err) - } - - return -} - -func runTestQuery(tb testing.TB, request []byte, modify func(*forwarder), ports ...uint16) ([]byte, error) { - logf := tstest.WhileTestRunningLogger(tb) - netMon, err := netmon.New(logf) - if err != nil { - tb.Fatal(err) - } - - var dialer tsdial.Dialer - dialer.SetNetMon(netMon) - - fwd := newForwarder(logf, netMon, nil, &dialer, new(health.Tracker), nil) - if modify != nil { - modify(fwd) - } - - resolvers := make([]resolverAndDelay, len(ports)) - for i, port := range ports { - resolvers[i].name = &dnstype.Resolver{Addr: fmt.Sprintf("127.0.0.1:%d", port)} - } - - rpkt := packet{ - bs: request, - family: "tcp", - addr: netip.MustParseAddrPort("127.0.0.1:12345"), - } - - rchan := make(chan packet, 1) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - tb.Cleanup(cancel) - err = fwd.forwardWithDestChan(ctx, rpkt, rchan, resolvers...) - select { - case res := <-rchan: - return res.bs, err - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -// makeTestRequest returns a new TypeA request for the given domain. -func makeTestRequest(tb testing.TB, domain string) []byte { - tb.Helper() - name := dns.MustNewName(domain) - builder := dns.NewBuilder(nil, dns.Header{}) - builder.StartQuestions() - builder.Question(dns.Question{ - Name: name, - Type: dns.TypeA, - Class: dns.ClassINET, - }) - request, err := builder.Finish() - if err != nil { - tb.Fatal(err) - } - return request -} - -// makeTestResponse returns a new Type A response for the given domain, -// with the specified status code and zero or more addresses. -func makeTestResponse(tb testing.TB, domain string, code dns.RCode, addrs ...netip.Addr) []byte { - tb.Helper() - name := dns.MustNewName(domain) - builder := dns.NewBuilder(nil, dns.Header{ - Response: true, - Authoritative: true, - RCode: code, - }) - builder.StartQuestions() - q := dns.Question{ - Name: name, - Type: dns.TypeA, - Class: dns.ClassINET, - } - builder.Question(q) - if len(addrs) > 0 { - builder.StartAnswers() - for _, addr := range addrs { - builder.AResource(dns.ResourceHeader{ - Name: q.Name, - Class: q.Class, - TTL: 120, - }, dns.AResource{ - A: addr.As4(), - }) - } - } - response, err := builder.Finish() - if err != nil { - tb.Fatal(err) - } - return response -} - -func mustRunTestQuery(tb testing.TB, request []byte, modify func(*forwarder), ports ...uint16) []byte { - resp, err := runTestQuery(tb, request, modify, ports...) - if err != nil { - tb.Fatalf("error making request: %v", err) - } - return resp -} - -func TestForwarderTCPFallback(t *testing.T) { - enableDebug(t) - - const domain = "large-dns-response.tailscale.com." - - // Make a response that's very large, containing a bunch of localhost addresses. - request, largeResponse := makeLargeResponse(t, domain) - - var sawTCPRequest atomic.Bool - port := runDNSServer(t, nil, largeResponse, func(isTCP bool, gotRequest []byte) { - if isTCP { - t.Logf("saw TCP request") - sawTCPRequest.Store(true) - } else { - t.Logf("saw UDP request") - } - - if !bytes.Equal(request, gotRequest) { - t.Errorf("invalid request\ngot: %+v\nwant: %+v", gotRequest, request) - } - }) - - resp := mustRunTestQuery(t, request, nil, port) - if !bytes.Equal(resp, largeResponse) { - t.Errorf("invalid response\ngot: %+v\nwant: %+v", resp, largeResponse) - } - if !sawTCPRequest.Load() { - t.Errorf("DNS server never saw TCP request") - } - - // NOTE: can't assert that we see a UDP request here since we might - // race and run the TCP query first. We test the UDP codepath in - // TestForwarderTCPFallbackDisabled below, though. -} - -// Test to ensure that if the UDP listener is unresponsive, we always make a -// TCP request even if we never get a response. -func TestForwarderTCPFallbackTimeout(t *testing.T) { - enableDebug(t) - - const domain = "large-dns-response.tailscale.com." - - // Make a response that's very large, containing a bunch of localhost addresses. - request, largeResponse := makeLargeResponse(t, domain) - - var sawTCPRequest atomic.Bool - opts := &testDNSServerOptions{SkipUDP: true} - port := runDNSServer(t, opts, largeResponse, func(isTCP bool, gotRequest []byte) { - if isTCP { - t.Logf("saw TCP request") - sawTCPRequest.Store(true) - } else { - t.Error("saw unexpected UDP request") - } - - if !bytes.Equal(request, gotRequest) { - t.Errorf("invalid request\ngot: %+v\nwant: %+v", gotRequest, request) - } - }) - - resp := mustRunTestQuery(t, request, nil, port) - if !bytes.Equal(resp, largeResponse) { - t.Errorf("invalid response\ngot: %+v\nwant: %+v", resp, largeResponse) - } - if !sawTCPRequest.Load() { - t.Errorf("DNS server never saw TCP request") - } -} - -func TestForwarderTCPFallbackDisabled(t *testing.T) { - enableDebug(t) - - const domain = "large-dns-response.tailscale.com." - - // Make a response that's very large, containing a bunch of localhost addresses. - request, largeResponse := makeLargeResponse(t, domain) - - var sawUDPRequest atomic.Bool - port := runDNSServer(t, nil, largeResponse, func(isTCP bool, gotRequest []byte) { - if isTCP { - t.Error("saw unexpected TCP request") - } else { - t.Logf("saw UDP request") - sawUDPRequest.Store(true) - } - - if !bytes.Equal(request, gotRequest) { - t.Errorf("invalid request\ngot: %+v\nwant: %+v", gotRequest, request) - } - }) - - resp := mustRunTestQuery(t, request, func(fwd *forwarder) { - // Disable retries for this test. - fwd.controlKnobs = &controlknobs.Knobs{} - fwd.controlKnobs.DisableDNSForwarderTCPRetries.Store(true) - }, port) - - wantResp := append([]byte(nil), largeResponse[:maxResponseBytes]...) - - // Set the truncated flag on the expected response, since that's what we expect. - flags := binary.BigEndian.Uint16(wantResp[2:4]) - flags |= dnsFlagTruncated - binary.BigEndian.PutUint16(wantResp[2:4], flags) - - if !bytes.Equal(resp, wantResp) { - t.Errorf("invalid response\ngot (%d): %+v\nwant (%d): %+v", len(resp), resp, len(wantResp), wantResp) - } - if !sawUDPRequest.Load() { - t.Errorf("DNS server never saw UDP request") - } -} - -// Test to ensure that we propagate DNS errors -func TestForwarderTCPFallbackError(t *testing.T) { - enableDebug(t) - - const domain = "error-response.tailscale.com." - - // Our response is a SERVFAIL - response := makeTestResponse(t, domain, dns.RCodeServerFailure) - - // Our request is a single A query for the domain in the answer, above. - request := makeTestRequest(t, domain) - - var sawRequest atomic.Bool - port := runDNSServer(t, nil, response, func(isTCP bool, gotRequest []byte) { - sawRequest.Store(true) - if !bytes.Equal(request, gotRequest) { - t.Errorf("invalid request\ngot: %+v\nwant: %+v", gotRequest, request) - } - }) - - resp, err := runTestQuery(t, request, nil, port) - if !sawRequest.Load() { - t.Error("did not see DNS request") - } - if err != nil { - t.Fatalf("wanted nil, got %v", err) - } - var parser dns.Parser - respHeader, err := parser.Start(resp) - if err != nil { - t.Fatalf("parser.Start() failed: %v", err) - } - if got, want := respHeader.RCode, dns.RCodeServerFailure; got != want { - t.Errorf("wanted %v, got %v", want, got) - } -} - -// Test to ensure that if we have more than one resolver, and at least one of them -// returns a successful response, we propagate it. -func TestForwarderWithManyResolvers(t *testing.T) { - enableDebug(t) - - const domain = "example.com." - request := makeTestRequest(t, domain) - - tests := []struct { - name string - responses [][]byte // upstream responses - wantResponses [][]byte // we should receive one of these from the forwarder - }{ - { - name: "Success", - responses: [][]byte{ // All upstream servers returned successful, but different, response. - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.2")), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.3")), - }, - wantResponses: [][]byte{ // We may forward whichever response is received first. - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.2")), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.3")), - }, - }, - { - name: "ServFail", - responses: [][]byte{ // All upstream servers returned a SERVFAIL. - makeTestResponse(t, domain, dns.RCodeServerFailure), - makeTestResponse(t, domain, dns.RCodeServerFailure), - makeTestResponse(t, domain, dns.RCodeServerFailure), - }, - wantResponses: [][]byte{ - makeTestResponse(t, domain, dns.RCodeServerFailure), - }, - }, - { - name: "ServFail+Success", - responses: [][]byte{ // All upstream servers fail except for one. - makeTestResponse(t, domain, dns.RCodeServerFailure), - makeTestResponse(t, domain, dns.RCodeServerFailure), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - makeTestResponse(t, domain, dns.RCodeServerFailure), - }, - wantResponses: [][]byte{ // We should forward the successful response. - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - }, - }, - { - name: "NXDomain", - responses: [][]byte{ // All upstream servers returned NXDOMAIN. - makeTestResponse(t, domain, dns.RCodeNameError), - makeTestResponse(t, domain, dns.RCodeNameError), - makeTestResponse(t, domain, dns.RCodeNameError), - }, - wantResponses: [][]byte{ - makeTestResponse(t, domain, dns.RCodeNameError), - }, - }, - { - name: "NXDomain+Success", - responses: [][]byte{ // All upstream servers returned NXDOMAIN except for one. - makeTestResponse(t, domain, dns.RCodeNameError), - makeTestResponse(t, domain, dns.RCodeNameError), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - }, - wantResponses: [][]byte{ // However, only SERVFAIL are considered to be errors. Therefore, we may forward any response. - makeTestResponse(t, domain, dns.RCodeNameError), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - }, - }, - { - name: "Refused", - responses: [][]byte{ // All upstream servers return different failures. - makeTestResponse(t, domain, dns.RCodeRefused), - makeTestResponse(t, domain, dns.RCodeRefused), - makeTestResponse(t, domain, dns.RCodeRefused), - makeTestResponse(t, domain, dns.RCodeRefused), - makeTestResponse(t, domain, dns.RCodeRefused), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - }, - wantResponses: [][]byte{ // Refused is not considered to be an error and can be forwarded. - makeTestResponse(t, domain, dns.RCodeRefused), - makeTestResponse(t, domain, dns.RCodeSuccess, netip.MustParseAddr("127.0.0.1")), - }, - }, - { - name: "MixFail", - responses: [][]byte{ // All upstream servers return different failures. - makeTestResponse(t, domain, dns.RCodeServerFailure), - makeTestResponse(t, domain, dns.RCodeNameError), - makeTestResponse(t, domain, dns.RCodeRefused), - }, - wantResponses: [][]byte{ // Both NXDomain and Refused can be forwarded. - makeTestResponse(t, domain, dns.RCodeNameError), - makeTestResponse(t, domain, dns.RCodeRefused), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ports := make([]uint16, len(tt.responses)) - for i := range tt.responses { - ports[i] = runDNSServer(t, nil, tt.responses[i], func(isTCP bool, gotRequest []byte) {}) - } - gotResponse, err := runTestQuery(t, request, nil, ports...) - if err != nil { - t.Fatalf("wanted nil, got %v", err) - } - responseOk := slices.ContainsFunc(tt.wantResponses, func(wantResponse []byte) bool { - return slices.Equal(gotResponse, wantResponse) - }) - if !responseOk { - t.Errorf("invalid response\ngot: %+v\nwant: %+v", gotResponse, tt.wantResponses[0]) - } - }) - } -} - -// mdnsResponder at minimum has an expectation that NXDOMAIN must include the -// question, otherwise it will penalize our server (#13511). -func TestNXDOMAINIncludesQuestion(t *testing.T) { - var domain = "lb._dns-sd._udp.example.org." - - // Our response is a NXDOMAIN - response := func() []byte { - name := dns.MustNewName(domain) - - builder := dns.NewBuilder(nil, dns.Header{ - Response: true, - RCode: dns.RCodeNameError, - }) - builder.StartQuestions() - builder.Question(dns.Question{ - Name: name, - Type: dns.TypePTR, - Class: dns.ClassINET, - }) - response, err := builder.Finish() - if err != nil { - t.Fatal(err) - } - return response - }() - - // Our request is a single PTR query for the domain in the answer, above. - request := func() []byte { - builder := dns.NewBuilder(nil, dns.Header{}) - builder.StartQuestions() - builder.Question(dns.Question{ - Name: dns.MustNewName(domain), - Type: dns.TypePTR, - Class: dns.ClassINET, - }) - request, err := builder.Finish() - if err != nil { - t.Fatal(err) - } - return request - }() - - port := runDNSServer(t, nil, response, func(isTCP bool, gotRequest []byte) { - }) - - res, err := runTestQuery(t, request, nil, port) - if err != nil { - t.Fatal(err) - } - - if !slices.Equal(res, response) { - t.Errorf("invalid response\ngot: %+v\nwant: %+v", res, response) - } -} diff --git a/net/dns/resolver/macios_ext.go b/net/dns/resolver/macios_ext.go index e3f979c194d91..09705ac913af3 100644 --- a/net/dns/resolver/macios_ext.go +++ b/net/dns/resolver/macios_ext.go @@ -9,8 +9,8 @@ import ( "errors" "net" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" ) func init() { diff --git a/net/dns/resolver/tsdns.go b/net/dns/resolver/tsdns.go index 43ba0acf194f2..bc6c5bb843161 100644 --- a/net/dns/resolver/tsdns.go +++ b/net/dns/resolver/tsdns.go @@ -22,21 +22,21 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dns/resolvconffile" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/cloudenv" + "github.com/sagernet/tailscale/util/dnsname" dns "golang.org/x/net/dns/dnsmessage" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/dns/resolvconffile" - "tailscale.com/net/netaddr" - "tailscale.com/net/netmon" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tsdial" - "tailscale.com/syncs" - "tailscale.com/types/dnstype" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/cloudenv" - "tailscale.com/util/dnsname" ) const dnsSymbolicFQDN = "magicdns.localhost-tailscale-daemon." diff --git a/net/dns/resolver/tsdns_server_test.go b/net/dns/resolver/tsdns_server_test.go deleted file mode 100644 index 82fd3bebf232c..0000000000000 --- a/net/dns/resolver/tsdns_server_test.go +++ /dev/null @@ -1,333 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package resolver - -import ( - "fmt" - "net" - "net/netip" - "strings" - "testing" - - "github.com/miekg/dns" -) - -// This file exists to isolate the test infrastructure -// that depends on github.com/miekg/dns -// from the rest, which only depends on dnsmessage. - -// resolveToIP returns a handler function which responds -// to queries of type A it receives with an A record containing ipv4, -// to queries of type AAAA with an AAAA record containing ipv6, -// to queries of type NS with an NS record containing name. -func resolveToIP(ipv4, ipv6 netip.Addr, ns string) dns.HandlerFunc { - return func(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - - if len(req.Question) != 1 { - panic("not a single-question request") - } - question := req.Question[0] - - var ans dns.RR - switch question.Qtype { - case dns.TypeA: - ans = &dns.A{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - }, - A: ipv4.AsSlice(), - } - case dns.TypeAAAA: - ans = &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - }, - AAAA: ipv6.AsSlice(), - } - case dns.TypeNS: - ans = &dns.NS{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - }, - Ns: ns, - } - } - - m.Answer = append(m.Answer, ans) - w.WriteMsg(m) - } -} - -// resolveToIPLowercase returns a handler function which canonicalizes responses -// by lowercasing the question and answer names, and responds -// to queries of type A it receives with an A record containing ipv4, -// to queries of type AAAA with an AAAA record containing ipv6, -// to queries of type NS with an NS record containing name. -func resolveToIPLowercase(ipv4, ipv6 netip.Addr, ns string) dns.HandlerFunc { - return func(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - - if len(req.Question) != 1 { - panic("not a single-question request") - } - m.Question[0].Name = strings.ToLower(m.Question[0].Name) - question := req.Question[0] - - var ans dns.RR - switch question.Qtype { - case dns.TypeA: - ans = &dns.A{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - }, - A: ipv4.AsSlice(), - } - case dns.TypeAAAA: - ans = &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - }, - AAAA: ipv6.AsSlice(), - } - case dns.TypeNS: - ans = &dns.NS{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - }, - Ns: ns, - } - } - - m.Answer = append(m.Answer, ans) - w.WriteMsg(m) - } -} - -// resolveToTXT returns a handler function which responds to queries of type TXT -// it receives with the strings in txts. -func resolveToTXT(txts []string, ednsMaxSize uint16) dns.HandlerFunc { - return func(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - - if len(req.Question) != 1 { - panic("not a single-question request") - } - question := req.Question[0] - - if question.Qtype != dns.TypeTXT { - w.WriteMsg(m) - return - } - - ans := &dns.TXT{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeTXT, - Class: dns.ClassINET, - }, - Txt: txts, - } - - m.Answer = append(m.Answer, ans) - - queryInfo := &dns.TXT{ - Hdr: dns.RR_Header{ - Name: "query-info.test.", - Rrtype: dns.TypeTXT, - Class: dns.ClassINET, - }, - } - - if edns := req.IsEdns0(); edns == nil { - queryInfo.Txt = []string{"EDNS=false"} - } else { - queryInfo.Txt = []string{"EDNS=true", fmt.Sprintf("maxSize=%v", edns.UDPSize())} - } - - m.Extra = append(m.Extra, queryInfo) - - if ednsMaxSize > 0 { - m.SetEdns0(ednsMaxSize, false) - } - - if err := w.WriteMsg(m); err != nil { - panic(err) - } - } -} - -var resolveToNXDOMAIN = dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetRcode(req, dns.RcodeNameError) - w.WriteMsg(m) -}) - -// weirdoGoCNAMEHandler returns a DNS handler that satisfies -// Go's weird Resolver.LookupCNAME (read its godoc carefully!). -// -// This doesn't even return a CNAME record, because that's not -// what Go looks for. -func weirdoGoCNAMEHandler(target string) dns.HandlerFunc { - return func(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - question := req.Question[0] - - switch question.Qtype { - case dns.TypeA: - m.Answer = append(m.Answer, &dns.CNAME{ - Hdr: dns.RR_Header{ - Name: target, - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: 600, - }, - Target: target, - }) - case dns.TypeAAAA: - m.Answer = append(m.Answer, &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: target, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 600, - }, - AAAA: net.ParseIP("1::2"), - }) - } - w.WriteMsg(m) - } -} - -// dnsHandler returns a handler that replies with the answers/options -// provided. -// -// Types supported: netip.Addr. -func dnsHandler(answers ...any) dns.HandlerFunc { - return func(w dns.ResponseWriter, req *dns.Msg) { - m := new(dns.Msg) - m.SetReply(req) - if len(req.Question) != 1 { - panic("not a single-question request") - } - m.RecursionAvailable = true // to stop net package's errLameReferral on empty replies - - question := req.Question[0] - for _, a := range answers { - switch a := a.(type) { - default: - panic(fmt.Sprintf("unsupported dnsHandler arg %T", a)) - case netip.Addr: - ip := a - if ip.Is4() { - m.Answer = append(m.Answer, &dns.A{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - }, - A: ip.AsSlice(), - }) - } else if ip.Is6() { - m.Answer = append(m.Answer, &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - }, - AAAA: ip.AsSlice(), - }) - } - case dns.PTR: - ptr := a - ptr.Hdr = dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - } - m.Answer = append(m.Answer, &ptr) - case dns.CNAME: - c := a - c.Hdr = dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: 600, - } - m.Answer = append(m.Answer, &c) - case dns.TXT: - txt := a - txt.Hdr = dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeTXT, - Class: dns.ClassINET, - } - m.Answer = append(m.Answer, &txt) - case dns.SRV: - srv := a - srv.Hdr = dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - } - m.Answer = append(m.Answer, &srv) - case dns.NS: - rr := a - rr.Hdr = dns.RR_Header{ - Name: question.Name, - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - } - m.Answer = append(m.Answer, &rr) - } - } - w.WriteMsg(m) - } -} - -func serveDNS(tb testing.TB, addr string, records ...any) *dns.Server { - if len(records)%2 != 0 { - panic("must have an even number of record values") - } - mux := dns.NewServeMux() - for i := 0; i < len(records); i += 2 { - name := records[i].(string) - handler := records[i+1].(dns.Handler) - mux.Handle(name, handler) - } - waitch := make(chan struct{}) - server := &dns.Server{ - Addr: addr, - Net: "udp", - Handler: mux, - NotifyStartedFunc: func() { close(waitch) }, - ReusePort: true, - } - - go func() { - err := server.ListenAndServe() - if err != nil { - panic(fmt.Sprintf("ListenAndServe(%q): %v", addr, err)) - } - }() - - <-waitch - return server -} diff --git a/net/dns/resolver/tsdns_test.go b/net/dns/resolver/tsdns_test.go deleted file mode 100644 index d7b9fb360eaf0..0000000000000 --- a/net/dns/resolver/tsdns_test.go +++ /dev/null @@ -1,1524 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package resolver - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "log" - "math/rand" - "net" - "net/netip" - "reflect" - "runtime" - "strconv" - "strings" - "testing" - "time" - - miekdns "github.com/miekg/dns" - dns "golang.org/x/net/dns/dnsmessage" - "tailscale.com/health" - "tailscale.com/net/netaddr" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/tstest" - "tailscale.com/types/dnstype" - "tailscale.com/types/logger" - "tailscale.com/util/dnsname" -) - -var ( - testipv4 = netip.MustParseAddr("1.2.3.4") - testipv6 = netip.MustParseAddr("0001:0203:0405:0607:0809:0a0b:0c0d:0e0f") - - testipv4Arpa = dnsname.FQDN("4.3.2.1.in-addr.arpa.") - testipv6Arpa = dnsname.FQDN("f.0.e.0.d.0.c.0.b.0.a.0.9.0.8.0.7.0.6.0.5.0.4.0.3.0.2.0.1.0.0.0.ip6.arpa.") -) - -var dnsCfg = Config{ - Hosts: map[dnsname.FQDN][]netip.Addr{ - "test1.ipn.dev.": {testipv4}, - "test2.ipn.dev.": {testipv6}, - }, - LocalDomains: []dnsname.FQDN{"ipn.dev.", "3.2.1.in-addr.arpa.", "1.0.0.0.ip6.arpa."}, -} - -const noEdns = 0 - -const dnsHeaderLen = 12 - -func dnspacket(domain dnsname.FQDN, tp dns.Type, ednsSize uint16) []byte { - var dnsHeader dns.Header - question := dns.Question{ - Name: dns.MustNewName(domain.WithTrailingDot()), - Type: tp, - Class: dns.ClassINET, - } - - builder := dns.NewBuilder(nil, dnsHeader) - if err := builder.StartQuestions(); err != nil { - panic(err) - } - if err := builder.Question(question); err != nil { - panic(err) - } - - if ednsSize != noEdns { - if err := builder.StartAdditionals(); err != nil { - panic(err) - } - - ednsHeader := dns.ResourceHeader{ - Name: dns.MustNewName("."), - Type: dns.TypeOPT, - Class: dns.Class(ednsSize), - } - - if err := builder.OPTResource(ednsHeader, dns.OPTResource{}); err != nil { - panic(err) - } - } - - payload, _ := builder.Finish() - - return payload -} - -type dnsResponse struct { - ip netip.Addr - txt []string - name dnsname.FQDN - rcode dns.RCode - truncated bool - requestEdns bool - requestEdnsSize uint16 - responseEdns bool - responseEdnsSize uint16 -} - -func unpackResponse(payload []byte) (dnsResponse, error) { - var response dnsResponse - var parser dns.Parser - - h, err := parser.Start(payload) - if err != nil { - return response, err - } - - if !h.Response { - return response, errors.New("not a response") - } - - response.rcode = h.RCode - if response.rcode != dns.RCodeSuccess { - return response, nil - } - - response.truncated = h.Truncated - if response.truncated { - // TODO(#2067): Ideally, answer processing should still succeed when - // dealing with a truncated message, but currently when we truncate - // a packet, it's caused by the buffer being too small and usually that - // means the data runs out mid-record. dns.Parser does not like it when - // that happens. We can improve this by trimming off incomplete records. - return response, nil - } - - err = parser.SkipAllQuestions() - if err != nil { - return response, err - } - - for { - ah, err := parser.AnswerHeader() - if err == dns.ErrSectionDone { - break - } - if err != nil { - return response, err - } - - switch ah.Type { - case dns.TypeA: - res, err := parser.AResource() - if err != nil { - return response, err - } - response.ip = netaddr.IPv4(res.A[0], res.A[1], res.A[2], res.A[3]) - case dns.TypeAAAA: - res, err := parser.AAAAResource() - if err != nil { - return response, err - } - response.ip = netip.AddrFrom16(res.AAAA) - case dns.TypeTXT: - res, err := parser.TXTResource() - if err != nil { - return response, err - } - response.txt = res.TXT - case dns.TypeNS: - res, err := parser.NSResource() - if err != nil { - return response, err - } - response.name, err = dnsname.ToFQDN(res.NS.String()) - if err != nil { - return response, err - } - default: - return response, errors.New("type not in {A, AAAA, NS}") - } - } - - err = parser.SkipAllAuthorities() - if err != nil { - return response, err - } - - for { - ah, err := parser.AdditionalHeader() - if err == dns.ErrSectionDone { - break - } - if err != nil { - return response, err - } - - switch ah.Type { - case dns.TypeOPT: - _, err := parser.OPTResource() - if err != nil { - return response, err - } - response.responseEdns = true - response.responseEdnsSize = uint16(ah.Class) - case dns.TypeTXT: - res, err := parser.TXTResource() - if err != nil { - return response, err - } - switch ah.Name.String() { - case "query-info.test.": - for _, msg := range res.TXT { - s := strings.SplitN(msg, "=", 2) - if len(s) != 2 { - continue - } - switch s[0] { - case "EDNS": - response.requestEdns, err = strconv.ParseBool(s[1]) - if err != nil { - return response, err - } - case "maxSize": - sz, err := strconv.ParseUint(s[1], 10, 16) - if err != nil { - return response, err - } - response.requestEdnsSize = uint16(sz) - } - } - } - } - } - - return response, nil -} - -func syncRespond(r *Resolver, query []byte) ([]byte, error) { - return r.Query(context.Background(), query, "udp", netip.AddrPort{}) -} - -func mustIP(str string) netip.Addr { - ip, err := netip.ParseAddr(str) - if err != nil { - panic(err) - } - return ip -} - -func TestRoutesRequireNoCustomResolvers(t *testing.T) { - tests := []struct { - name string - config Config - expected bool - }{ - {"noRoutes", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{}}, true}, - {"onlyDefault", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{ - "ts.net.": { - {}, - }, - }}, true}, - {"oneOther", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{ - "example.com.": { - {}, - }, - }}, false}, - {"defaultAndOneOther", Config{Routes: map[dnsname.FQDN][]*dnstype.Resolver{ - "ts.net.": { - {}, - }, - "example.com.": { - {}, - }, - }}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.config.RoutesRequireNoCustomResolvers() - if result != tt.expected { - t.Errorf("result = %v; want %v", result, tt.expected) - } - }) - } -} - -func TestRDNSNameToIPv4(t *testing.T) { - tests := []struct { - name string - input dnsname.FQDN - wantIP netip.Addr - wantOK bool - }{ - {"valid", "4.123.24.1.in-addr.arpa.", netaddr.IPv4(1, 24, 123, 4), true}, - {"double_dot", "1..2.3.in-addr.arpa.", netip.Addr{}, false}, - {"overflow", "1.256.3.4.in-addr.arpa.", netip.Addr{}, false}, - {"not_ip", "sub.do.ma.in.in-addr.arpa.", netip.Addr{}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ip, ok := rdnsNameToIPv4(tt.input) - if ok != tt.wantOK { - t.Errorf("ok = %v; want %v", ok, tt.wantOK) - } else if ok && ip != tt.wantIP { - t.Errorf("ip = %v; want %v", ip, tt.wantIP) - } - }) - } -} - -func TestRDNSNameToIPv6(t *testing.T) { - tests := []struct { - name string - input dnsname.FQDN - wantIP netip.Addr - wantOK bool - }{ - { - "valid", - "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", - mustIP("2001:db8::567:89ab"), - true, - }, - { - "double_dot", - "b..9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", - netip.Addr{}, - false, - }, - { - "double_hex", - "b.a.98.0.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", - netip.Addr{}, - false, - }, - { - "not_hex", - "b.a.g.0.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", - netip.Addr{}, - false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ip, ok := rdnsNameToIPv6(tt.input) - if ok != tt.wantOK { - t.Errorf("ok = %v; want %v", ok, tt.wantOK) - } else if ok && ip != tt.wantIP { - t.Errorf("ip = %v; want %v", ip, tt.wantIP) - } - }) - } -} - -func newResolver(t testing.TB) *Resolver { - return New(t.Logf, - nil, // no link selector - tsdial.NewDialer(netmon.NewStatic()), - new(health.Tracker), - nil, // no control knobs - ) -} - -func TestResolveLocal(t *testing.T) { - r := newResolver(t) - defer r.Close() - - r.SetConfig(dnsCfg) - - tests := []struct { - name string - qname dnsname.FQDN - qtype dns.Type - ip netip.Addr - code dns.RCode - }{ - {"ipv4", "test1.ipn.dev.", dns.TypeA, testipv4, dns.RCodeSuccess}, - {"ipv6", "test2.ipn.dev.", dns.TypeAAAA, testipv6, dns.RCodeSuccess}, - {"no-ipv6", "test1.ipn.dev.", dns.TypeAAAA, netip.Addr{}, dns.RCodeSuccess}, - {"nxdomain", "test3.ipn.dev.", dns.TypeA, netip.Addr{}, dns.RCodeNameError}, - {"foreign domain", "google.com.", dns.TypeA, netip.Addr{}, dns.RCodeRefused}, - {"all", "test1.ipn.dev.", dns.TypeA, testipv4, dns.RCodeSuccess}, - {"mx-ipv4", "test1.ipn.dev.", dns.TypeMX, netip.Addr{}, dns.RCodeSuccess}, - {"mx-ipv6", "test2.ipn.dev.", dns.TypeMX, netip.Addr{}, dns.RCodeSuccess}, - {"mx-nxdomain", "test3.ipn.dev.", dns.TypeMX, netip.Addr{}, dns.RCodeNameError}, - {"ns-nxdomain", "test3.ipn.dev.", dns.TypeNS, netip.Addr{}, dns.RCodeNameError}, - {"onion-domain", "footest.onion.", dns.TypeA, netip.Addr{}, dns.RCodeNameError}, - {"magicdns", dnsSymbolicFQDN, dns.TypeA, netip.MustParseAddr("100.100.100.100"), dns.RCodeSuccess}, - {"via_hex", dnsname.FQDN("via-0xff.1.2.3.4."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:ff:1.2.3.4"), dns.RCodeSuccess}, - {"via_dec", dnsname.FQDN("via-1.10.0.0.1."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:1:10.0.0.1"), dns.RCodeSuccess}, - {"x_via_hex", dnsname.FQDN("4.3.2.1.via-0xff."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:ff:4.3.2.1"), dns.RCodeSuccess}, - {"x_via_dec", dnsname.FQDN("1.0.0.10.via-1."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:1:1.0.0.10"), dns.RCodeSuccess}, - {"via_invalid", dnsname.FQDN("via-."), dns.TypeAAAA, netip.Addr{}, dns.RCodeRefused}, - {"via_invalid_2", dnsname.FQDN("2.3.4.5.via-."), dns.TypeAAAA, netip.Addr{}, dns.RCodeRefused}, - - // Hyphenated 4via6 format. - // Without any suffix domain: - {"via_form3_hex_bare", dnsname.FQDN("1-2-3-4-via-0xff."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:ff:1.2.3.4"), dns.RCodeSuccess}, - {"via_form3_dec_bare", dnsname.FQDN("1-2-3-4-via-1."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:1:1.2.3.4"), dns.RCodeSuccess}, - // With a Tailscale domain: - {"via_form3_dec_ts.net", dnsname.FQDN("1-2-3-4-via-1.foo.ts.net."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:1:1.2.3.4"), dns.RCodeSuccess}, - {"via_form3_dec_tailscale.net", dnsname.FQDN("1-2-3-4-via-1.foo.tailscale.net."), dns.TypeAAAA, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:1:1.2.3.4"), dns.RCodeSuccess}, - // Non-Tailscale domain suffixes aren't allowed for now: (the allowed - // suffixes are currently hard-coded and not plumbed via the netmap) - {"via_form3_dec_example.com", dnsname.FQDN("1-2-3-4-via-1.example.com."), dns.TypeAAAA, netip.Addr{}, dns.RCodeRefused}, - {"via_form3_dec_examplets.net", dnsname.FQDN("1-2-3-4-via-1.examplets.net."), dns.TypeAAAA, netip.Addr{}, dns.RCodeRefused}, - - // Resolve A and ALL types of resource records. - {"via_type_a", dnsname.FQDN("1-2-3-4-via-1."), dns.TypeA, netip.Addr{}, dns.RCodeSuccess}, - {"via_invalid_type_a", dnsname.FQDN("1-2-3-4-via-."), dns.TypeA, netip.Addr{}, dns.RCodeRefused}, - {"via_type_all", dnsname.FQDN("1-2-3-4-via-1."), dns.TypeALL, netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:1:1.2.3.4"), dns.RCodeSuccess}, - {"via_invalid_type_all", dnsname.FQDN("1-2-3-4-via-."), dns.TypeALL, netip.Addr{}, dns.RCodeRefused}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ip, code := r.resolveLocal(tt.qname, tt.qtype) - if code != tt.code { - t.Errorf("code = %v; want %v", code, tt.code) - } - // Only check ip for non-err - if ip != tt.ip { - t.Errorf("ip = %v; want %v", ip, tt.ip) - } - }) - } -} - -func TestResolveLocalReverse(t *testing.T) { - r := newResolver(t) - defer r.Close() - - r.SetConfig(dnsCfg) - - tests := []struct { - name string - q dnsname.FQDN - want dnsname.FQDN - code dns.RCode - }{ - {"ipv4", testipv4Arpa, "test1.ipn.dev.", dns.RCodeSuccess}, - {"ipv6", testipv6Arpa, "test2.ipn.dev.", dns.RCodeSuccess}, - {"ipv4_nxdomain", dnsname.FQDN("5.3.2.1.in-addr.arpa."), "", dns.RCodeNameError}, - {"ipv6_nxdomain", dnsname.FQDN("0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.ip6.arpa."), "", dns.RCodeNameError}, - {"nxdomain", dnsname.FQDN("2.3.4.5.in-addr.arpa."), "", dns.RCodeRefused}, - {"magicdns", dnsname.FQDN("100.100.100.100.in-addr.arpa."), dnsSymbolicFQDN, dns.RCodeSuccess}, - {"ipv6_4to6", dnsname.FQDN("4.6.4.6.4.6.2.6.6.9.d.c.3.4.8.4.2.1.b.a.0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa."), dnsSymbolicFQDN, dns.RCodeSuccess}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - name, code := r.resolveLocalReverse(tt.q) - if code != tt.code { - t.Errorf("code = %v; want %v", code, tt.code) - } - if name != tt.want { - t.Errorf("ip = %v; want %v", name, tt.want) - } - }) - } -} - -func ipv6Works() bool { - c, err := net.Listen("tcp", "[::1]:0") - if err != nil { - return false - } - c.Close() - return true -} - -func generateTXT(size int, source rand.Source) []string { - const sizePerTXT = 120 - - if size%2 != 0 { - panic("even lengths only") - } - - rng := rand.New(source) - - txts := make([]string, 0, size/sizePerTXT+1) - - raw := make([]byte, sizePerTXT/2) - - rem := size - for ; rem > sizePerTXT; rem -= sizePerTXT { - rng.Read(raw) - txts = append(txts, hex.EncodeToString(raw)) - } - if rem > 0 { - rng.Read(raw[:rem/2]) - txts = append(txts, hex.EncodeToString(raw[:rem/2])) - } - - return txts -} - -func TestDelegate(t *testing.T) { - tstest.ResourceCheck(t) - - if !ipv6Works() { - t.Skip("skipping test that requires localhost IPv6") - } - - randSource := rand.NewSource(4) - - // smallTXT does not require EDNS - smallTXT := generateTXT(300, randSource) - - // medTXT and largeTXT are responses that require EDNS but we would like to - // support these sizes of response without truncation because they are - // moderately common. - medTXT := generateTXT(1200, randSource) - largeTXT := generateTXT(3900, randSource) - - // xlargeTXT is slightly above the maximum response size that we support, - // so there should be truncation. - xlargeTXT := generateTXT(5000, randSource) - - // hugeTXT is significantly larger than any typical MTU and will require - // significant fragmentation. For buffer management reasons, we do not - // intend to handle responses this large, so there should be truncation. - hugeTXT := generateTXT(64000, randSource) - - records := []any{ - "test.site.", - resolveToIP(testipv4, testipv6, "dns.test.site."), - "LCtesT.SiTe.", - resolveToIPLowercase(testipv4, testipv6, "dns.test.site."), - "nxdomain.site.", resolveToNXDOMAIN, - "small.txt.", resolveToTXT(smallTXT, noEdns), - "smalledns.txt.", resolveToTXT(smallTXT, 512), - "med.txt.", resolveToTXT(medTXT, 1500), - "large.txt.", resolveToTXT(largeTXT, maxResponseBytes), - "xlarge.txt.", resolveToTXT(xlargeTXT, 8000), - "huge.txt.", resolveToTXT(hugeTXT, 65527), - } - v4server := serveDNS(t, "127.0.0.1:0", records...) - defer v4server.Shutdown() - v6server := serveDNS(t, "[::1]:0", records...) - defer v6server.Shutdown() - - r := newResolver(t) - defer r.Close() - - cfg := dnsCfg - cfg.Routes = map[dnsname.FQDN][]*dnstype.Resolver{ - ".": { - &dnstype.Resolver{Addr: v4server.PacketConn.LocalAddr().String()}, - &dnstype.Resolver{Addr: v6server.PacketConn.LocalAddr().String()}, - }, - } - r.SetConfig(cfg) - - tests := []struct { - title string - query []byte - response dnsResponse - }{ - { - "ipv4", - dnspacket("test.site.", dns.TypeA, noEdns), - dnsResponse{ip: testipv4, rcode: dns.RCodeSuccess}, - }, - { - "ipv6", - dnspacket("test.site.", dns.TypeAAAA, noEdns), - dnsResponse{ip: testipv6, rcode: dns.RCodeSuccess}, - }, - { - "ns", - dnspacket("test.site.", dns.TypeNS, noEdns), - dnsResponse{name: "dns.test.site.", rcode: dns.RCodeSuccess}, - }, - { - "ipv4", - dnspacket("LCtesT.SiTe.", dns.TypeA, noEdns), - dnsResponse{ip: testipv4, rcode: dns.RCodeSuccess}, - }, - { - "ipv6", - dnspacket("LCtesT.SiTe.", dns.TypeAAAA, noEdns), - dnsResponse{ip: testipv6, rcode: dns.RCodeSuccess}, - }, - { - "ns", - dnspacket("LCtesT.SiTe.", dns.TypeNS, noEdns), - dnsResponse{name: "dns.test.site.", rcode: dns.RCodeSuccess}, - }, - { - "nxdomain", - dnspacket("nxdomain.site.", dns.TypeA, noEdns), - dnsResponse{rcode: dns.RCodeNameError}, - }, - { - "smalltxt", - dnspacket("small.txt.", dns.TypeTXT, 8000), - dnsResponse{txt: smallTXT, rcode: dns.RCodeSuccess, requestEdns: true, requestEdnsSize: maxResponseBytes}, - }, - { - "smalltxtedns", - dnspacket("smalledns.txt.", dns.TypeTXT, 512), - dnsResponse{ - txt: smallTXT, - rcode: dns.RCodeSuccess, - requestEdns: true, - requestEdnsSize: 512, - responseEdns: true, - responseEdnsSize: 512, - }, - }, - { - "medtxt", - dnspacket("med.txt.", dns.TypeTXT, 2000), - dnsResponse{ - txt: medTXT, - rcode: dns.RCodeSuccess, - requestEdns: true, - requestEdnsSize: 2000, - responseEdns: true, - responseEdnsSize: 1500, - }, - }, - { - "largetxt", - dnspacket("large.txt.", dns.TypeTXT, maxResponseBytes), - dnsResponse{ - txt: largeTXT, - rcode: dns.RCodeSuccess, - requestEdns: true, - requestEdnsSize: maxResponseBytes, - responseEdns: true, - responseEdnsSize: maxResponseBytes, - }, - }, - { - "xlargetxt", - dnspacket("xlarge.txt.", dns.TypeTXT, 8000), - dnsResponse{ - rcode: dns.RCodeSuccess, - truncated: true, - // request/response EDNS fields will be unset because of - // they were truncated away - }, - }, - { - "hugetxt", - dnspacket("huge.txt.", dns.TypeTXT, 8000), - dnsResponse{ - rcode: dns.RCodeSuccess, - truncated: true, - // request/response EDNS fields will be unset because of - // they were truncated away - }, - }, - } - - for _, tt := range tests { - t.Run(tt.title, func(t *testing.T) { - if tt.title == "hugetxt" && runtime.GOOS == "darwin" { - t.Skip("known to not work on macOS: https://github.com/tailscale/tailscale/issues/2229") - } - payload, err := syncRespond(r, tt.query) - if err != nil { - t.Errorf("err = %v; want nil", err) - return - } - response, err := unpackResponse(payload) - if err != nil { - t.Errorf("extract: err = %v; want nil (in %x)", err, payload) - return - } - if response.rcode != tt.response.rcode { - t.Errorf("rcode = %v; want %v", response.rcode, tt.response.rcode) - } - if response.ip != tt.response.ip { - t.Errorf("ip = %v; want %v", response.ip, tt.response.ip) - } - if response.name != tt.response.name { - t.Errorf("name = %v; want %v", response.name, tt.response.name) - } - if len(response.txt) != len(tt.response.txt) { - t.Errorf("%v txt records, want %v txt records", len(response.txt), len(tt.response.txt)) - } else { - for i := range response.txt { - if response.txt[i] != tt.response.txt[i] { - t.Errorf("txt record %v is %s, want %s", i, response.txt[i], tt.response.txt[i]) - } - } - } - if response.requestEdns != tt.response.requestEdns { - t.Errorf("requestEdns = %v; want %v", response.requestEdns, tt.response.requestEdns) - } - if response.requestEdnsSize != tt.response.requestEdnsSize { - t.Errorf("requestEdnsSize = %v; want %v", response.requestEdnsSize, tt.response.requestEdnsSize) - } - if response.responseEdns != tt.response.responseEdns { - t.Errorf("responseEdns = %v; want %v", response.requestEdns, tt.response.requestEdns) - } - if response.responseEdnsSize != tt.response.responseEdnsSize { - t.Errorf("responseEdnsSize = %v; want %v", response.responseEdnsSize, tt.response.responseEdnsSize) - } - }) - } -} - -func TestDelegateSplitRoute(t *testing.T) { - test4 := netip.MustParseAddr("2.3.4.5") - test6 := netip.MustParseAddr("ff::1") - - server1 := serveDNS(t, "127.0.0.1:0", - "test.site.", resolveToIP(testipv4, testipv6, "dns.test.site.")) - defer server1.Shutdown() - server2 := serveDNS(t, "127.0.0.1:0", - "test.other.", resolveToIP(test4, test6, "dns.other.")) - defer server2.Shutdown() - - r := newResolver(t) - defer r.Close() - - cfg := dnsCfg - cfg.Routes = map[dnsname.FQDN][]*dnstype.Resolver{ - ".": {{Addr: server1.PacketConn.LocalAddr().String()}}, - "other.": {{Addr: server2.PacketConn.LocalAddr().String()}}, - } - r.SetConfig(cfg) - - tests := []struct { - title string - query []byte - response dnsResponse - }{ - { - "general", - dnspacket("test.site.", dns.TypeA, noEdns), - dnsResponse{ip: testipv4, rcode: dns.RCodeSuccess}, - }, - { - "override", - dnspacket("test.other.", dns.TypeA, noEdns), - dnsResponse{ip: test4, rcode: dns.RCodeSuccess}, - }, - } - - for _, tt := range tests { - t.Run(tt.title, func(t *testing.T) { - payload, err := syncRespond(r, tt.query) - if err != nil { - t.Errorf("err = %v; want nil", err) - return - } - response, err := unpackResponse(payload) - if err != nil { - t.Errorf("extract: err = %v; want nil (in %x)", err, payload) - return - } - if response.rcode != tt.response.rcode { - t.Errorf("rcode = %v; want %v", response.rcode, tt.response.rcode) - } - if response.ip != tt.response.ip { - t.Errorf("ip = %v; want %v", response.ip, tt.response.ip) - } - if response.name != tt.response.name { - t.Errorf("name = %v; want %v", response.name, tt.response.name) - } - }) - } -} - -var allResponse = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x00, // flags: response, authoritative, no error - 0x00, 0x01, // one question - 0x00, 0x01, // one answer - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x31, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0xff, 0x00, 0x01, // type ALL, class IN - // Answer: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x31, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0x01, 0x00, 0x01, // type A, class IN - 0x00, 0x00, 0x02, 0x58, // TTL: 600 - 0x00, 0x04, // length: 4 bytes - 0x01, 0x02, 0x03, 0x04, // A: 1.2.3.4 -} - -var ipv4Response = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x00, // flags: response, authoritative, no error - 0x00, 0x01, // one question - 0x00, 0x01, // one answer - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x31, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0x01, 0x00, 0x01, // type A, class IN - // Answer: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x31, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0x01, 0x00, 0x01, // type A, class IN - 0x00, 0x00, 0x02, 0x58, // TTL: 600 - 0x00, 0x04, // length: 4 bytes - 0x01, 0x02, 0x03, 0x04, // A: 1.2.3.4 -} - -var ipv6Response = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x00, // flags: response, authoritative, no error - 0x00, 0x01, // one question - 0x00, 0x01, // one answer - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x32, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0x1c, 0x00, 0x01, // type AAAA, class IN - // Answer: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x32, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0x1c, 0x00, 0x01, // type AAAA, class IN - 0x00, 0x00, 0x02, 0x58, // TTL: 600 - 0x00, 0x10, // length: 16 bytes - // AAAA: 0001:0203:0405:0607:0809:0A0B:0C0D:0E0F - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0xb, 0xc, 0xd, 0xe, 0xf, -} - -var ipv4UppercaseResponse = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x00, // flags: response, authoritative, no error - 0x00, 0x01, // one question - 0x00, 0x01, // one answer - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: - 0x05, 0x54, 0x45, 0x53, 0x54, 0x31, 0x03, 0x49, 0x50, 0x4e, 0x03, 0x44, 0x45, 0x56, 0x00, // name - 0x00, 0x01, 0x00, 0x01, // type A, class IN - // Answer: - 0x05, 0x54, 0x45, 0x53, 0x54, 0x31, 0x03, 0x49, 0x50, 0x4e, 0x03, 0x44, 0x45, 0x56, 0x00, // name - 0x00, 0x01, 0x00, 0x01, // type A, class IN - 0x00, 0x00, 0x02, 0x58, // TTL: 600 - 0x00, 0x04, // length: 4 bytes - 0x01, 0x02, 0x03, 0x04, // A: 1.2.3.4 -} - -var ptrResponse = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x00, // flags: response, authoritative, no error - 0x00, 0x01, // one question - 0x00, 0x01, // one answer - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: 4.3.2.1.in-addr.arpa - 0x01, 0x34, 0x01, 0x33, 0x01, 0x32, 0x01, 0x31, 0x07, - 0x69, 0x6e, 0x2d, 0x61, 0x64, 0x64, 0x72, 0x04, 0x61, 0x72, 0x70, 0x61, 0x00, - 0x00, 0x0c, 0x00, 0x01, // type PTR, class IN - // Answer: 4.3.2.1.in-addr.arpa - 0x01, 0x34, 0x01, 0x33, 0x01, 0x32, 0x01, 0x31, 0x07, - 0x69, 0x6e, 0x2d, 0x61, 0x64, 0x64, 0x72, 0x04, 0x61, 0x72, 0x70, 0x61, 0x00, - 0x00, 0x0c, 0x00, 0x01, // type PTR, class IN - 0x00, 0x00, 0x02, 0x58, // TTL: 600 - 0x00, 0x0f, // length: 15 bytes - // PTR: test1.ipn.dev - 0x05, 0x74, 0x65, 0x73, 0x74, 0x31, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, -} - -var ptrResponse6 = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x00, // flags: response, authoritative, no error - 0x00, 0x01, // one question - 0x00, 0x01, // one answer - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: f.0.e.0.d.0.c.0.b.0.a.0.9.0.8.0.7.0.6.0.5.0.4.0.3.0.2.0.1.0.0.0.ip6.arpa - 0x01, 0x66, 0x01, 0x30, 0x01, 0x65, 0x01, 0x30, - 0x01, 0x64, 0x01, 0x30, 0x01, 0x63, 0x01, 0x30, - 0x01, 0x62, 0x01, 0x30, 0x01, 0x61, 0x01, 0x30, - 0x01, 0x39, 0x01, 0x30, 0x01, 0x38, 0x01, 0x30, - 0x01, 0x37, 0x01, 0x30, 0x01, 0x36, 0x01, 0x30, - 0x01, 0x35, 0x01, 0x30, 0x01, 0x34, 0x01, 0x30, - 0x01, 0x33, 0x01, 0x30, 0x01, 0x32, 0x01, 0x30, - 0x01, 0x31, 0x01, 0x30, 0x01, 0x30, 0x01, 0x30, - 0x03, 0x69, 0x70, 0x36, - 0x04, 0x61, 0x72, 0x70, 0x61, 0x00, - 0x00, 0x0c, 0x00, 0x01, // type PTR, class IN6 - // Answer: f.0.e.0.d.0.c.0.b.0.a.0.9.0.8.0.7.0.6.0.5.0.4.0.3.0.2.0.1.0.0.0.ip6.arpa - 0x01, 0x66, 0x01, 0x30, 0x01, 0x65, 0x01, 0x30, - 0x01, 0x64, 0x01, 0x30, 0x01, 0x63, 0x01, 0x30, - 0x01, 0x62, 0x01, 0x30, 0x01, 0x61, 0x01, 0x30, - 0x01, 0x39, 0x01, 0x30, 0x01, 0x38, 0x01, 0x30, - 0x01, 0x37, 0x01, 0x30, 0x01, 0x36, 0x01, 0x30, - 0x01, 0x35, 0x01, 0x30, 0x01, 0x34, 0x01, 0x30, - 0x01, 0x33, 0x01, 0x30, 0x01, 0x32, 0x01, 0x30, - 0x01, 0x31, 0x01, 0x30, 0x01, 0x30, 0x01, 0x30, - 0x03, 0x69, 0x70, 0x36, - 0x04, 0x61, 0x72, 0x70, 0x61, 0x00, - 0x00, 0x0c, 0x00, 0x01, // type PTR, class IN - 0x00, 0x00, 0x02, 0x58, // TTL: 600 - 0x00, 0x0f, // length: 15 bytes - // PTR: test2.ipn.dev - 0x05, 0x74, 0x65, 0x73, 0x74, 0x32, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, -} - -var nxdomainResponse = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x03, // flags: response, authoritative, error: nxdomain - 0x00, 0x01, // one question - 0x00, 0x00, // no answers - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x33, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0x01, 0x00, 0x01, // type A, class IN -} - -var emptyResponse = []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x00, // flags: response, authoritative, no error - 0x00, 0x01, // one question - 0x00, 0x00, // no answers - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: - 0x05, 0x74, 0x65, 0x73, 0x74, 0x31, 0x03, 0x69, 0x70, 0x6e, 0x03, 0x64, 0x65, 0x76, 0x00, // name - 0x00, 0x1c, 0x00, 0x01, // type AAAA, class IN -} - -func TestFull(t *testing.T) { - r := newResolver(t) - defer r.Close() - - r.SetConfig(dnsCfg) - - // One full packet and one error packet - tests := []struct { - name string - request []byte - response []byte - }{ - {"all", dnspacket("test1.ipn.dev.", dns.TypeALL, noEdns), allResponse}, - {"ipv4", dnspacket("test1.ipn.dev.", dns.TypeA, noEdns), ipv4Response}, - {"ipv6", dnspacket("test2.ipn.dev.", dns.TypeAAAA, noEdns), ipv6Response}, - {"no-ipv6", dnspacket("test1.ipn.dev.", dns.TypeAAAA, noEdns), emptyResponse}, - {"upper", dnspacket("TEST1.IPN.DEV.", dns.TypeA, noEdns), ipv4UppercaseResponse}, - {"ptr4", dnspacket("4.3.2.1.in-addr.arpa.", dns.TypePTR, noEdns), ptrResponse}, - {"ptr6", dnspacket("f.0.e.0.d.0.c.0.b.0.a.0.9.0.8.0.7.0.6.0.5.0.4.0.3.0.2.0.1.0.0.0.ip6.arpa.", - dns.TypePTR, noEdns), ptrResponse6}, - {"nxdomain", dnspacket("test3.ipn.dev.", dns.TypeA, noEdns), nxdomainResponse}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - response, err := syncRespond(r, tt.request) - if err != nil { - t.Errorf("err = %v; want nil", err) - } - if !bytes.Equal(response, tt.response) { - t.Errorf("response = %x; want %x", response, tt.response) - } - }) - } -} - -func TestAllocs(t *testing.T) { - r := newResolver(t) - defer r.Close() - r.SetConfig(dnsCfg) - - // It is seemingly pointless to test allocs in the delegate path, - // as dialer.Dial -> Read -> Write alone comprise 12 allocs. - tests := []struct { - name string - query []byte - want uint64 - }{ - // Name lowercasing, response slice created by dns.NewBuilder, - // and closure allocation from go call. - {"forward", dnspacket("test1.ipn.dev.", dns.TypeA, noEdns), 3}, - // 3 extra allocs in rdnsNameToIPv4 and one in marshalPTRRecord (dns.NewName). - {"reverse", dnspacket("4.3.2.1.in-addr.arpa.", dns.TypePTR, noEdns), 5}, - } - - for _, tt := range tests { - err := tstest.MinAllocsPerRun(t, tt.want, func() { - syncRespond(r, tt.query) - }) - if err != nil { - t.Errorf("%s: %v", tt.name, err) - } - } -} - -func TestTrimRDNSBonjourPrefix(t *testing.T) { - tests := []struct { - in dnsname.FQDN - want bool - }{ - {"b._dns-sd._udp.0.10.20.172.in-addr.arpa.", true}, - {"db._dns-sd._udp.0.10.20.172.in-addr.arpa.", true}, - {"r._dns-sd._udp.0.10.20.172.in-addr.arpa.", true}, - {"dr._dns-sd._udp.0.10.20.172.in-addr.arpa.", true}, - {"lb._dns-sd._udp.0.10.20.172.in-addr.arpa.", true}, - {"qq._dns-sd._udp.0.10.20.172.in-addr.arpa.", false}, - {"0.10.20.172.in-addr.arpa.", false}, - {"lb._dns-sd._udp.ts-dns.test.", true}, - } - - for _, test := range tests { - got := hasRDNSBonjourPrefix(test.in) - if got != test.want { - t.Errorf("trimRDNSBonjourPrefix(%q) = %v, want %v", test.in, got, test.want) - } - } -} - -func BenchmarkFull(b *testing.B) { - server := serveDNS(b, "127.0.0.1:0", - "test.site.", resolveToIP(testipv4, testipv6, "dns.test.site.")) - defer server.Shutdown() - - r := newResolver(b) - defer r.Close() - - cfg := dnsCfg - cfg.Routes = map[dnsname.FQDN][]*dnstype.Resolver{ - ".": {{Addr: server.PacketConn.LocalAddr().String()}}, - } - - tests := []struct { - name string - request []byte - }{ - {"forward", dnspacket("test1.ipn.dev.", dns.TypeA, noEdns)}, - {"reverse", dnspacket("4.3.2.1.in-addr.arpa.", dns.TypePTR, noEdns)}, - {"delegated", dnspacket("test.site.", dns.TypeA, noEdns)}, - } - - for _, tt := range tests { - b.Run(tt.name, func(b *testing.B) { - b.ReportAllocs() - for range b.N { - syncRespond(r, tt.request) - } - }) - } -} - -func TestMarshalResponseFormatError(t *testing.T) { - resp := new(response) - resp.Header.RCode = dns.RCodeFormatError - v, err := marshalResponse(resp) - if err != nil { - t.Errorf("marshal error: %v", err) - } - t.Logf("response: %q", v) -} - -func TestForwardLinkSelection(t *testing.T) { - configCall := make(chan string, 1) - tstest.Replace(t, &initListenConfig, func(nc *net.ListenConfig, netMon *netmon.Monitor, tunName string) error { - select { - case configCall <- tunName: - return nil - default: - t.Error("buffer full") - return errors.New("buffer full") - } - }) - - // specialIP is some IP we pretend that our link selector - // routes differently. - specialIP := netaddr.IPv4(1, 2, 3, 4) - - netMon, err := netmon.New(logger.WithPrefix(t.Logf, ".... netmon: ")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { netMon.Close() }) - - fwd := newForwarder(t.Logf, netMon, linkSelFunc(func(ip netip.Addr) string { - if ip == netaddr.IPv4(1, 2, 3, 4) { - return "special" - } - return "" - }), new(tsdial.Dialer), new(health.Tracker), nil /* no control knobs */) - - // Test non-special IP. - if got, err := fwd.packetListener(netip.Addr{}); err != nil { - t.Fatal(err) - } else if got != stdNetPacketListener { - t.Errorf("for IP zero value, didn't get expected packet listener") - } - select { - case v := <-configCall: - t.Errorf("unexpected ListenConfig call, with tunName %q", v) - default: - } - - // Test that our special IP generates a call to initListenConfig. - if got, err := fwd.packetListener(specialIP); err != nil { - t.Fatal(err) - } else if got == stdNetPacketListener { - t.Errorf("special IP returned std packet listener; expected unique one") - } - if v, ok := <-configCall; !ok { - t.Errorf("didn't get ListenConfig call") - } else if v != "special" { - t.Errorf("got tunName %q; want 'special'", v) - } -} - -type linkSelFunc func(ip netip.Addr) string - -func (f linkSelFunc) PickLink(ip netip.Addr) string { return f(ip) } - -func TestHandleExitNodeDNSQueryWithNetPkg(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("skipping test on Windows; waiting for golang.org/issue/33097") - } - - records := []any{ - "no-records.test.", - dnsHandler(), - - "one-a.test.", - dnsHandler(netip.MustParseAddr("1.2.3.4")), - - "two-a.test.", - dnsHandler(netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")), - - "one-aaaa.test.", - dnsHandler(netip.MustParseAddr("1::2")), - - "two-aaaa.test.", - dnsHandler(netip.MustParseAddr("1::2"), netip.MustParseAddr("3::4")), - - "nx-domain.test.", - resolveToNXDOMAIN, - - "4.3.2.1.in-addr.arpa.", - dnsHandler(miekdns.PTR{Ptr: "foo.com."}), - - "cname.test.", - weirdoGoCNAMEHandler("the-target.foo."), - - "txt.test.", - dnsHandler( - miekdns.TXT{Txt: []string{"txt1=one"}}, - miekdns.TXT{Txt: []string{"txt2=two"}}, - miekdns.TXT{Txt: []string{"txt3=three"}}, - ), - - "srv.test.", - dnsHandler( - miekdns.SRV{ - Priority: 1, - Weight: 2, - Port: 3, - Target: "foo.com.", - }, - miekdns.SRV{ - Priority: 4, - Weight: 5, - Port: 6, - Target: "bar.com.", - }, - ), - - "ns.test.", - dnsHandler(miekdns.NS{Ns: "ns1.foo."}, miekdns.NS{Ns: "ns2.bar."}), - } - v4server := serveDNS(t, "127.0.0.1:0", records...) - defer v4server.Shutdown() - - // backendResolver is the resolver between - // handleExitNodeDNSQueryWithNetPkg and its upstream resolver, - // which in this test's case is the miekg/dns test DNS server - // (v4server). - backResolver := &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { - var d net.Dialer - return d.DialContext(ctx, "udp", v4server.PacketConn.LocalAddr().String()) - }, - } - - t.Run("no_such_host", func(t *testing.T) { - res, err := handleExitNodeDNSQueryWithNetPkg(context.Background(), t.Logf, backResolver, &response{ - Header: dns.Header{ - ID: 123, - Response: true, - OpCode: 0, // query - }, - Question: dns.Question{ - Name: dns.MustNewName("nx-domain.test."), - Type: dns.TypeA, - Class: dns.ClassINET, - }, - }) - if err != nil { - t.Fatal(err) - } - if len(res) < dnsHeaderLen { - t.Fatal("short reply") - } - rcode := dns.RCode(res[3] & 0x0f) - if rcode != dns.RCodeNameError { - t.Errorf("RCode = %v; want dns.RCodeNameError", rcode) - t.Logf("Response was: %q", res) - } - }) - - matchPacked := func(want string) func(t testing.TB, got []byte) { - return func(t testing.TB, got []byte) { - if string(got) == want { - return - } - t.Errorf("unexpected reply.\n got: %q\nwant: %q\n", got, want) - t.Errorf("\nin hex:\n got: % 2x\nwant: % 2x\n", got, want) - } - } - - tests := []struct { - Type dns.Type - Name string - Check func(t testing.TB, got []byte) - }{ - { - Type: dns.TypeA, - Name: "one-a.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x01\x00\x00\x00\x00\x05one-a\x04test\x00\x00\x01\x00\x01\x05one-a\x04test\x00\x00\x01\x00\x01\x00\x00\x02X\x00\x04\x01\x02\x03\x04"), - }, - { - Type: dns.TypeA, - Name: "two-a.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x02\x00\x00\x00\x00\x05two-a\x04test\x00\x00\x01\x00\x01\xc0\f\x00\x01\x00\x01\x00\x00\x02X\x00\x04\x01\x02\x03\x04\xc0\f\x00\x01\x00\x01\x00\x00\x02X\x00\x04\x05\x06\a\b"), - }, - { - Type: dns.TypeAAAA, - Name: "one-aaaa.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x01\x00\x00\x00\x00\bone-aaaa\x04test\x00\x00\x1c\x00\x01\bone-aaaa\x04test\x00\x00\x1c\x00\x01\x00\x00\x02X\x00\x10\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02"), - }, - { - Type: dns.TypeAAAA, - Name: "two-aaaa.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x02\x00\x00\x00\x00\btwo-aaaa\x04test\x00\x00\x1c\x00\x01\xc0\f\x00\x1c\x00\x01\x00\x00\x02X\x00\x10\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xc0\f\x00\x1c\x00\x01\x00\x00\x02X\x00\x10\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04"), - }, - { - Type: dns.TypePTR, - Name: "4.3.2.1.in-addr.arpa.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x01\x00\x00\x00\x00\x014\x013\x012\x011\ain-addr\x04arpa\x00\x00\f\x00\x01\x014\x013\x012\x011\ain-addr\x04arpa\x00\x00\f\x00\x01\x00\x00\x02X\x00\t\x03foo\x03com\x00"), - }, - { - Type: dns.TypeCNAME, - Name: "cname.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x01\x00\x00\x00\x00\x05cname\x04test\x00\x00\x05\x00\x01\x05cname\x04test\x00\x00\x05\x00\x01\x00\x00\x02X\x00\x10\nthe-target\x03foo\x00"), - }, - - // No records of various types - { - Type: dns.TypeA, - Name: "no-records.test.", - Check: matchPacked("\x00{\x84\x03\x00\x01\x00\x00\x00\x00\x00\x00\nno-records\x04test\x00\x00\x01\x00\x01"), - }, - { - Type: dns.TypeAAAA, - Name: "no-records.test.", - Check: matchPacked("\x00{\x84\x03\x00\x01\x00\x00\x00\x00\x00\x00\nno-records\x04test\x00\x00\x1c\x00\x01"), - }, - { - Type: dns.TypeCNAME, - Name: "no-records.test.", - Check: matchPacked("\x00{\x84\x03\x00\x01\x00\x00\x00\x00\x00\x00\nno-records\x04test\x00\x00\x05\x00\x01"), - }, - { - Type: dns.TypeSRV, - Name: "no-records.test.", - Check: matchPacked("\x00{\x84\x03\x00\x01\x00\x00\x00\x00\x00\x00\nno-records\x04test\x00\x00!\x00\x01"), - }, - { - Type: dns.TypeTXT, - Name: "txt.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x03\x00\x00\x00\x00\x03txt\x04test\x00\x00\x10\x00\x01\x03txt\x04test\x00\x00\x10\x00\x01\x00\x00\x02X\x00\t\btxt1=one\x03txt\x04test\x00\x00\x10\x00\x01\x00\x00\x02X\x00\t\btxt2=two\x03txt\x04test\x00\x00\x10\x00\x01\x00\x00\x02X\x00\v\ntxt3=three"), - }, - { - Type: dns.TypeSRV, - Name: "srv.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x02\x00\x00\x00\x00\x03srv\x04test\x00\x00!\x00\x01\x03srv\x04test\x00\x00!\x00\x01\x00\x00\x02X\x00\x0f\x00\x01\x00\x02\x00\x03\x03foo\x03com\x00\x03srv\x04test\x00\x00!\x00\x01\x00\x00\x02X\x00\x0f\x00\x04\x00\x05\x00\x06\x03bar\x03com\x00"), - }, - { - Type: dns.TypeNS, - Name: "ns.test.", - Check: matchPacked("\x00{\x84\x00\x00\x01\x00\x02\x00\x00\x00\x00\x02ns\x04test\x00\x00\x02\x00\x01\x02ns\x04test\x00\x00\x02\x00\x01\x00\x00\x02X\x00\t\x03ns1\x03foo\x00\x02ns\x04test\x00\x00\x02\x00\x01\x00\x00\x02X\x00\t\x03ns2\x03bar\x00"), - }, - } - - for _, tt := range tests { - t.Run(fmt.Sprintf("%v_%v", tt.Type, strings.Trim(tt.Name, ".")), func(t *testing.T) { - got, err := handleExitNodeDNSQueryWithNetPkg(context.Background(), t.Logf, backResolver, &response{ - Header: dns.Header{ - ID: 123, - Response: true, - OpCode: 0, // query - }, - Question: dns.Question{ - Name: dns.MustNewName(tt.Name), - Type: tt.Type, - Class: dns.ClassINET, - }, - }) - if err != nil { - t.Fatal(err) - } - if len(got) < dnsHeaderLen { - t.Errorf("short record") - } - if tt.Check != nil { - tt.Check(t, got) - if t.Failed() { - t.Errorf("Got: %q\nIn hex: % 02x", got, got) - } - } - }) - } - - wrapRes := newWrapResolver(backResolver) - ctx := context.Background() - - t.Run("wrap_ip_a", func(t *testing.T) { - ips, err := wrapRes.LookupIP(ctx, "ip", "two-a.test.") - if err != nil { - t.Fatal(err) - } - if got, want := ips, []net.IP{ - net.ParseIP("1.2.3.4").To4(), - net.ParseIP("5.6.7.8").To4(), - }; !reflect.DeepEqual(got, want) { - t.Errorf("LookupIP = %v; want %v", got, want) - } - }) - - t.Run("wrap_ip_aaaa", func(t *testing.T) { - ips, err := wrapRes.LookupIP(ctx, "ip", "two-aaaa.test.") - if err != nil { - t.Fatal(err) - } - if got, want := ips, []net.IP{ - net.ParseIP("1::2"), - net.ParseIP("3::4"), - }; !reflect.DeepEqual(got, want) { - t.Errorf("LookupIP(v6) = %v; want %v", got, want) - } - }) - - t.Run("wrap_ip_nx", func(t *testing.T) { - ips, err := wrapRes.LookupIP(ctx, "ip", "nx-domain.test.") - if !isGoNoSuchHostError(err) { - t.Errorf("no NX domain = (%v, %v); want no host error", ips, err) - } - }) - - t.Run("wrap_srv", func(t *testing.T) { - _, srvs, err := wrapRes.LookupSRV(ctx, "", "", "srv.test.") - if err != nil { - t.Fatal(err) - } - if got, want := srvs, []*net.SRV{ - { - Target: "foo.com.", - Priority: 1, - Weight: 2, - Port: 3, - }, - { - Target: "bar.com.", - Priority: 4, - Weight: 5, - Port: 6, - }, - }; !reflect.DeepEqual(got, want) { - jgot, _ := json.Marshal(got) - jwant, _ := json.Marshal(want) - t.Errorf("SRV = %s; want %s", jgot, jwant) - } - }) - - t.Run("wrap_txt", func(t *testing.T) { - txts, err := wrapRes.LookupTXT(ctx, "txt.test.") - if err != nil { - t.Fatal(err) - } - if got, want := txts, []string{"txt1=one", "txt2=two", "txt3=three"}; !reflect.DeepEqual(got, want) { - t.Errorf("TXT = %q; want %q", got, want) - } - }) - - t.Run("wrap_ns", func(t *testing.T) { - nss, err := wrapRes.LookupNS(ctx, "ns.test.") - if err != nil { - t.Fatal(err) - } - if got, want := nss, []*net.NS{ - {Host: "ns1.foo."}, - {Host: "ns2.bar."}, - }; !reflect.DeepEqual(got, want) { - jgot, _ := json.Marshal(got) - jwant, _ := json.Marshal(want) - t.Errorf("NS = %s; want %s", jgot, jwant) - } - }) -} - -// newWrapResolver returns a resolver that uses r (via handleExitNodeDNSQueryWithNetPkg) -// to make DNS requests. -func newWrapResolver(r *net.Resolver) *net.Resolver { - if runtime.GOOS == "windows" { - panic("doesn't work on Windows") // golang.org/issue/33097 - } - return &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { - return &wrapResolverConn{ctx: ctx, r: r}, nil - }, - } -} - -type wrapResolverConn struct { - ctx context.Context - r *net.Resolver - buf bytes.Buffer -} - -var _ net.PacketConn = (*wrapResolverConn)(nil) - -func (*wrapResolverConn) Close() error { return nil } -func (*wrapResolverConn) LocalAddr() net.Addr { return fakeAddr{} } -func (*wrapResolverConn) RemoteAddr() net.Addr { return fakeAddr{} } -func (*wrapResolverConn) SetDeadline(t time.Time) error { return nil } -func (*wrapResolverConn) SetReadDeadline(t time.Time) error { return nil } -func (*wrapResolverConn) SetWriteDeadline(t time.Time) error { return nil } - -func (a *wrapResolverConn) Read(p []byte) (n int, err error) { - n, _, err = a.ReadFrom(p) - return -} - -func (a *wrapResolverConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { - n, err = a.buf.Read(p) - return n, fakeAddr{}, err -} - -func (a *wrapResolverConn) Write(packet []byte) (n int, err error) { - return a.WriteTo(packet, fakeAddr{}) -} - -func (a *wrapResolverConn) WriteTo(q []byte, _ net.Addr) (n int, err error) { - resp := parseExitNodeQuery(q) - if resp == nil { - return 0, errors.New("bad query") - } - res, err := handleExitNodeDNSQueryWithNetPkg(context.Background(), log.Printf, a.r, resp) - if err != nil { - return 0, err - } - a.buf.Write(res) - return len(q), nil -} - -type fakeAddr struct{} - -func (fakeAddr) Network() string { return "unused" } -func (fakeAddr) String() string { return "unused-todoAddr" } - -func TestUnARPA(t *testing.T) { - tests := []struct { - in, want string - }{ - {"", ""}, - {"bad", ""}, - {"4.4.8.8.in-addr.arpa.", "8.8.4.4"}, - {".in-addr.arpa.", ""}, - {"e.0.0.2.0.0.0.0.0.0.0.0.0.0.0.0.b.0.8.0.a.0.0.4.0.b.8.f.7.0.6.2.ip6.arpa.", "2607:f8b0:400a:80b::200e"}, - {".ip6.arpa.", ""}, - } - for _, tt := range tests { - got, ok := unARPA(tt.in) - if ok != (got != "") { - t.Errorf("inconsistent results for %q: (%q, %v)", tt.in, got, ok) - } - if got != tt.want { - t.Errorf("unARPA(%q) = %q; want %q", tt.in, got, tt.want) - } - } -} - -// TestServfail validates that a SERVFAIL error response is returned if -// all upstream resolvers respond with SERVFAIL. -// -// See: https://github.com/tailscale/tailscale/issues/4722 -func TestServfail(t *testing.T) { - server := serveDNS(t, "127.0.0.1:0", "test.site.", miekdns.HandlerFunc(func(w miekdns.ResponseWriter, req *miekdns.Msg) { - m := new(miekdns.Msg) - m.Rcode = miekdns.RcodeServerFailure - w.WriteMsg(m) - })) - defer server.Shutdown() - - r := newResolver(t) - defer r.Close() - - cfg := dnsCfg - cfg.Routes = map[dnsname.FQDN][]*dnstype.Resolver{ - ".": {{Addr: server.PacketConn.LocalAddr().String()}}, - } - r.SetConfig(cfg) - - pkt, err := syncRespond(r, dnspacket("test.site.", dns.TypeA, noEdns)) - if err != nil { - t.Fatalf("err = %v, want nil", err) - } - - wantPkt := []byte{ - 0x00, 0x00, // transaction id: 0 - 0x84, 0x02, // flags: response, authoritative, error: servfail - 0x00, 0x01, // one question - 0x00, 0x00, // no answers - 0x00, 0x00, 0x00, 0x00, // no authority or additional RRs - // Question: - 0x04, 0x74, 0x65, 0x73, 0x74, 0x04, 0x73, 0x69, 0x74, 0x65, 0x00, // name - 0x00, 0x01, 0x00, 0x01, // type A, class IN - } - - if !bytes.Equal(pkt, wantPkt) { - t.Errorf("response was %X, want %X", pkt, wantPkt) - } -} diff --git a/net/dns/utf_test.go b/net/dns/utf_test.go deleted file mode 100644 index b5fd372622519..0000000000000 --- a/net/dns/utf_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dns - -import "testing" - -func TestMaybeUnUTF16(t *testing.T) { - tests := []struct { - in string - want string - }{ - {"abc", "abc"}, // UTF-8 - {"a\x00b\x00c\x00", "abc"}, // UTF-16-LE - {"\x00a\x00b\x00c", "abc"}, // UTF-16-BE - } - - for _, test := range tests { - got := string(maybeUnUTF16([]byte(test.in))) - if got != test.want { - t.Errorf("maybeUnUTF16(%q) = %q, want %q", test.in, got, test.want) - } - } -} diff --git a/net/dns/wsl_windows.go b/net/dns/wsl_windows.go index 8b0780f55e17c..64fb7a888ae92 100644 --- a/net/dns/wsl_windows.go +++ b/net/dns/wsl_windows.go @@ -15,10 +15,10 @@ import ( "syscall" "time" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/winutil" "golang.org/x/sys/windows" - "tailscale.com/health" - "tailscale.com/types/logger" - "tailscale.com/util/winutil" ) // wslDistros reports the names of the installed WSL2 linux distributions. diff --git a/net/dnscache/dnscache.go b/net/dnscache/dnscache.go index 2cbea6c0fd896..56b863d15385d 100644 --- a/net/dnscache/dnscache.go +++ b/net/dnscache/dnscache.go @@ -18,11 +18,11 @@ import ( "sync/atomic" "time" - "tailscale.com/envknob" - "tailscale.com/types/logger" - "tailscale.com/util/cloudenv" - "tailscale.com/util/singleflight" - "tailscale.com/util/slicesx" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/cloudenv" + "github.com/sagernet/tailscale/util/singleflight" + "github.com/sagernet/tailscale/util/slicesx" ) var zaddr netip.Addr diff --git a/net/dnscache/dnscache_test.go b/net/dnscache/dnscache_test.go deleted file mode 100644 index ef4249b7401f3..0000000000000 --- a/net/dnscache/dnscache_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dnscache - -import ( - "context" - "errors" - "flag" - "fmt" - "net" - "net/netip" - "reflect" - "testing" - "time" - - "tailscale.com/tstest" -) - -var dialTest = flag.String("dial-test", "", "if non-empty, addr:port to test dial") - -func TestDialer(t *testing.T) { - if *dialTest == "" { - t.Skip("skipping; --dial-test is blank") - } - r := &Resolver{Logf: t.Logf} - var std net.Dialer - dialer := Dialer(std.DialContext, r) - t0 := time.Now() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - c, err := dialer(ctx, "tcp", *dialTest) - if err != nil { - t.Fatal(err) - } - t.Logf("dialed in %v", time.Since(t0)) - c.Close() -} - -func TestDialCall_DNSWasTrustworthy(t *testing.T) { - type step struct { - ip netip.Addr // IP we pretended to dial - err error // the dial error or nil for success - } - mustIP := netip.MustParseAddr - errFail := errors.New("some connect failure") - tests := []struct { - name string - steps []step - want bool - }{ - { - name: "no-info", - want: false, - }, - { - name: "previous-dial", - steps: []step{ - {mustIP("2003::1"), nil}, - {mustIP("2003::1"), errFail}, - }, - want: true, - }, - { - name: "no-previous-dial", - steps: []step{ - {mustIP("2003::1"), errFail}, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := &dialer{ - pastConnect: map[netip.Addr]time.Time{}, - } - dc := &dialCall{ - d: d, - } - for _, st := range tt.steps { - dc.noteDialResult(st.ip, st.err) - } - got := dc.dnsWasTrustworthy() - if got != tt.want { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } -} - -func TestDialCall_uniqueIPs(t *testing.T) { - dc := &dialCall{} - mustIP := netip.MustParseAddr - errFail := errors.New("some connect failure") - dc.noteDialResult(mustIP("2003::1"), errFail) - dc.noteDialResult(mustIP("2003::2"), errFail) - got := dc.uniqueIPs([]netip.Addr{ - mustIP("2003::1"), - mustIP("2003::2"), - mustIP("2003::2"), - mustIP("2003::3"), - mustIP("2003::3"), - mustIP("2003::4"), - mustIP("2003::4"), - }) - want := []netip.Addr{ - mustIP("2003::3"), - mustIP("2003::4"), - } - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v; want %v", got, want) - } -} - -func TestResolverAllHostStaticResult(t *testing.T) { - r := &Resolver{ - Logf: t.Logf, - SingleHost: "foo.bar", - SingleHostStaticResult: []netip.Addr{ - netip.MustParseAddr("2001:4860:4860::8888"), - netip.MustParseAddr("2001:4860:4860::8844"), - netip.MustParseAddr("8.8.8.8"), - netip.MustParseAddr("8.8.4.4"), - }, - } - ip4, ip6, allIPs, err := r.LookupIP(context.Background(), "foo.bar") - if err != nil { - t.Fatal(err) - } - if got, want := ip4.String(), "8.8.8.8"; got != want { - t.Errorf("ip4 got %q; want %q", got, want) - } - if got, want := ip6.String(), "2001:4860:4860::8888"; got != want { - t.Errorf("ip4 got %q; want %q", got, want) - } - if got, want := fmt.Sprintf("%q", allIPs), `["2001:4860:4860::8888" "2001:4860:4860::8844" "8.8.8.8" "8.8.4.4"]`; got != want { - t.Errorf("allIPs got %q; want %q", got, want) - } - - _, _, _, err = r.LookupIP(context.Background(), "bad") - if got, want := fmt.Sprint(err), `dnscache: unexpected hostname "bad" doesn't match expected "foo.bar"`; got != want { - t.Errorf("bad dial error got %q; want %q", got, want) - } -} - -func TestShouldTryBootstrap(t *testing.T) { - tstest.Replace(t, &debug, func() bool { return true }) - - type step struct { - ip netip.Addr // IP we pretended to dial - err error // the dial error or nil for success - } - - canceled, cancel := context.WithCancel(context.Background()) - cancel() - - deadlineExceeded, cancel := context.WithTimeout(context.Background(), 0) - defer cancel() - - ctx := context.Background() - errFailed := errors.New("some failure") - - cacheWithFallback := &Resolver{ - Logf: t.Logf, - LookupIPFallback: func(_ context.Context, _ string) ([]netip.Addr, error) { - panic("unimplemented") - }, - } - cacheNoFallback := &Resolver{Logf: t.Logf} - - testCases := []struct { - name string - steps []step - ctx context.Context - err error - noFallback bool - want bool - }{ - { - name: "no-error", - ctx: ctx, - err: nil, - want: false, - }, - { - name: "canceled", - ctx: canceled, - err: errFailed, - want: false, - }, - { - name: "deadline-exceeded", - ctx: deadlineExceeded, - err: errFailed, - want: false, - }, - { - name: "no-fallback", - ctx: ctx, - err: errFailed, - noFallback: true, - want: false, - }, - { - name: "dns-was-trustworthy", - ctx: ctx, - err: errFailed, - steps: []step{ - {netip.MustParseAddr("2003::1"), nil}, - {netip.MustParseAddr("2003::1"), errFailed}, - }, - want: false, - }, - { - name: "should-bootstrap", - ctx: ctx, - err: errFailed, - want: true, - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - d := &dialer{ - pastConnect: map[netip.Addr]time.Time{}, - } - if tt.noFallback { - d.dnsCache = cacheNoFallback - } else { - d.dnsCache = cacheWithFallback - } - dc := &dialCall{d: d} - for _, st := range tt.steps { - dc.noteDialResult(st.ip, st.err) - } - got := d.shouldTryBootstrap(tt.ctx, tt.err, dc) - if got != tt.want { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } -} diff --git a/net/dnscache/messagecache_test.go b/net/dnscache/messagecache_test.go deleted file mode 100644 index 41fc334483f78..0000000000000 --- a/net/dnscache/messagecache_test.go +++ /dev/null @@ -1,291 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dnscache - -import ( - "bytes" - "context" - "errors" - "fmt" - "net" - "runtime" - "testing" - "time" - - "golang.org/x/net/dns/dnsmessage" - "tailscale.com/tstest" -) - -func TestMessageCache(t *testing.T) { - clock := tstest.NewClock(tstest.ClockOpts{ - Start: time.Date(1987, 11, 1, 0, 0, 0, 0, time.UTC), - }) - mc := &MessageCache{Clock: clock.Now} - mc.SetMaxCacheSize(2) - clock.Advance(time.Second) - - var out bytes.Buffer - if err := mc.ReplyFromCache(&out, makeQ(1, "foo.com.")); err != ErrCacheMiss { - t.Fatalf("unexpected error: %v", err) - } - - if err := mc.AddCacheEntry( - makeQ(2, "foo.com."), - makeRes(2, "FOO.COM.", ttlOpt(10), - &dnsmessage.AResource{A: [4]byte{127, 0, 0, 1}}, - &dnsmessage.AResource{A: [4]byte{127, 0, 0, 2}})); err != nil { - t.Fatal(err) - } - - // Expect cache hit, with 10 seconds remaining. - out.Reset() - if err := mc.ReplyFromCache(&out, makeQ(3, "foo.com.")); err != nil { - t.Fatalf("expected cache hit; got: %v", err) - } - if p := mustParseResponse(t, out.Bytes()); p.TxID != 3 { - t.Errorf("TxID = %v; want %v", p.TxID, 3) - } else if p.TTL != 10 { - t.Errorf("TTL = %v; want 10", p.TTL) - } - - // One second elapses, expect a cache hit, with 9 seconds - // remaining. - clock.Advance(time.Second) - out.Reset() - if err := mc.ReplyFromCache(&out, makeQ(4, "foo.com.")); err != nil { - t.Fatalf("expected cache hit; got: %v", err) - } - if p := mustParseResponse(t, out.Bytes()); p.TxID != 4 { - t.Errorf("TxID = %v; want %v", p.TxID, 4) - } else if p.TTL != 9 { - t.Errorf("TTL = %v; want 9", p.TTL) - } - - // Expect cache miss on MX record. - if err := mc.ReplyFromCache(&out, makeQ(4, "foo.com.", dnsmessage.TypeMX)); err != ErrCacheMiss { - t.Fatalf("expected cache miss on MX; got: %v", err) - } - // Expect cache miss on CHAOS class. - if err := mc.ReplyFromCache(&out, makeQ(4, "foo.com.", dnsmessage.ClassCHAOS)); err != ErrCacheMiss { - t.Fatalf("expected cache miss on CHAOS; got: %v", err) - } - - // Ten seconds elapses; expect a cache miss. - clock.Advance(10 * time.Second) - if err := mc.ReplyFromCache(&out, makeQ(5, "foo.com.")); err != ErrCacheMiss { - t.Fatalf("expected cache miss, got: %v", err) - } -} - -type parsedMeta struct { - TxID uint16 - TTL uint32 -} - -func mustParseResponse(t testing.TB, r []byte) (ret parsedMeta) { - t.Helper() - var p dnsmessage.Parser - h, err := p.Start(r) - if err != nil { - t.Fatal(err) - } - ret.TxID = h.ID - qq, err := p.AllQuestions() - if err != nil { - t.Fatalf("AllQuestions: %v", err) - } - if len(qq) != 1 { - t.Fatalf("num questions = %v; want 1", len(qq)) - } - aa, err := p.AllAnswers() - if err != nil { - t.Fatalf("AllAnswers: %v", err) - } - for _, r := range aa { - if ret.TTL == 0 { - ret.TTL = r.Header.TTL - } - if ret.TTL != r.Header.TTL { - t.Fatal("mixed TTLs") - } - } - return ret -} - -type responseOpt bool - -type ttlOpt uint32 - -func makeQ(txID uint16, name string, opt ...any) []byte { - opt = append(opt, responseOpt(false)) - return makeDNSPkt(txID, name, opt...) -} - -func makeRes(txID uint16, name string, opt ...any) []byte { - opt = append(opt, responseOpt(true)) - return makeDNSPkt(txID, name, opt...) -} - -func makeDNSPkt(txID uint16, name string, opt ...any) []byte { - typ := dnsmessage.TypeA - class := dnsmessage.ClassINET - var response bool - var answers []dnsmessage.ResourceBody - var ttl uint32 = 1 // one second by default - for _, o := range opt { - switch o := o.(type) { - case dnsmessage.Type: - typ = o - case dnsmessage.Class: - class = o - case responseOpt: - response = bool(o) - case dnsmessage.ResourceBody: - answers = append(answers, o) - case ttlOpt: - ttl = uint32(o) - default: - panic(fmt.Sprintf("unknown opt type %T", o)) - } - } - qname := dnsmessage.MustNewName(name) - msg := dnsmessage.Message{ - Header: dnsmessage.Header{ID: txID, Response: response}, - Questions: []dnsmessage.Question{ - { - Name: qname, - Type: typ, - Class: class, - }, - }, - } - for _, rb := range answers { - msg.Answers = append(msg.Answers, dnsmessage.Resource{ - Header: dnsmessage.ResourceHeader{ - Name: qname, - Type: typ, - Class: class, - TTL: ttl, - }, - Body: rb, - }) - } - buf, err := msg.Pack() - if err != nil { - panic(err) - } - return buf -} - -func TestASCIILowerName(t *testing.T) { - n := asciiLowerName(dnsmessage.MustNewName("Foo.COM.")) - if got, want := n.String(), "foo.com."; got != want { - t.Errorf("got = %q; want %q", got, want) - } -} - -func TestGetDNSQueryCacheKey(t *testing.T) { - tests := []struct { - name string - pkt []byte - want msgQ - txID uint16 - anyTX bool - }{ - { - name: "empty", - }, - { - name: "a", - pkt: makeQ(123, "foo.com."), - want: msgQ{"foo.com.", dnsmessage.TypeA}, - txID: 123, - }, - { - name: "aaaa", - pkt: makeQ(6, "foo.com.", dnsmessage.TypeAAAA), - want: msgQ{"foo.com.", dnsmessage.TypeAAAA}, - txID: 6, - }, - { - name: "normalize_case", - pkt: makeQ(123, "FoO.CoM."), - want: msgQ{"foo.com.", dnsmessage.TypeA}, - txID: 123, - }, - { - name: "ignore_response", - pkt: makeRes(123, "foo.com."), - }, - { - name: "ignore_question_with_answers", - pkt: makeQ(2, "foo.com.", &dnsmessage.AResource{A: [4]byte{127, 0, 0, 1}}), - }, - { - name: "whatever_go_generates", // in case Go's net package grows functionality we don't handle - pkt: getGoNetPacketDNSQuery("from-go.foo."), - want: msgQ{"from-go.foo.", dnsmessage.TypeA}, - anyTX: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, gotTX, ok := getDNSQueryCacheKey(tt.pkt) - if !ok { - if tt.txID == 0 && got == (msgQ{}) { - return - } - t.Fatal("failed") - } - if got != tt.want { - t.Errorf("got %+v, want %+v", got, tt.want) - } - if gotTX != tt.txID && !tt.anyTX { - t.Errorf("got tx %v, want %v", gotTX, tt.txID) - } - }) - } -} - -func getGoNetPacketDNSQuery(name string) []byte { - if runtime.GOOS == "windows" { - // On Windows, Go's net.Resolver doesn't use the DNS client. - // See https://github.com/golang/go/issues/33097 which - // was approved but not yet implemented. - // For now just pretend it's implemented to make this test - // pass on Windows with complicated the caller. - return makeQ(123, name) - } - res := make(chan []byte, 1) - r := &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - return goResolverConn(res), nil - }, - } - r.LookupIP(context.Background(), "ip4", name) - return <-res -} - -type goResolverConn chan<- []byte - -func (goResolverConn) Close() error { return nil } -func (goResolverConn) LocalAddr() net.Addr { return todoAddr{} } -func (goResolverConn) RemoteAddr() net.Addr { return todoAddr{} } -func (goResolverConn) SetDeadline(t time.Time) error { return nil } -func (goResolverConn) SetReadDeadline(t time.Time) error { return nil } -func (goResolverConn) SetWriteDeadline(t time.Time) error { return nil } -func (goResolverConn) Read([]byte) (int, error) { return 0, errors.New("boom") } -func (c goResolverConn) Write(p []byte) (int, error) { - select { - case c <- p[2:]: // skip 2 byte length for TCP mode DNS query - default: - } - return 0, errors.New("boom") -} - -type todoAddr struct{} - -func (todoAddr) Network() string { return "unused" } -func (todoAddr) String() string { return "unused-todoAddr" } diff --git a/net/dnsfallback/dnsfallback.go b/net/dnsfallback/dnsfallback.go index 4c5d5fa2f2743..fad06b7f9ddb4 100644 --- a/net/dnsfallback/dnsfallback.go +++ b/net/dnsfallback/dnsfallback.go @@ -26,19 +26,19 @@ import ( "sync/atomic" "time" - "tailscale.com/atomicfile" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/dns/recursive" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/tlsdial" - "tailscale.com/net/tshttpproxy" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/singleflight" - "tailscale.com/util/slicesx" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/dns/recursive" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/tlsdial" + "github.com/sagernet/tailscale/net/tshttpproxy" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/singleflight" + "github.com/sagernet/tailscale/util/slicesx" ) var ( diff --git a/net/dnsfallback/dnsfallback_test.go b/net/dnsfallback/dnsfallback_test.go deleted file mode 100644 index 16f5027d4850f..0000000000000 --- a/net/dnsfallback/dnsfallback_test.go +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dnsfallback - -import ( - "context" - "encoding/json" - "flag" - "os" - "path/filepath" - "reflect" - "testing" - - "tailscale.com/net/netmon" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" -) - -func TestGetDERPMap(t *testing.T) { - dm := GetDERPMap() - if dm == nil { - t.Fatal("nil") - } - if len(dm.Regions) == 0 { - t.Fatal("no regions") - } -} - -func TestCache(t *testing.T) { - cacheFile := filepath.Join(t.TempDir(), "cache.json") - - // Write initial cache value - initialCache := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 99: { - RegionID: 99, - RegionCode: "test", - RegionName: "Testville", - Nodes: []*tailcfg.DERPNode{{ - Name: "99a", - RegionID: 99, - HostName: "derp99a.tailscale.com", - IPv4: "1.2.3.4", - }}, - }, - - // Intentionally attempt to "overwrite" something - 1: { - RegionID: 1, - RegionCode: "r1", - RegionName: "r1", - Nodes: []*tailcfg.DERPNode{{ - Name: "1c", - RegionID: 1, - HostName: "derp1c.tailscale.com", - IPv4: "127.0.0.1", - IPv6: "::1", - }}, - }, - }, - } - d, err := json.Marshal(initialCache) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cacheFile, d, 0666); err != nil { - t.Fatal(err) - } - - // Clear any existing cached DERP map(s) - cachedDERPMap.Store(nil) - - // Load the cache - SetCachePath(cacheFile, t.Logf) - if cm := cachedDERPMap.Load(); !reflect.DeepEqual(initialCache, cm) { - t.Fatalf("cached map was %+v; want %+v", cm, initialCache) - } - - // Verify that our DERP map is merged with the cache. - dm := GetDERPMap() - region, ok := dm.Regions[99] - if !ok { - t.Fatal("expected region 99") - } - if !reflect.DeepEqual(region, initialCache.Regions[99]) { - t.Fatalf("region 99: got %+v; want %+v", region, initialCache.Regions[99]) - } - - // Verify that our cache can't override a statically-baked-in DERP server. - n0 := dm.Regions[1].Nodes[0] - if n0.IPv4 == "127.0.0.1" || n0.IPv6 == "::1" { - t.Errorf("got %+v; expected no overwrite for node", n0) - } - - // Also, make sure that the static DERP map still has the same first - // node as when this test was last written/updated; this ensures that - // we don't accidentally start allowing overwrites due to some of the - // test's assumptions changing out from underneath us as we update the - // JSON file of fallback servers. - if getStaticDERPMap().Regions[1].Nodes[0].HostName != "derp1c.tailscale.com" { - t.Errorf("DERP server has a different name; please update this test") - } -} - -func TestCacheUnchanged(t *testing.T) { - cacheFile := filepath.Join(t.TempDir(), "cache.json") - - // Write initial cache value - initialCache := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 99: { - RegionID: 99, - RegionCode: "test", - RegionName: "Testville", - Nodes: []*tailcfg.DERPNode{{ - Name: "99a", - RegionID: 99, - HostName: "derp99a.tailscale.com", - IPv4: "1.2.3.4", - }}, - }, - }, - } - d, err := json.Marshal(initialCache) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cacheFile, d, 0666); err != nil { - t.Fatal(err) - } - - // Clear any existing cached DERP map(s) - cachedDERPMap.Store(nil) - - // Load the cache - SetCachePath(cacheFile, t.Logf) - if cm := cachedDERPMap.Load(); !reflect.DeepEqual(initialCache, cm) { - t.Fatalf("cached map was %+v; want %+v", cm, initialCache) - } - - // Remove the cache file on-disk, then re-set to the current value. If - // our equality comparison is working, we won't rewrite the file - // on-disk since the cached value won't have changed. - if err := os.Remove(cacheFile); err != nil { - t.Fatal(err) - } - - UpdateCache(initialCache, t.Logf) - if _, err := os.Stat(cacheFile); !os.IsNotExist(err) { - t.Fatalf("got err=%v; expected to not find cache file", err) - } - - // Now, update the cache with something slightly different and verify - // that we did re-write the file on-disk. - updatedCache := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 99: { - RegionID: 99, - RegionCode: "test", - RegionName: "Testville", - Nodes: []*tailcfg.DERPNode{ /* set below */ }, - }, - }, - } - clonedNode := *initialCache.Regions[99].Nodes[0] - clonedNode.IPv4 = "1.2.3.5" - updatedCache.Regions[99].Nodes = append(updatedCache.Regions[99].Nodes, &clonedNode) - - UpdateCache(updatedCache, t.Logf) - if st, err := os.Stat(cacheFile); err != nil { - t.Fatalf("could not stat cache file; err=%v", err) - } else if !st.Mode().IsRegular() || st.Size() == 0 { - t.Fatalf("didn't find non-empty regular file; mode=%v size=%d", st.Mode(), st.Size()) - } -} - -var extNetwork = flag.Bool("use-external-network", false, "use the external network in tests") - -func TestLookup(t *testing.T) { - if !*extNetwork { - t.Skip("skipping test without --use-external-network") - } - - logf, closeLogf := logger.LogfCloser(t.Logf) - defer closeLogf() - - netMon, err := netmon.New(logf) - if err != nil { - t.Fatal(err) - } - - resolver := &fallbackResolver{ - logf: logf, - netMon: netMon, - waitForCompare: true, - } - addrs, err := resolver.Lookup(context.Background(), "controlplane.tailscale.com") - if err != nil { - t.Fatal(err) - } - t.Logf("addrs: %+v", addrs) -} diff --git a/net/dnsfallback/update-dns-fallbacks.go b/net/dnsfallback/update-dns-fallbacks.go index 384e77e104cdc..82ede42cac9e6 100644 --- a/net/dnsfallback/update-dns-fallbacks.go +++ b/net/dnsfallback/update-dns-fallbacks.go @@ -12,7 +12,7 @@ import ( "net/http" "os" - "tailscale.com/tailcfg" + "github.com/sagernet/tailscale/tailcfg" ) func main() { diff --git a/net/flowtrack/flowtrack.go b/net/flowtrack/flowtrack.go index 8b3d799f7bbf4..c8f448fa0d40e 100644 --- a/net/flowtrack/flowtrack.go +++ b/net/flowtrack/flowtrack.go @@ -15,7 +15,7 @@ import ( "fmt" "net/netip" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/types/ipproto" ) // MakeTuple makes a Tuple out of netip.AddrPort values. diff --git a/net/flowtrack/flowtrack_test.go b/net/flowtrack/flowtrack_test.go deleted file mode 100644 index 1a13f7753a547..0000000000000 --- a/net/flowtrack/flowtrack_test.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package flowtrack - -import ( - "encoding/json" - "net/netip" - "testing" - - "tailscale.com/tstest" - "tailscale.com/types/ipproto" -) - -func TestCache(t *testing.T) { - c := &Cache[int]{MaxEntries: 2} - - k1 := MakeTuple(0, netip.MustParseAddrPort("1.1.1.1:1"), netip.MustParseAddrPort("1.1.1.1:1")) - k2 := MakeTuple(0, netip.MustParseAddrPort("1.1.1.1:1"), netip.MustParseAddrPort("2.2.2.2:2")) - k3 := MakeTuple(0, netip.MustParseAddrPort("1.1.1.1:1"), netip.MustParseAddrPort("3.3.3.3:3")) - k4 := MakeTuple(0, netip.MustParseAddrPort("1.1.1.1:1"), netip.MustParseAddrPort("4.4.4.4:4")) - - wantLen := func(want int) { - t.Helper() - if got := c.Len(); got != want { - t.Fatalf("Len = %d; want %d", got, want) - } - } - wantVal := func(key Tuple, want int) { - t.Helper() - got, ok := c.Get(key) - if !ok { - t.Fatalf("Get(%q) failed; want value %v", key, want) - } - if *got != want { - t.Fatalf("Get(%q) = %v; want %v", key, got, want) - } - } - wantMissing := func(key Tuple) { - t.Helper() - if got, ok := c.Get(key); ok { - t.Fatalf("Get(%q) = %v; want absent from cache", key, got) - } - } - - wantLen(0) - c.RemoveOldest() // shouldn't panic - c.Remove(k4) // shouldn't panic - - c.Add(k1, 1) - wantLen(1) - c.Add(k2, 2) - wantLen(2) - c.Add(k3, 3) - wantLen(2) // hit the max - - wantMissing(k1) - c.Remove(k1) - wantLen(2) // no change; k1 should've been the deleted one per LRU - - wantVal(k3, 3) - - wantVal(k2, 2) - c.Remove(k2) - wantLen(1) - wantMissing(k2) - - c.Add(k3, 30) - wantVal(k3, 30) - wantLen(1) - - err := tstest.MinAllocsPerRun(t, 0, func() { - got, ok := c.Get(k3) - if !ok { - t.Fatal("missing k3") - } - if *got != 30 { - t.Fatalf("got = %d; want 30", got) - } - }) - if err != nil { - t.Error(err) - } -} - -func BenchmarkMapKeys(b *testing.B) { - b.Run("typed", func(b *testing.B) { - c := &Cache[struct{}]{MaxEntries: 1000} - var t Tuple - for proto := range 20 { - t = Tuple{proto: ipproto.Proto(proto), src: netip.MustParseAddr("1.1.1.1").As16(), srcPort: 1, dst: netip.MustParseAddr("1.1.1.1").As16(), dstPort: 1} - c.Add(t, struct{}{}) - } - for i := 0; i < b.N; i++ { - _, ok := c.Get(t) - if !ok { - b.Fatal("missing key") - } - } - }) -} - -func TestStringJSON(t *testing.T) { - v := MakeTuple(123, - netip.MustParseAddrPort("1.2.3.4:5"), - netip.MustParseAddrPort("6.7.8.9:10")) - - if got, want := v.String(), "(IPProto-123 1.2.3.4:5 => 6.7.8.9:10)"; got != want { - t.Errorf("String = %q; want %q", got, want) - } - - got, err := json.Marshal(v) - if err != nil { - t.Fatal(err) - } - const want = `{"proto":123,"src":"1.2.3.4:5","dst":"6.7.8.9:10"}` - if string(got) != want { - t.Errorf("Marshal = %q; want %q", got, want) - } - - var back Tuple - if err := json.Unmarshal(got, &back); err != nil { - t.Fatal(err) - } - if back != v { - t.Errorf("back = %v; want %v", back, v) - } -} diff --git a/net/ipset/ipset.go b/net/ipset/ipset.go index 27c1e27ed4180..01a9521a7d980 100644 --- a/net/ipset/ipset.go +++ b/net/ipset/ipset.go @@ -9,8 +9,8 @@ import ( "net/netip" "github.com/gaissmai/bart" - "tailscale.com/types/views" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/set" ) // FalseContainsIPFunc is shorthand for NewContainsIPFunc(views.Slice[netip.Prefix]{}). diff --git a/net/ipset/ipset_test.go b/net/ipset/ipset_test.go deleted file mode 100644 index 2df4939cb99ad..0000000000000 --- a/net/ipset/ipset_test.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipset - -import ( - "net/netip" - "testing" - - "tailscale.com/tstest" - "tailscale.com/types/views" -) - -func pp(ss ...string) (ret []netip.Prefix) { - for _, s := range ss { - ret = append(ret, netip.MustParsePrefix(s)) - } - return -} - -func aa(ss ...string) (ret []netip.Addr) { - for _, s := range ss { - ret = append(ret, netip.MustParseAddr(s)) - } - return -} - -var newContainsIPFuncTests = []struct { - name string - pfx []netip.Prefix - want string - wantIn []netip.Addr - wantOut []netip.Addr -}{ - { - name: "empty", - pfx: pp(), - want: "empty", - wantOut: aa("8.8.8.8"), - }, - { - name: "cidr-list-1", - pfx: pp("10.0.0.0/8"), - want: "one-prefix", - wantIn: aa("10.0.0.1", "10.2.3.4"), - wantOut: aa("8.8.8.8"), - }, - { - name: "cidr-list-2", - pfx: pp("1.0.0.0/8", "3.0.0.0/8"), - want: "linear-contains", - wantIn: aa("1.0.0.1", "3.0.0.1"), - wantOut: aa("2.0.0.1"), - }, - { - name: "cidr-list-3", - pfx: pp("1.0.0.0/8", "3.0.0.0/8", "5.0.0.0/8"), - want: "linear-contains", - wantIn: aa("1.0.0.1", "5.0.0.1"), - wantOut: aa("2.0.0.1"), - }, - { - name: "cidr-list-4", - pfx: pp("1.0.0.0/8", "3.0.0.0/8", "5.0.0.0/8", "7.0.0.0/8"), - want: "linear-contains", - wantIn: aa("1.0.0.1", "7.0.0.1"), - wantOut: aa("2.0.0.1"), - }, - { - name: "cidr-list-5", - pfx: pp("1.0.0.0/8", "3.0.0.0/8", "5.0.0.0/8", "7.0.0.0/8", "9.0.0.0/8"), - want: "linear-contains", - wantIn: aa("1.0.0.1", "9.0.0.1"), - wantOut: aa("2.0.0.1"), - }, - { - name: "cidr-list-10", - pfx: pp("1.0.0.0/8", "3.0.0.0/8", "5.0.0.0/8", "7.0.0.0/8", "9.0.0.0/8", - "11.0.0.0/8", "13.0.0.0/8", "15.0.0.0/8", "17.0.0.0/8", "19.0.0.0/8"), - want: "bart", // big enough that bart is faster than linear-contains - wantIn: aa("1.0.0.1", "19.0.0.1"), - wantOut: aa("2.0.0.1"), - }, - { - name: "one-ip", - pfx: pp("10.1.0.0/32"), - want: "one-ip", - wantIn: aa("10.1.0.0"), - wantOut: aa("10.0.0.9"), - }, - { - name: "two-ip", - pfx: pp("10.1.0.0/32", "10.2.0.0/32"), - want: "two-ip", - wantIn: aa("10.1.0.0", "10.2.0.0"), - wantOut: aa("8.8.8.8"), - }, - { - name: "three-ip", - pfx: pp("10.1.0.0/32", "10.2.0.0/32", "10.3.0.0/32"), - want: "ip-map", - wantIn: aa("10.1.0.0", "10.2.0.0"), - wantOut: aa("8.8.8.8"), - }, -} - -func BenchmarkNewContainsIPFunc(b *testing.B) { - for _, tt := range newContainsIPFuncTests { - b.Run(tt.name, func(b *testing.B) { - f := NewContainsIPFunc(views.SliceOf(tt.pfx)) - for i := 0; i < b.N; i++ { - for _, ip := range tt.wantIn { - if !f(ip) { - b.Fatal("unexpected false") - } - } - for _, ip := range tt.wantOut { - if f(ip) { - b.Fatal("unexpected true") - } - } - } - }) - } -} - -func TestNewContainsIPFunc(t *testing.T) { - for _, tt := range newContainsIPFuncTests { - t.Run(tt.name, func(t *testing.T) { - var got string - tstest.Replace(t, &pathForTest, func(path string) { got = path }) - - f := NewContainsIPFunc(views.SliceOf(tt.pfx)) - if got != tt.want { - t.Errorf("func type = %q; want %q", got, tt.want) - } - for _, ip := range tt.wantIn { - if !f(ip) { - t.Errorf("match(%v) = false; want true", ip) - } - } - for _, ip := range tt.wantOut { - if f(ip) { - t.Errorf("match(%v) = true; want false", ip) - } - } - }) - } -} diff --git a/net/ktimeout/ktimeout_linux_test.go b/net/ktimeout/ktimeout_linux_test.go deleted file mode 100644 index a367bfd4a5a95..0000000000000 --- a/net/ktimeout/ktimeout_linux_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ktimeout - -import ( - "net" - "testing" - "time" - - "golang.org/x/net/nettest" - "golang.org/x/sys/unix" - "tailscale.com/util/must" -) - -func TestSetUserTimeout(t *testing.T) { - l := must.Get(nettest.NewLocalListener("tcp")) - defer l.Close() - - var err error - if e := must.Get(l.(*net.TCPListener).SyscallConn()).Control(func(fd uintptr) { - err = SetUserTimeout(fd, 0) - }); e != nil { - t.Fatal(e) - } - if err != nil { - t.Fatal(err) - } - v := must.Get(unix.GetsockoptInt(int(must.Get(l.(*net.TCPListener).File()).Fd()), unix.SOL_TCP, unix.TCP_USER_TIMEOUT)) - if v != 0 { - t.Errorf("TCP_USER_TIMEOUT: got %v; want 0", v) - } - - if e := must.Get(l.(*net.TCPListener).SyscallConn()).Control(func(fd uintptr) { - err = SetUserTimeout(fd, 30*time.Second) - }); e != nil { - t.Fatal(e) - } - if err != nil { - t.Fatal(err) - } - v = must.Get(unix.GetsockoptInt(int(must.Get(l.(*net.TCPListener).File()).Fd()), unix.SOL_TCP, unix.TCP_USER_TIMEOUT)) - if v != 30000 { - t.Errorf("TCP_USER_TIMEOUT: got %v; want 30000", v) - } -} diff --git a/net/ktimeout/ktimeout_test.go b/net/ktimeout/ktimeout_test.go deleted file mode 100644 index 7befa3b1ab077..0000000000000 --- a/net/ktimeout/ktimeout_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ktimeout - -import ( - "context" - "fmt" - "net" - "time" -) - -func ExampleUserTimeout() { - lc := net.ListenConfig{ - Control: UserTimeout(30 * time.Second), - } - l, err := lc.Listen(context.TODO(), "tcp", "127.0.0.1:0") - if err != nil { - fmt.Printf("error: %v", err) - return - } - l.Close() - // Output: -} diff --git a/net/memnet/conn_test.go b/net/memnet/conn_test.go deleted file mode 100644 index 743ce5248cb9d..0000000000000 --- a/net/memnet/conn_test.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package memnet - -import ( - "net" - "testing" - - "golang.org/x/net/nettest" -) - -func TestConn(t *testing.T) { - nettest.TestConn(t, func() (c1 net.Conn, c2 net.Conn, stop func(), err error) { - c1, c2 = NewConn("test", bufferSize) - return c1, c2, func() { - c1.Close() - c2.Close() - }, nil - }) -} diff --git a/net/memnet/listener_test.go b/net/memnet/listener_test.go deleted file mode 100644 index 73b67841ad08c..0000000000000 --- a/net/memnet/listener_test.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package memnet - -import ( - "context" - "testing" -) - -func TestListener(t *testing.T) { - l := Listen("srv.local") - defer l.Close() - go func() { - c, err := l.Accept() - if err != nil { - t.Error(err) - return - } - defer c.Close() - }() - - if c, err := l.Dial(context.Background(), "tcp", "invalid"); err == nil { - c.Close() - t.Fatalf("dial to invalid address succeeded") - } - c, err := l.Dial(context.Background(), "tcp", "srv.local") - if err != nil { - t.Fatalf("dial failed: %v", err) - return - } - c.Close() -} diff --git a/net/memnet/pipe_test.go b/net/memnet/pipe_test.go deleted file mode 100644 index a86d65388e27d..0000000000000 --- a/net/memnet/pipe_test.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package memnet - -import ( - "errors" - "fmt" - "os" - "testing" - "time" -) - -func TestPipeHello(t *testing.T) { - p := NewPipe("p1", 1<<16) - msg := "Hello, World!" - if n, err := p.Write([]byte(msg)); err != nil { - t.Fatal(err) - } else if n != len(msg) { - t.Errorf("p.Write(%q) n=%d, want %d", msg, n, len(msg)) - } - b := make([]byte, len(msg)) - if n, err := p.Read(b); err != nil { - t.Fatal(err) - } else if n != len(b) { - t.Errorf("p.Read(%q) n=%d, want %d", string(b[:n]), n, len(b)) - } - if got := string(b); got != msg { - t.Errorf("p.Read: %q, want %q", got, msg) - } -} - -func TestPipeTimeout(t *testing.T) { - t.Run("write", func(t *testing.T) { - p := NewPipe("p1", 1<<16) - p.SetWriteDeadline(time.Now().Add(-1 * time.Second)) - n, err := p.Write([]byte{'h'}) - if !errors.Is(err, os.ErrDeadlineExceeded) { - t.Errorf("missing write timeout got err: %v", err) - } - if n != 0 { - t.Errorf("n=%d on timeout", n) - } - }) - t.Run("read", func(t *testing.T) { - p := NewPipe("p1", 1<<16) - p.Write([]byte{'h'}) - - p.SetReadDeadline(time.Now().Add(-1 * time.Second)) - b := make([]byte, 1) - n, err := p.Read(b) - if !errors.Is(err, os.ErrDeadlineExceeded) { - t.Errorf("missing read timeout got err: %v", err) - } - if n != 0 { - t.Errorf("n=%d on timeout", n) - } - }) - t.Run("block-write", func(t *testing.T) { - p := NewPipe("p1", 1<<16) - p.SetWriteDeadline(time.Now().Add(10 * time.Millisecond)) - if err := p.Block(); err != nil { - t.Fatal(err) - } - if _, err := p.Write([]byte{'h'}); !errors.Is(err, os.ErrDeadlineExceeded) { - t.Fatalf("want write timeout got: %v", err) - } - }) - t.Run("block-read", func(t *testing.T) { - p := NewPipe("p1", 1<<16) - p.Write([]byte{'h', 'i'}) - p.SetReadDeadline(time.Now().Add(10 * time.Millisecond)) - b := make([]byte, 1) - if err := p.Block(); err != nil { - t.Fatal(err) - } - if _, err := p.Read(b); !errors.Is(err, os.ErrDeadlineExceeded) { - t.Fatalf("want read timeout got: %v", err) - } - }) -} - -func TestLimit(t *testing.T) { - p := NewPipe("p1", 1) - errCh := make(chan error) - go func() { - n, err := p.Write([]byte{'a', 'b', 'c'}) - if err != nil { - errCh <- err - } else if n != 3 { - errCh <- fmt.Errorf("p.Write n=%d, want 3", n) - } else { - errCh <- nil - } - }() - b := make([]byte, 3) - - if n, err := p.Read(b); err != nil { - t.Fatal(err) - } else if n != 1 { - t.Errorf("Read(%q): n=%d want 1", string(b), n) - } - if n, err := p.Read(b); err != nil { - t.Fatal(err) - } else if n != 1 { - t.Errorf("Read(%q): n=%d want 1", string(b), n) - } - if n, err := p.Read(b); err != nil { - t.Fatal(err) - } else if n != 1 { - t.Errorf("Read(%q): n=%d want 1", string(b), n) - } - - if err := <-errCh; err != nil { - t.Error(err) - } -} diff --git a/net/netcheck/netcheck.go b/net/netcheck/netcheck.go index 2c429862eb133..1cb9b52d92866 100644 --- a/net/netcheck/netcheck.go +++ b/net/netcheck/netcheck.go @@ -23,26 +23,26 @@ import ( "syscall" "time" + "github.com/sagernet/tailscale/derp/derphttp" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/captivedetection" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/neterror" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/ping" + "github.com/sagernet/tailscale/net/portmapper" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/mak" "github.com/tcnksm/go-httpstat" - "tailscale.com/derp/derphttp" - "tailscale.com/envknob" - "tailscale.com/net/captivedetection" - "tailscale.com/net/dnscache" - "tailscale.com/net/neterror" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/ping" - "tailscale.com/net/portmapper" - "tailscale.com/net/sockstats" - "tailscale.com/net/stun" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/types/nettype" - "tailscale.com/types/opt" - "tailscale.com/types/views" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" ) // Debugging and experimentation tweakables. diff --git a/net/netcheck/netcheck_test.go b/net/netcheck/netcheck_test.go deleted file mode 100644 index b4fbb4023dcc1..0000000000000 --- a/net/netcheck/netcheck_test.go +++ /dev/null @@ -1,962 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netcheck - -import ( - "bytes" - "context" - "fmt" - "maps" - "net" - "net/http" - "net/netip" - "reflect" - "slices" - "strconv" - "strings" - "testing" - "time" - - "tailscale.com/net/netmon" - "tailscale.com/net/stun/stuntest" - "tailscale.com/tailcfg" - "tailscale.com/tstest/nettest" -) - -func newTestClient(t testing.TB) *Client { - c := &Client{ - NetMon: netmon.NewStatic(), - Logf: t.Logf, - TimeNow: func() time.Time { - return time.Unix(1729624521, 0) - }, - } - return c -} - -func TestBasic(t *testing.T) { - stunAddr, cleanup := stuntest.Serve(t) - defer cleanup() - - c := newTestClient(t) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := c.Standalone(ctx, "127.0.0.1:0"); err != nil { - t.Fatal(err) - } - - r, err := c.GetReport(ctx, stuntest.DERPMapOf(stunAddr.String()), nil) - if err != nil { - t.Fatal(err) - } - if !r.UDP { - t.Error("want UDP") - } - if r.Now.IsZero() { - t.Error("Now is zero") - } - if len(r.RegionLatency) != 1 { - t.Errorf("expected 1 key in DERPLatency; got %+v", r.RegionLatency) - } - if _, ok := r.RegionLatency[1]; !ok { - t.Errorf("expected key 1 in DERPLatency; got %+v", r.RegionLatency) - } - if !r.GlobalV4.IsValid() { - t.Error("expected GlobalV4 set") - } - if r.PreferredDERP != 1 { - t.Errorf("PreferredDERP = %v; want 1", r.PreferredDERP) - } - v4Addrs, _ := r.GetGlobalAddrs() - if len(v4Addrs) != 1 { - t.Error("expected one global IPv4 address") - } - if got, want := v4Addrs[0], r.GlobalV4; got != want { - t.Errorf("got %v; want %v", got, want) - } -} - -func TestMultiGlobalAddressMapping(t *testing.T) { - c := &Client{ - Logf: t.Logf, - } - - rs := &reportState{ - c: c, - start: time.Now(), - report: newReport(), - } - derpNode := &tailcfg.DERPNode{} - port1 := netip.MustParseAddrPort("127.0.0.1:1234") - port2 := netip.MustParseAddrPort("127.0.0.1:2345") - port3 := netip.MustParseAddrPort("127.0.0.1:3456") - // First report for port1 - rs.addNodeLatency(derpNode, port1, 10*time.Millisecond) - // Singular report for port2 - rs.addNodeLatency(derpNode, port2, 11*time.Millisecond) - // Duplicate reports for port3 - rs.addNodeLatency(derpNode, port3, 12*time.Millisecond) - rs.addNodeLatency(derpNode, port3, 13*time.Millisecond) - - r := rs.report - v4Addrs, _ := r.GetGlobalAddrs() - wantV4Addrs := []netip.AddrPort{port1, port3} - if !slices.Equal(v4Addrs, wantV4Addrs) { - t.Errorf("got global addresses: %v, want %v", v4Addrs, wantV4Addrs) - } -} - -func TestWorksWhenUDPBlocked(t *testing.T) { - blackhole, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to open blackhole STUN listener: %v", err) - } - defer blackhole.Close() - - stunAddr := blackhole.LocalAddr().String() - - dm := stuntest.DERPMapOf(stunAddr) - dm.Regions[1].Nodes[0].STUNOnly = true - - c := newTestClient(t) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - r, err := c.GetReport(ctx, dm, nil) - if err != nil { - t.Fatal(err) - } - r.UPnP = "" - r.PMP = "" - r.PCP = "" - - want := newReport() - - // The Now field can't be compared with reflect.DeepEqual; check using - // the Equal method and then overwrite it so that the comparison below - // succeeds. - if !r.Now.Equal(c.TimeNow()) { - t.Errorf("Now = %v; want %v", r.Now, c.TimeNow()) - } - want.Now = r.Now - - // The IPv4CanSend flag gets set differently across platforms. - // On Windows this test detects false, while on Linux detects true. - // That's not relevant to this test, so just accept what we're - // given. - want.IPv4CanSend = r.IPv4CanSend - // OS IPv6 test is irrelevant here, accept whatever the current - // machine has. - want.OSHasIPv6 = r.OSHasIPv6 - // Captive portal test is irrelevant; accept what the current report - // has. - want.CaptivePortal = r.CaptivePortal - - if !reflect.DeepEqual(r, want) { - t.Errorf("mismatch\n got: %+v\nwant: %+v\n", r, want) - } -} - -func TestAddReportHistoryAndSetPreferredDERP(t *testing.T) { - // report returns a *Report from (DERP host, time.Duration)+ pairs. - report := func(a ...any) *Report { - r := &Report{RegionLatency: map[int]time.Duration{}} - for i := 0; i < len(a); i += 2 { - s := a[i].(string) - if !strings.HasPrefix(s, "d") { - t.Fatalf("invalid derp server key %q", s) - } - regionID, err := strconv.Atoi(s[1:]) - if err != nil { - t.Fatalf("invalid derp server key %q", s) - } - - switch v := a[i+1].(type) { - case time.Duration: - r.RegionLatency[regionID] = v - case int: - r.RegionLatency[regionID] = time.Second * time.Duration(v) - default: - panic(fmt.Sprintf("unexpected type %T", v)) - } - } - return r - } - mkLDAFunc := func(mm map[int]time.Time) func(int) time.Time { - return func(region int) time.Time { - return mm[region] - } - } - type step struct { - after time.Duration - r *Report - } - startTime := time.Unix(123, 0) - tests := []struct { - name string - steps []step - homeParams *tailcfg.DERPHomeParams - opts *GetReportOpts - wantDERP int // want PreferredDERP on final step - wantPrevLen int // wanted len(c.prev) - }{ - { - name: "first_reading", - steps: []step{ - {0, report("d1", 2, "d2", 3)}, - }, - wantPrevLen: 1, - wantDERP: 1, - }, - { - name: "with_two", - steps: []step{ - {0, report("d1", 2, "d2", 3)}, - {1 * time.Second, report("d1", 4, "d2", 3)}, - }, - wantPrevLen: 2, - wantDERP: 1, // t0's d1 of 2 is still best - }, - { - name: "but_now_d1_gone", - steps: []step{ - {0, report("d1", 2, "d2", 3)}, - {1 * time.Second, report("d1", 4, "d2", 3)}, - {2 * time.Second, report("d2", 3)}, - }, - wantPrevLen: 3, - wantDERP: 2, // only option - }, - { - name: "d1_is_back", - steps: []step{ - {0, report("d1", 2, "d2", 3)}, - {1 * time.Second, report("d1", 4, "d2", 3)}, - {2 * time.Second, report("d2", 3)}, - {3 * time.Second, report("d1", 4, "d2", 3)}, // same as 2 seconds ago - }, - wantPrevLen: 4, - wantDERP: 1, // t0's d1 of 2 is still best - }, - { - name: "things_clean_up", - steps: []step{ - {0, report("d1", 1, "d2", 2)}, - {1 * time.Second, report("d1", 1, "d2", 2)}, - {2 * time.Second, report("d1", 1, "d2", 2)}, - {3 * time.Second, report("d1", 1, "d2", 2)}, - {10 * time.Minute, report("d3", 3)}, - }, - wantPrevLen: 1, // t=[0123]s all gone. (too old, older than 10 min) - wantDERP: 3, // only option - }, - { - name: "preferred_derp_hysteresis_no_switch", - steps: []step{ - {0 * time.Second, report("d1", 4, "d2", 5)}, - {1 * time.Second, report("d1", 4, "d2", 3)}, - }, - wantPrevLen: 2, - wantDERP: 1, // 2 didn't get fast enough - }, - { - name: "preferred_derp_hysteresis_no_switch_absolute", - steps: []step{ - {0 * time.Second, report("d1", 4*time.Millisecond, "d2", 5*time.Millisecond)}, - {1 * time.Second, report("d1", 4*time.Millisecond, "d2", 1*time.Millisecond)}, - }, - wantPrevLen: 2, - wantDERP: 1, // 2 is 50%+ faster, but the absolute diff is <10ms - }, - { - name: "preferred_derp_hysteresis_do_switch", - steps: []step{ - {0 * time.Second, report("d1", 4, "d2", 5)}, - {1 * time.Second, report("d1", 4, "d2", 1)}, - }, - wantPrevLen: 2, - wantDERP: 2, // 2 got fast enough - }, - { - name: "derp_home_params", - homeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{ - 1: 2.0 / 3, // 66% - }, - }, - steps: []step{ - // We only use a single step here to avoid - // conflating DERP selection as a result of - // weight hints with the "stickiness" check - // that tries to not change the home DERP - // between steps. - {1 * time.Second, report("d1", 10, "d2", 8)}, - }, - wantPrevLen: 1, - wantDERP: 1, // 2 was faster, but not by 50%+ - }, - { - name: "derp_home_params_high_latency", - homeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{ - 1: 2.0 / 3, // 66% - }, - }, - steps: []step{ - // See derp_home_params for why this is a single step. - {1 * time.Second, report("d1", 100, "d2", 10)}, - }, - wantPrevLen: 1, - wantDERP: 2, // 2 was faster by more than 50% - }, - { - name: "derp_home_params_invalid", - homeParams: &tailcfg.DERPHomeParams{ - RegionScore: map[int]float64{ - 1: 0.0, - 2: -1.0, - }, - }, - steps: []step{ - {1 * time.Second, report("d1", 4, "d2", 5)}, - }, - wantPrevLen: 1, - wantDERP: 1, - }, - { - name: "saw_derp_traffic", - steps: []step{ - {0, report("d1", 2, "d2", 3)}, // (1) initially pick d1 - {2 * time.Second, report("d1", 4, "d2", 3)}, // (2) still d1 - {2 * time.Second, report("d2", 3)}, // (3) d1 gone, but have traffic - }, - opts: &GetReportOpts{ - GetLastDERPActivity: mkLDAFunc(map[int]time.Time{ - 1: startTime.Add(2*time.Second + PreferredDERPFrameTime/2), // within active window of step (3) - }), - }, - wantPrevLen: 3, - wantDERP: 1, // still on 1 since we got traffic from it - }, - { - name: "saw_derp_traffic_history", - steps: []step{ - {0, report("d1", 2, "d2", 3)}, // (1) initially pick d1 - {2 * time.Second, report("d1", 4, "d2", 3)}, // (2) still d1 - {2 * time.Second, report("d2", 3)}, // (3) d1 gone, but have traffic - }, - opts: &GetReportOpts{ - GetLastDERPActivity: mkLDAFunc(map[int]time.Time{ - 1: startTime.Add(4*time.Second - PreferredDERPFrameTime - 1), // not within active window of (3) - }), - }, - wantPrevLen: 3, - wantDERP: 2, // moved to d2 since d1 is gone - }, - { - name: "preferred_derp_hysteresis_no_switch_pct", - steps: []step{ - {0 * time.Second, report("d1", 34*time.Millisecond, "d2", 35*time.Millisecond)}, - {1 * time.Second, report("d1", 34*time.Millisecond, "d2", 23*time.Millisecond)}, - }, - wantPrevLen: 2, - wantDERP: 1, // diff is 11ms, but d2 is greater than 2/3s of d1 - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fakeTime := startTime - c := &Client{ - TimeNow: func() time.Time { return fakeTime }, - } - dm := &tailcfg.DERPMap{HomeParams: tt.homeParams} - rs := &reportState{ - c: c, - start: fakeTime, - opts: tt.opts, - } - for _, s := range tt.steps { - fakeTime = fakeTime.Add(s.after) - rs.start = fakeTime.Add(-100 * time.Millisecond) - c.addReportHistoryAndSetPreferredDERP(rs, s.r, dm.View()) - } - lastReport := tt.steps[len(tt.steps)-1].r - if got, want := len(c.prev), tt.wantPrevLen; got != want { - t.Errorf("len(prev) = %v; want %v", got, want) - } - if got, want := lastReport.PreferredDERP, tt.wantDERP; got != want { - t.Errorf("PreferredDERP = %v; want %v", got, want) - } - }) - } -} - -func TestMakeProbePlan(t *testing.T) { - // basicMap has 5 regions. each region has a number of nodes - // equal to the region number (1 has 1a, 2 has 2a and 2b, etc.) - basicMap := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{}, - } - for rid := 1; rid <= 5; rid++ { - var nodes []*tailcfg.DERPNode - for nid := 0; nid < rid; nid++ { - nodes = append(nodes, &tailcfg.DERPNode{ - Name: fmt.Sprintf("%d%c", rid, 'a'+rune(nid)), - RegionID: rid, - HostName: fmt.Sprintf("derp%d-%d", rid, nid), - IPv4: fmt.Sprintf("%d.0.0.%d", rid, nid), - IPv6: fmt.Sprintf("%d::%d", rid, nid), - }) - } - basicMap.Regions[rid] = &tailcfg.DERPRegion{ - RegionID: rid, - Nodes: nodes, - } - } - - const ms = time.Millisecond - p := func(name string, c rune, d ...time.Duration) probe { - var proto probeProto - switch c { - case 4: - proto = probeIPv4 - case 6: - proto = probeIPv6 - case 'h': - proto = probeHTTPS - } - pr := probe{node: name, proto: proto} - if len(d) == 1 { - pr.delay = d[0] - } else if len(d) > 1 { - panic("too many args") - } - return pr - } - tests := []struct { - name string - dm *tailcfg.DERPMap - have6if bool - no4 bool // no IPv4 - last *Report - want probePlan - }{ - { - name: "initial_v6", - dm: basicMap, - have6if: true, - last: nil, // initial - want: probePlan{ - "region-1-v4": []probe{p("1a", 4), p("1a", 4, 100*ms), p("1a", 4, 200*ms)}, // all a - "region-1-v6": []probe{p("1a", 6), p("1a", 6, 100*ms), p("1a", 6, 200*ms)}, - "region-2-v4": []probe{p("2a", 4), p("2b", 4, 100*ms), p("2a", 4, 200*ms)}, // a -> b -> a - "region-2-v6": []probe{p("2a", 6), p("2b", 6, 100*ms), p("2a", 6, 200*ms)}, - "region-3-v4": []probe{p("3a", 4), p("3b", 4, 100*ms), p("3c", 4, 200*ms)}, // a -> b -> c - "region-3-v6": []probe{p("3a", 6), p("3b", 6, 100*ms), p("3c", 6, 200*ms)}, - "region-4-v4": []probe{p("4a", 4), p("4b", 4, 100*ms), p("4c", 4, 200*ms)}, - "region-4-v6": []probe{p("4a", 6), p("4b", 6, 100*ms), p("4c", 6, 200*ms)}, - "region-5-v4": []probe{p("5a", 4), p("5b", 4, 100*ms), p("5c", 4, 200*ms)}, - "region-5-v6": []probe{p("5a", 6), p("5b", 6, 100*ms), p("5c", 6, 200*ms)}, - }, - }, - { - name: "initial_no_v6", - dm: basicMap, - have6if: false, - last: nil, // initial - want: probePlan{ - "region-1-v4": []probe{p("1a", 4), p("1a", 4, 100*ms), p("1a", 4, 200*ms)}, // all a - "region-2-v4": []probe{p("2a", 4), p("2b", 4, 100*ms), p("2a", 4, 200*ms)}, // a -> b -> a - "region-3-v4": []probe{p("3a", 4), p("3b", 4, 100*ms), p("3c", 4, 200*ms)}, // a -> b -> c - "region-4-v4": []probe{p("4a", 4), p("4b", 4, 100*ms), p("4c", 4, 200*ms)}, - "region-5-v4": []probe{p("5a", 4), p("5b", 4, 100*ms), p("5c", 4, 200*ms)}, - }, - }, - { - name: "second_v4_no_6if", - dm: basicMap, - have6if: false, - last: &Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - // Pretend 5 is missing - }, - RegionV4Latency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - }, - }, - want: probePlan{ - "region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms)}, - "region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)}, - "region-3-v4": []probe{p("3a", 4)}, - }, - }, - { - name: "second_v4_only_with_6if", - dm: basicMap, - have6if: true, - last: &Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - // Pretend 5 is missing - }, - RegionV4Latency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - }, - }, - want: probePlan{ - "region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms)}, - "region-1-v6": []probe{p("1a", 6)}, - "region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)}, - "region-2-v6": []probe{p("2a", 6)}, - "region-3-v4": []probe{p("3a", 4)}, - }, - }, - { - name: "second_mixed", - dm: basicMap, - have6if: true, - last: &Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - // Pretend 5 is missing - }, - RegionV4Latency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - }, - RegionV6Latency: map[int]time.Duration{ - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - }, - }, - want: probePlan{ - "region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms)}, - "region-1-v6": []probe{p("1a", 6), p("1a", 6, 12*ms)}, - "region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)}, - "region-2-v6": []probe{p("2a", 6), p("2b", 6, 24*ms)}, - "region-3-v4": []probe{p("3a", 4)}, - }, - }, - { - name: "only_v6_initial", - have6if: true, - no4: true, - dm: basicMap, - want: probePlan{ - "region-1-v6": []probe{p("1a", 6), p("1a", 6, 100*ms), p("1a", 6, 200*ms)}, - "region-2-v6": []probe{p("2a", 6), p("2b", 6, 100*ms), p("2a", 6, 200*ms)}, - "region-3-v6": []probe{p("3a", 6), p("3b", 6, 100*ms), p("3c", 6, 200*ms)}, - "region-4-v6": []probe{p("4a", 6), p("4b", 6, 100*ms), p("4c", 6, 200*ms)}, - "region-5-v6": []probe{p("5a", 6), p("5b", 6, 100*ms), p("5c", 6, 200*ms)}, - }, - }, - { - name: "try_harder_for_preferred_derp", - dm: basicMap, - have6if: true, - last: &Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - }, - RegionV4Latency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 20 * time.Millisecond, - }, - RegionV6Latency: map[int]time.Duration{ - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - }, - PreferredDERP: 1, - }, - want: probePlan{ - "region-1-v4": []probe{p("1a", 4), p("1a", 4, 12*ms), p("1a", 4, 124*ms), p("1a", 4, 186*ms)}, - "region-1-v6": []probe{p("1a", 6), p("1a", 6, 12*ms), p("1a", 6, 124*ms), p("1a", 6, 186*ms)}, - "region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)}, - "region-2-v6": []probe{p("2a", 6), p("2b", 6, 24*ms)}, - "region-3-v4": []probe{p("3a", 4)}, - }, - }, - { - // #13969: ensure that the prior/current home region is always included in - // probe plans, so that we don't flap between regions due to a single major - // netcheck having excluded the home region due to a spuriously high sample. - name: "ensure_home_region_inclusion", - dm: basicMap, - have6if: true, - last: &Report{ - RegionLatency: map[int]time.Duration{ - 1: 50 * time.Millisecond, - 2: 20 * time.Millisecond, - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - }, - RegionV4Latency: map[int]time.Duration{ - 1: 50 * time.Millisecond, - 2: 20 * time.Millisecond, - }, - RegionV6Latency: map[int]time.Duration{ - 3: 30 * time.Millisecond, - 4: 40 * time.Millisecond, - }, - PreferredDERP: 1, - }, - want: probePlan{ - "region-1-v4": []probe{p("1a", 4), p("1a", 4, 60*ms), p("1a", 4, 220*ms), p("1a", 4, 330*ms)}, - "region-1-v6": []probe{p("1a", 6), p("1a", 6, 60*ms), p("1a", 6, 220*ms), p("1a", 6, 330*ms)}, - "region-2-v4": []probe{p("2a", 4), p("2b", 4, 24*ms)}, - "region-2-v6": []probe{p("2a", 6), p("2b", 6, 24*ms)}, - "region-3-v4": []probe{p("3a", 4), p("3b", 4, 36*ms)}, - "region-3-v6": []probe{p("3a", 6), p("3b", 6, 36*ms)}, - "region-4-v4": []probe{p("4a", 4)}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ifState := &netmon.State{ - HaveV6: tt.have6if, - HaveV4: !tt.no4, - } - preferredDERP := 0 - if tt.last != nil { - preferredDERP = tt.last.PreferredDERP - } - got := makeProbePlan(tt.dm, ifState, tt.last, preferredDERP) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("unexpected plan; got:\n%v\nwant:\n%v\n", got, tt.want) - } - }) - } -} - -func (plan probePlan) String() string { - var sb strings.Builder - for _, key := range slices.Sorted(maps.Keys(plan)) { - fmt.Fprintf(&sb, "[%s]", key) - pv := plan[key] - for _, p := range pv { - fmt.Fprintf(&sb, " %v", p) - } - sb.WriteByte('\n') - } - return sb.String() -} - -func (p probe) String() string { - wait := "" - if p.wait > 0 { - wait = "+" + p.wait.String() - } - delay := "" - if p.delay > 0 { - delay = "@" + p.delay.String() - } - return fmt.Sprintf("%s-%s%s%s", p.node, p.proto, delay, wait) -} - -func TestLogConciseReport(t *testing.T) { - dm := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: nil, - 2: nil, - 3: nil, - }, - } - const ms = time.Millisecond - tests := []struct { - name string - r *Report - want string - }{ - { - name: "no_udp", - r: &Report{}, - want: "udp=false v4=false icmpv4=false v6=false mapvarydest= portmap=? derp=0", - }, - { - name: "no_udp_icmp", - r: &Report{ICMPv4: true, IPv4: true}, - want: "udp=false icmpv4=true v6=false mapvarydest= portmap=? derp=0", - }, - { - name: "ipv4_one_region", - r: &Report{ - UDP: true, - IPv4: true, - PreferredDERP: 1, - RegionLatency: map[int]time.Duration{ - 1: 10 * ms, - }, - RegionV4Latency: map[int]time.Duration{ - 1: 10 * ms, - }, - }, - want: "udp=true v6=false mapvarydest= portmap=? derp=1 derpdist=1v4:10ms", - }, - { - name: "ipv4_all_region", - r: &Report{ - UDP: true, - IPv4: true, - PreferredDERP: 1, - RegionLatency: map[int]time.Duration{ - 1: 10 * ms, - 2: 20 * ms, - 3: 30 * ms, - }, - RegionV4Latency: map[int]time.Duration{ - 1: 10 * ms, - 2: 20 * ms, - 3: 30 * ms, - }, - }, - want: "udp=true v6=false mapvarydest= portmap=? derp=1 derpdist=1v4:10ms,2v4:20ms,3v4:30ms", - }, - { - name: "ipboth_all_region", - r: &Report{ - UDP: true, - IPv4: true, - IPv6: true, - PreferredDERP: 1, - RegionLatency: map[int]time.Duration{ - 1: 10 * ms, - 2: 20 * ms, - 3: 30 * ms, - }, - RegionV4Latency: map[int]time.Duration{ - 1: 10 * ms, - 2: 20 * ms, - 3: 30 * ms, - }, - RegionV6Latency: map[int]time.Duration{ - 1: 10 * ms, - 2: 20 * ms, - 3: 30 * ms, - }, - }, - want: "udp=true v6=true mapvarydest= portmap=? derp=1 derpdist=1v4:10ms,1v6:10ms,2v4:20ms,2v6:20ms,3v4:30ms,3v6:30ms", - }, - { - name: "portmap_all", - r: &Report{ - UDP: true, - UPnP: "true", - PMP: "true", - PCP: "true", - }, - want: "udp=true v4=false v6=false mapvarydest= portmap=UMC derp=0", - }, - { - name: "portmap_some", - r: &Report{ - UDP: true, - UPnP: "true", - PMP: "false", - PCP: "true", - }, - want: "udp=true v4=false v6=false mapvarydest= portmap=UC derp=0", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - c := &Client{Logf: func(f string, a ...any) { fmt.Fprintf(&buf, f, a...) }} - c.logConciseReport(tt.r, dm) - if got, ok := strings.CutPrefix(buf.String(), "[v1] report: "); !ok { - t.Errorf("unexpected result.\n got: %#q\nwant: %#q\n", got, tt.want) - } - }) - } -} - -func TestSortRegions(t *testing.T) { - unsortedMap := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{}, - } - for rid := 1; rid <= 5; rid++ { - var nodes []*tailcfg.DERPNode - nodes = append(nodes, &tailcfg.DERPNode{ - Name: fmt.Sprintf("%da", rid), - RegionID: rid, - HostName: fmt.Sprintf("derp%d-1", rid), - IPv4: fmt.Sprintf("%d.0.0.1", rid), - IPv6: fmt.Sprintf("%d::1", rid), - }) - unsortedMap.Regions[rid] = &tailcfg.DERPRegion{ - RegionID: rid, - Nodes: nodes, - } - } - report := newReport() - report.RegionLatency[1] = time.Second * time.Duration(5) - report.RegionLatency[2] = time.Second * time.Duration(3) - report.RegionLatency[3] = time.Second * time.Duration(6) - report.RegionLatency[4] = time.Second * time.Duration(0) - report.RegionLatency[5] = time.Second * time.Duration(2) - sortedMap := sortRegions(unsortedMap, report, 0) - - // Sorting by latency this should result in rid: 5, 2, 1, 3 - // rid 4 with latency 0 should be at the end - want := []int{5, 2, 1, 3, 4} - got := make([]int, len(sortedMap)) - for i, r := range sortedMap { - got[i] = r.RegionID - } - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v; want %v", got, want) - } -} - -type RoundTripFunc func(req *http.Request) *http.Response - -func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req), nil -} - -func TestNodeAddrResolve(t *testing.T) { - nettest.SkipIfNoNetwork(t) - c := &Client{ - Logf: t.Logf, - UseDNSCache: true, - } - - dn := &tailcfg.DERPNode{ - Name: "derptest1a", - RegionID: 901, - HostName: "tailscale.com", - // No IPv4 or IPv6 addrs - } - dnV4Only := &tailcfg.DERPNode{ - Name: "derptest1b", - RegionID: 901, - HostName: "ipv4.google.com", - // No IPv4 or IPv6 addrs - } - - // Checks whether IPv6 and IPv6 DNS resolution works on this platform. - ipv6Works := func(t *testing.T) bool { - // Verify that we can create an IPv6 socket. - ln, err := net.ListenPacket("udp6", "[::1]:0") - if err != nil { - t.Logf("IPv6 may not work on this machine: %v", err) - return false - } - ln.Close() - - // Resolve a hostname that we know has an IPv6 address. - addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip6", "google.com") - if err != nil { - t.Logf("IPv6 DNS resolution error: %v", err) - return false - } - if len(addrs) == 0 { - t.Logf("IPv6 DNS resolution returned no addresses") - return false - } - return true - } - - ctx := context.Background() - for _, tt := range []bool{true, false} { - t.Run(fmt.Sprintf("UseDNSCache=%v", tt), func(t *testing.T) { - c.resolver = nil - c.UseDNSCache = tt - - t.Run("IPv4", func(t *testing.T) { - ap := c.nodeAddr(ctx, dn, probeIPv4) - if !ap.IsValid() { - t.Fatal("expected valid AddrPort") - } - if !ap.Addr().Is4() { - t.Fatalf("expected IPv4 addr, got: %v", ap.Addr()) - } - t.Logf("got IPv4 addr: %v", ap) - }) - t.Run("IPv6", func(t *testing.T) { - // Skip if IPv6 doesn't work on this machine. - if !ipv6Works(t) { - t.Skipf("IPv6 may not work on this machine") - } - - ap := c.nodeAddr(ctx, dn, probeIPv6) - if !ap.IsValid() { - t.Fatal("expected valid AddrPort") - } - if !ap.Addr().Is6() { - t.Fatalf("expected IPv6 addr, got: %v", ap.Addr()) - } - t.Logf("got IPv6 addr: %v", ap) - }) - t.Run("IPv6 Failure", func(t *testing.T) { - ap := c.nodeAddr(ctx, dnV4Only, probeIPv6) - if ap.IsValid() { - t.Fatalf("expected no addr but got: %v", ap) - } - t.Logf("correctly got invalid addr") - }) - }) - } -} - -func TestReportTimeouts(t *testing.T) { - if ReportTimeout < stunProbeTimeout { - t.Errorf("ReportTimeout (%v) cannot be less than stunProbeTimeout (%v)", ReportTimeout, stunProbeTimeout) - } - if ReportTimeout < icmpProbeTimeout { - t.Errorf("ReportTimeout (%v) cannot be less than icmpProbeTimeout (%v)", ReportTimeout, icmpProbeTimeout) - } - if ReportTimeout < httpsProbeTimeout { - t.Errorf("ReportTimeout (%v) cannot be less than httpsProbeTimeout (%v)", ReportTimeout, httpsProbeTimeout) - } -} - -func TestNoUDPNilGetReportOpts(t *testing.T) { - blackhole, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to open blackhole STUN listener: %v", err) - } - defer blackhole.Close() - - dm := stuntest.DERPMapOf(blackhole.LocalAddr().String()) - for _, region := range dm.Regions { - for _, n := range region.Nodes { - n.STUNOnly = false // exercise ICMP & HTTPS probing - } - } - - c := newTestClient(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - r, err := c.GetReport(ctx, dm, nil) - if err != nil { - t.Fatal(err) - } - if r.UDP { - t.Fatal("unexpected working UDP") - } -} diff --git a/net/netcheck/standalone.go b/net/netcheck/standalone.go index c72d7005f7c7e..327bbdde46b9e 100644 --- a/net/netcheck/standalone.go +++ b/net/netcheck/standalone.go @@ -8,12 +8,12 @@ import ( "errors" "net/netip" - "tailscale.com/net/netaddr" - "tailscale.com/net/netns" - "tailscale.com/net/stun" - "tailscale.com/types/logger" - "tailscale.com/types/nettype" - "tailscale.com/util/multierr" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/util/multierr" ) // Standalone creates the necessary UDP sockets on the given bindAddr and starts diff --git a/net/neterror/neterror_linux_test.go b/net/neterror/neterror_linux_test.go deleted file mode 100644 index 5b99060741351..0000000000000 --- a/net/neterror/neterror_linux_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package neterror - -import ( - "errors" - "net" - "os" - "syscall" - "testing" -) - -func TestTreatAsLostUDP(t *testing.T) { - tests := []struct { - name string - err error - want bool - }{ - {"nil", nil, false}, - {"non-nil", errors.New("foo"), false}, - {"eperm", syscall.EPERM, true}, - { - name: "operror", - err: &net.OpError{ - Op: "write", - Err: &os.SyscallError{ - Syscall: "sendto", - Err: syscall.EPERM, - }, - }, - want: true, - }, - { - name: "host_unreach", - err: &net.OpError{ - Op: "write", - Err: &os.SyscallError{ - Syscall: "sendto", - Err: syscall.EHOSTUNREACH, - }, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := TreatAsLostUDP(tt.err); got != tt.want { - t.Errorf("got = %v; want %v", got, tt.want) - } - }) - } - -} diff --git a/net/netmon/defaultroute_darwin.go b/net/netmon/defaultroute_darwin.go index 4efe2f1aa61bf..91630d0b846e6 100644 --- a/net/netmon/defaultroute_darwin.go +++ b/net/netmon/defaultroute_darwin.go @@ -9,7 +9,7 @@ import ( "log" "net" - "tailscale.com/syncs" + "github.com/sagernet/tailscale/syncs" ) var ( diff --git a/net/netmon/interfaces_android.go b/net/netmon/interfaces_android.go index 26104e879a393..333d89251fd62 100644 --- a/net/netmon/interfaces_android.go +++ b/net/netmon/interfaces_android.go @@ -10,11 +10,11 @@ import ( "os/exec" "sync/atomic" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/util/lineiter" "go4.org/mem" "golang.org/x/sys/unix" - "tailscale.com/net/netaddr" - "tailscale.com/syncs" - "tailscale.com/util/lineiter" ) var ( diff --git a/net/netmon/interfaces_bsd.go b/net/netmon/interfaces_bsd.go index 86bc5615d2321..5dcd4c0c11391 100644 --- a/net/netmon/interfaces_bsd.go +++ b/net/netmon/interfaces_bsd.go @@ -15,9 +15,9 @@ import ( "net/netip" "syscall" + "github.com/sagernet/tailscale/net/netaddr" "golang.org/x/net/route" "golang.org/x/sys/unix" - "tailscale.com/net/netaddr" ) // ErrNoGatewayIndexFound is returned by DefaultRouteInterfaceIndex when no diff --git a/net/netmon/interfaces_darwin.go b/net/netmon/interfaces_darwin.go index b175f980a2109..d5ac7b8940fbe 100644 --- a/net/netmon/interfaces_darwin.go +++ b/net/netmon/interfaces_darwin.go @@ -11,9 +11,9 @@ import ( "syscall" "unsafe" + "github.com/sagernet/tailscale/util/mak" "golang.org/x/net/route" "golang.org/x/sys/unix" - "tailscale.com/util/mak" ) // fetchRoutingTable calls route.FetchRIB, fetching NET_RT_DUMP2. diff --git a/net/netmon/interfaces_darwin_test.go b/net/netmon/interfaces_darwin_test.go deleted file mode 100644 index d756d13348bc3..0000000000000 --- a/net/netmon/interfaces_darwin_test.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmon - -import ( - "io" - "net/netip" - "os/exec" - "testing" - - "go4.org/mem" - "tailscale.com/util/lineiter" - "tailscale.com/version" -) - -func TestLikelyHomeRouterIPSyscallExec(t *testing.T) { - syscallIP, _, syscallOK := likelyHomeRouterIPBSDFetchRIB() - netstatIP, netstatIf, netstatOK := likelyHomeRouterIPDarwinExec() - - if syscallOK != netstatOK || syscallIP != netstatIP { - t.Errorf("syscall() = %v, %v, netstat = %v, %v", - syscallIP, syscallOK, - netstatIP, netstatOK, - ) - } - - if !syscallOK { - return - } - - def, err := defaultRoute() - if err != nil { - t.Errorf("defaultRoute() error: %v", err) - } - - if def.InterfaceName != netstatIf { - t.Errorf("syscall default route interface %s differs from netstat %s", def.InterfaceName, netstatIf) - } -} - -/* -Parse out 10.0.0.1 and en0 from: - -$ netstat -r -n -f inet -Routing tables - -Internet: -Destination Gateway Flags Netif Expire -default 10.0.0.1 UGSc en0 -default link#14 UCSI utun2 -10/16 link#4 UCS en0 ! -10.0.0.1/32 link#4 UCS en0 ! -... -*/ -func likelyHomeRouterIPDarwinExec() (ret netip.Addr, netif string, ok bool) { - if version.IsMobile() { - // Don't try to do subprocesses on iOS. Ends up with log spam like: - // kernel: "Sandbox: IPNExtension(86580) deny(1) process-fork" - // This is why we have likelyHomeRouterIPDarwinSyscall. - return ret, "", false - } - cmd := exec.Command("/usr/sbin/netstat", "-r", "-n", "-f", "inet") - stdout, err := cmd.StdoutPipe() - if err != nil { - return - } - if err := cmd.Start(); err != nil { - return - } - defer cmd.Wait() - defer io.Copy(io.Discard, stdout) // clear the pipe to prevent hangs - - var f []mem.RO - for lr := range lineiter.Reader(stdout) { - lineb, err := lr.Value() - if err != nil { - break - } - line := mem.B(lineb) - if !mem.Contains(line, mem.S("default")) { - continue - } - f = mem.AppendFields(f[:0], line) - if len(f) < 4 || !f[0].EqualString("default") { - continue - } - ipm, flagsm, netifm := f[1], f[2], f[3] - if !mem.Contains(flagsm, mem.S("G")) { - continue - } - if mem.Contains(flagsm, mem.S("I")) { - continue - } - ip, err := netip.ParseAddr(string(mem.Append(nil, ipm))) - if err == nil && ip.IsPrivate() { - ret = ip - netif = netifm.StringCopy() - // We've found what we're looking for. - break - } - } - return ret, netif, ret.IsValid() -} - -func TestFetchRoutingTable(t *testing.T) { - // Issue 1345: this used to be flaky on darwin. - for range 20 { - _, err := fetchRoutingTable() - if err != nil { - t.Fatal(err) - } - } -} diff --git a/net/netmon/interfaces_default_route_test.go b/net/netmon/interfaces_default_route_test.go deleted file mode 100644 index e231eea9ac794..0000000000000 --- a/net/netmon/interfaces_default_route_test.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || (darwin && !ts_macext) - -package netmon - -import ( - "testing" -) - -func TestDefaultRouteInterface(t *testing.T) { - // tests /proc/net/route on the local system, cannot make an assertion about - // the correct interface name, but good as a sanity check. - v, err := DefaultRouteInterface() - if err != nil { - t.Fatal(err) - } - t.Logf("got %q", v) -} diff --git a/net/netmon/interfaces_linux.go b/net/netmon/interfaces_linux.go index d0fb15ababe9e..3fa97dc96db1d 100644 --- a/net/netmon/interfaces_linux.go +++ b/net/netmon/interfaces_linux.go @@ -20,10 +20,10 @@ import ( "github.com/jsimonetti/rtnetlink" "github.com/mdlayher/netlink" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/util/lineiter" "go4.org/mem" "golang.org/x/sys/unix" - "tailscale.com/net/netaddr" - "tailscale.com/util/lineiter" ) func init() { diff --git a/net/netmon/interfaces_linux_test.go b/net/netmon/interfaces_linux_test.go deleted file mode 100644 index 4f740ac28ba08..0000000000000 --- a/net/netmon/interfaces_linux_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmon - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "testing" - - "tailscale.com/tstest" -) - -// test the specific /proc/net/route path as found on Google Cloud Run instances -func TestGoogleCloudRunDefaultRouteInterface(t *testing.T) { - dir := t.TempDir() - tstest.Replace(t, &procNetRoutePath, filepath.Join(dir, "CloudRun")) - buf := []byte("Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" + - "eth0\t8008FEA9\t00000000\t0001\t0\t0\t0\t01FFFFFF\t0\t0\t0\n" + - "eth1\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n") - err := os.WriteFile(procNetRoutePath, buf, 0644) - if err != nil { - t.Fatal(err) - } - got, err := DefaultRouteInterface() - if err != nil { - t.Fatal(err) - } - - if got != "eth1" { - t.Fatalf("got %s, want eth1", got) - } -} - -// we read chunks of /proc/net/route at a time, test that files longer than the chunk -// size can be handled. -func TestExtremelyLongProcNetRoute(t *testing.T) { - dir := t.TempDir() - tstest.Replace(t, &procNetRoutePath, filepath.Join(dir, "VeryLong")) - f, err := os.Create(procNetRoutePath) - if err != nil { - t.Fatal(err) - } - _, err = f.Write([]byte("Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n")) - if err != nil { - t.Fatal(err) - } - - for n := 0; n <= 900; n++ { - line := fmt.Sprintf("eth%d\t8008FEA9\t00000000\t0001\t0\t0\t0\t01FFFFFF\t0\t0\t0\n", n) - _, err := f.Write([]byte(line)) - if err != nil { - t.Fatal(err) - } - } - _, err = f.Write([]byte("tokenring1\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n")) - if err != nil { - t.Fatal(err) - } - - got, err := DefaultRouteInterface() - if err != nil { - t.Fatal(err) - } - - if got != "tokenring1" { - t.Fatalf("got %q, want tokenring1", got) - } -} - -// test the specific /proc/net/route path as found on AWS App Runner instances -func TestAwsAppRunnerDefaultRouteInterface(t *testing.T) { - dir := t.TempDir() - tstest.Replace(t, &procNetRoutePath, filepath.Join(dir, "CloudRun")) - buf := []byte("Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\n" + - "eth0\t00000000\tF9AFFEA9\t0003\t0\t0\t0\t00000000\t0\t0\t0\n" + - "*\tFEA9FEA9\t00000000\t0005\t0\t0\t0\tFFFFFFFF\t0\t0\t0\n" + - "ecs-eth0\t02AAFEA9\t01ACFEA9\t0007\t0\t0\t0\tFFFFFFFF\t0\t0\t0\n" + - "ecs-eth0\t00ACFEA9\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n" + - "eth0\t00AFFEA9\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n") - err := os.WriteFile(procNetRoutePath, buf, 0644) - if err != nil { - t.Fatal(err) - } - got, err := DefaultRouteInterface() - if err != nil { - t.Fatal(err) - } - - if got != "eth0" { - t.Fatalf("got %s, want eth0", got) - } -} - -func BenchmarkDefaultRouteInterface(b *testing.B) { - b.ReportAllocs() - for range b.N { - if _, err := DefaultRouteInterface(); err != nil { - b.Fatal(err) - } - } -} - -func TestRouteLinuxNetlink(t *testing.T) { - d, err := defaultRouteFromNetlink() - if errors.Is(err, fs.ErrPermission) { - t.Skip(err) - } - if err != nil { - t.Fatal(err) - } - t.Logf("Got: %+v", d) -} diff --git a/net/netmon/interfaces_test.go b/net/netmon/interfaces_test.go deleted file mode 100644 index edd4f6d6e202b..0000000000000 --- a/net/netmon/interfaces_test.go +++ /dev/null @@ -1,403 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmon - -import ( - "encoding/json" - "net" - "net/netip" - "testing" - - "tailscale.com/tstest" -) - -func TestGetState(t *testing.T) { - st, err := GetState() - if err != nil { - t.Fatal(err) - } - j, err := json.MarshalIndent(st, "", "\t") - if err != nil { - t.Errorf("JSON: %v", err) - } - t.Logf("Got: %s", j) - t.Logf("As string: %s", st) -} - -func TestLikelyHomeRouterIP(t *testing.T) { - ipnet := func(s string) net.Addr { - ip, ipnet, err := net.ParseCIDR(s) - ipnet.IP = ip - if err != nil { - t.Fatal(err) - } - return ipnet - } - - mockInterfaces := []Interface{ - // Interface that's not running - { - Interface: &net.Interface{ - Index: 1, - MTU: 1500, - Name: "down0", - Flags: net.FlagBroadcast | net.FlagMulticast, - }, - AltAddrs: []net.Addr{ - ipnet("10.0.0.100/8"), - }, - }, - - // Interface that's up, but only has an IPv6 address - { - Interface: &net.Interface{ - Index: 2, - MTU: 1500, - Name: "ipsixonly0", - Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning, - }, - AltAddrs: []net.Addr{ - ipnet("76f9:2e7d:55dd:48e1:48d0:763a:b591:b1bc/64"), - }, - }, - - // Fake interface with a gateway to the internet - { - Interface: &net.Interface{ - Index: 3, - MTU: 1500, - Name: "fake0", - Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning, - }, - AltAddrs: []net.Addr{ - ipnet("23a1:99c9:3a88:1d29:74d4:957b:2133:3f4e/64"), - ipnet("192.168.7.100/24"), - }, - }, - } - - // Mock out the responses from netInterfaces() - tstest.Replace(t, &altNetInterfaces, func() ([]Interface, error) { - return mockInterfaces, nil - }) - - // Mock out the likelyHomeRouterIP to return a known gateway. - tstest.Replace(t, &likelyHomeRouterIP, func() (netip.Addr, netip.Addr, bool) { - return netip.MustParseAddr("192.168.7.1"), netip.Addr{}, true - }) - - gw, my, ok := LikelyHomeRouterIP() - if !ok { - t.Fatal("expected success") - } - t.Logf("myIP = %v; gw = %v", my, gw) - - if want := netip.MustParseAddr("192.168.7.1"); gw != want { - t.Errorf("got gateway %v; want %v", gw, want) - } - if want := netip.MustParseAddr("192.168.7.100"); my != want { - t.Errorf("got self IP %v; want %v", my, want) - } - - // Verify that no IP is returned if there are no IPv4 addresses on - // local interfaces. - t.Run("NoIPv4Addrs", func(t *testing.T) { - tstest.Replace(t, &mockInterfaces, []Interface{ - // Interface that's up, but only has an IPv6 address - { - Interface: &net.Interface{ - Index: 2, - MTU: 1500, - Name: "en0", - Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning, - }, - AltAddrs: []net.Addr{ - ipnet("76f9:2e7d:55dd:48e1:48d0:763a:b591:b1bc/64"), - }, - }, - }) - - _, _, ok := LikelyHomeRouterIP() - if ok { - t.Fatal("expected no success") - } - }) -} - -// https://github.com/tailscale/tailscale/issues/10466 -func TestLikelyHomeRouterIP_Prefix(t *testing.T) { - ipnet := func(s string) net.Addr { - ip, ipnet, err := net.ParseCIDR(s) - ipnet.IP = ip - if err != nil { - t.Fatal(err) - } - return ipnet - } - - mockInterfaces := []Interface{ - // Valid and running interface that doesn't have a route to the - // internet, and comes before the interface that does. - { - Interface: &net.Interface{ - Index: 1, - MTU: 1500, - Name: "docker0", - Flags: net.FlagUp | - net.FlagBroadcast | - net.FlagMulticast | - net.FlagRunning, - }, - AltAddrs: []net.Addr{ - ipnet("172.17.0.0/16"), - }, - }, - - // Fake interface with a gateway to the internet. - { - Interface: &net.Interface{ - Index: 2, - MTU: 1500, - Name: "fake0", - Flags: net.FlagUp | - net.FlagBroadcast | - net.FlagMulticast | - net.FlagRunning, - }, - AltAddrs: []net.Addr{ - ipnet("192.168.7.100/24"), - }, - }, - } - - // Mock out the responses from netInterfaces() - tstest.Replace(t, &altNetInterfaces, func() ([]Interface, error) { - return mockInterfaces, nil - }) - - // Mock out the likelyHomeRouterIP to return a known gateway. - tstest.Replace(t, &likelyHomeRouterIP, func() (netip.Addr, netip.Addr, bool) { - return netip.MustParseAddr("192.168.7.1"), netip.Addr{}, true - }) - - gw, my, ok := LikelyHomeRouterIP() - if !ok { - t.Fatal("expected success") - } - t.Logf("myIP = %v; gw = %v", my, gw) - - if want := netip.MustParseAddr("192.168.7.1"); gw != want { - t.Errorf("got gateway %v; want %v", gw, want) - } - if want := netip.MustParseAddr("192.168.7.100"); my != want { - t.Errorf("got self IP %v; want %v", my, want) - } -} - -func TestLikelyHomeRouterIP_NoMocks(t *testing.T) { - // Verify that this works properly when called on a real live system, - // without any mocks. - gw, my, ok := LikelyHomeRouterIP() - t.Logf("LikelyHomeRouterIP: gw=%v my=%v ok=%v", gw, my, ok) -} - -func TestIsUsableV6(t *testing.T) { - tests := []struct { - name string - ip string - want bool - }{ - {"first ULA", "fc00::1", true}, - {"Tailscale", "fd7a:115c:a1e0::1", false}, - {"Cloud Run", "fddf:3978:feb1:d745::1", true}, - {"zeros", "0::0", false}, - {"Link Local", "fe80::1", false}, - {"Global", "2602::1", true}, - {"IPv4 public", "192.0.2.1", false}, - {"IPv4 private", "192.168.1.1", false}, - } - - for _, test := range tests { - if got := isUsableV6(netip.MustParseAddr(test.ip)); got != test.want { - t.Errorf("isUsableV6(%s) = %v, want %v", test.name, got, test.want) - } - } -} - -func TestStateString(t *testing.T) { - tests := []struct { - name string - s *State - want string - }{ - { - name: "typical_linux", - s: &State{ - DefaultRouteInterface: "eth0", - Interface: map[string]Interface{ - "eth0": { - Interface: &net.Interface{ - Flags: net.FlagUp, - }, - }, - "wlan0": { - Interface: &net.Interface{}, - }, - "lo": { - Interface: &net.Interface{}, - }, - }, - InterfaceIPs: map[string][]netip.Prefix{ - "eth0": { - netip.MustParsePrefix("10.0.0.2/8"), - }, - "lo": {}, - }, - HaveV4: true, - }, - want: `interfaces.State{defaultRoute=eth0 ifs={eth0:[10.0.0.2/8]} v4=true v6=false}`, - }, - { - name: "default_desc", - s: &State{ - DefaultRouteInterface: "foo", - Interface: map[string]Interface{ - "foo": { - Desc: "a foo thing", - Interface: &net.Interface{ - Flags: net.FlagUp, - }, - }, - }, - }, - want: `interfaces.State{defaultRoute=foo (a foo thing) ifs={foo:[]} v4=false v6=false}`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.s.String() - if got != tt.want { - t.Errorf("wrong\n got: %s\nwant: %s\n", got, tt.want) - } - }) - } -} - -// tests (*State).Equal -func TestEqual(t *testing.T) { - pfxs := func(addrs ...string) (ret []netip.Prefix) { - for _, addr := range addrs { - ret = append(ret, netip.MustParsePrefix(addr)) - } - return ret - } - - tests := []struct { - name string - s1, s2 *State - want bool // implies !wantMajor - }{ - { - name: "eq_nil", - want: true, - }, - { - name: "nil_mix", - s2: new(State), - want: false, - }, - { - name: "eq", - s1: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - s2: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - want: true, - }, - { - name: "default-route-changed", - s1: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - s2: &State{ - DefaultRouteInterface: "bar", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - want: false, - }, - { - name: "some-interface-ips-changed", - s1: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - s2: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.3/16")}, - }, - }, - want: false, - }, - { - name: "altaddrs-changed", - s1: &State{ - Interface: map[string]Interface{ - "foo": {AltAddrs: []net.Addr{&net.TCPAddr{IP: net.ParseIP("1.2.3.4")}}}, - }, - }, - s2: &State{ - Interface: map[string]Interface{ - "foo": {AltAddrs: []net.Addr{&net.TCPAddr{IP: net.ParseIP("5.6.7.8")}}}, - }, - }, - want: false, - }, - - // See tailscale/corp#19124 - { - name: "interface-removed", - s1: &State{ - InterfaceIPs: map[string][]netip.Prefix{ - "rmnet16": pfxs("2607:1111:2222:3333:4444:5555:6666:7777/64"), - "rmnet17": pfxs("2607:9999:8888:7777:666:5555:4444:3333/64"), - "tun0": pfxs("100.64.1.2/32", "fd7a:115c:a1e0::1/128"), - "v4-rmnet16": pfxs("192.0.0.4/32"), - "wlan0": pfxs("10.0.0.111/24"), // removed below - }, - }, - s2: &State{ - InterfaceIPs: map[string][]netip.Prefix{ - "rmnet16": pfxs("2607:1111:2222:3333:4444:5555:6666:7777/64"), - "rmnet17": pfxs("2607:9999:8888:7777:666:5555:4444:3333/64"), - "tun0": pfxs("100.64.1.2/32", "fd7a:115c:a1e0::1/128"), - "v4-rmnet16": pfxs("192.0.0.4/32"), - }, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.s2.Equal(tt.s1); got != tt.want { - t.Errorf("Equal = %v; want %v", got, tt.want) - } - }) - } -} diff --git a/net/netmon/interfaces_windows.go b/net/netmon/interfaces_windows.go index 00b686e593b1e..befda9649d5aa 100644 --- a/net/netmon/interfaces_windows.go +++ b/net/netmon/interfaces_windows.go @@ -11,9 +11,9 @@ import ( "syscall" "unsafe" + "github.com/sagernet/tailscale/tsconst" "golang.org/x/sys/windows" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/tsconst" ) const ( diff --git a/net/netmon/interfaces_windows_test.go b/net/netmon/interfaces_windows_test.go deleted file mode 100644 index 91db7bcc5266c..0000000000000 --- a/net/netmon/interfaces_windows_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmon - -import "testing" - -func BenchmarkGetPACWindows(b *testing.B) { - b.ReportAllocs() - for i := range b.N { - v := getPACWindows() - if i == 0 { - b.Logf("Got: %q", v) - } - } -} diff --git a/net/netmon/netmon.go b/net/netmon/netmon.go index 47b540d6a7f83..a3b05774b1e7b 100644 --- a/net/netmon/netmon.go +++ b/net/netmon/netmon.go @@ -14,9 +14,9 @@ import ( "sync" "time" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/set" ) // pollWallTimeInterval is how often we check the time to check diff --git a/net/netmon/netmon_darwin.go b/net/netmon/netmon_darwin.go index cc630112523fa..07ed6e4ec23f0 100644 --- a/net/netmon/netmon_darwin.go +++ b/net/netmon/netmon_darwin.go @@ -9,10 +9,10 @@ import ( "strings" "sync" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/net/route" "golang.org/x/sys/unix" - "tailscale.com/net/netaddr" - "tailscale.com/types/logger" ) const debugRouteMessages = false diff --git a/net/netmon/netmon_darwin_test.go b/net/netmon/netmon_darwin_test.go deleted file mode 100644 index 84c67cf6fa3e2..0000000000000 --- a/net/netmon/netmon_darwin_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmon - -import ( - "encoding/hex" - "strings" - "testing" - - "golang.org/x/net/route" -) - -func TestIssue1416RIB(t *testing.T) { - const ribHex = `32 00 05 10 30 00 00 00 00 00 00 00 04 00 00 00 14 12 04 00 06 03 06 00 65 6e 30 ac 87 a3 19 7f 82 00 00 00 0e 12 00 00 00 00 06 00 91 e0 f0 01 00 00` - rtmMsg, err := hex.DecodeString(strings.ReplaceAll(ribHex, " ", "")) - if err != nil { - t.Fatal(err) - } - msgs, err := route.ParseRIB(route.RIBTypeRoute, rtmMsg) - if err != nil { - t.Logf("ParseRIB: %v", err) - t.Skip("skipping on known failure; see https://github.com/tailscale/tailscale/issues/1416") - t.Fatal(err) - } - t.Logf("Got: %#v", msgs) -} diff --git a/net/netmon/netmon_freebsd.go b/net/netmon/netmon_freebsd.go index 30480a1d3387e..3151ed92d50cf 100644 --- a/net/netmon/netmon_freebsd.go +++ b/net/netmon/netmon_freebsd.go @@ -9,7 +9,7 @@ import ( "net" "strings" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // unspecifiedMessage is a minimal message implementation that should not diff --git a/net/netmon/netmon_linux.go b/net/netmon/netmon_linux.go index dd23dd34263c5..e64031a5c737f 100644 --- a/net/netmon/netmon_linux.go +++ b/net/netmon/netmon_linux.go @@ -12,10 +12,10 @@ import ( "github.com/jsimonetti/rtnetlink" "github.com/mdlayher/netlink" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/sys/unix" - "tailscale.com/envknob" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" ) var debugNetlinkMessages = envknob.RegisterBool("TS_DEBUG_NETLINK") diff --git a/net/netmon/netmon_linux_test.go b/net/netmon/netmon_linux_test.go deleted file mode 100644 index 75d7c646559f1..0000000000000 --- a/net/netmon/netmon_linux_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux && !android - -package netmon - -import ( - "net" - "net/netip" - "testing" - - "github.com/jsimonetti/rtnetlink" - "github.com/mdlayher/netlink" - "golang.org/x/sys/unix" -) - -func newAddrMsg(iface uint32, addr string, typ netlink.HeaderType) netlink.Message { - ip := net.ParseIP(addr) - if ip == nil { - panic("newAddrMsg: invalid addr: " + addr) - } - - addrMsg := rtnetlink.AddressMessage{ - Index: iface, - Attributes: &rtnetlink.AddressAttributes{ - Address: ip, - }, - } - - b, err := addrMsg.MarshalBinary() - if err != nil { - panic(err) - } - - return netlink.Message{ - Header: netlink.Header{Type: typ}, - Data: b, - } -} - -// See issue #4282 and nlConn.addrCache. -func TestIgnoreDuplicateNEWADDR(t *testing.T) { - mustReceive := func(c *nlConn) message { - msg, err := c.Receive() - if err != nil { - t.Fatalf("mustReceive: unwanted error: %s", err) - } - return msg - } - - t.Run("suppress duplicate NEWADDRs", func(t *testing.T) { - c := nlConn{ - buffered: []netlink.Message{ - newAddrMsg(1, "192.168.0.5", unix.RTM_NEWADDR), - newAddrMsg(1, "192.168.0.5", unix.RTM_NEWADDR), - }, - addrCache: make(map[uint32]map[netip.Addr]bool), - } - - msg := mustReceive(&c) - if _, ok := msg.(*newAddrMessage); !ok { - t.Fatalf("want newAddrMessage, got %T %v", msg, msg) - } - - msg = mustReceive(&c) - if _, ok := msg.(ignoreMessage); !ok { - t.Fatalf("want ignoreMessage, got %T %v", msg, msg) - } - }) - - t.Run("do not suppress after DELADDR", func(t *testing.T) { - c := nlConn{ - buffered: []netlink.Message{ - newAddrMsg(1, "192.168.0.5", unix.RTM_NEWADDR), - newAddrMsg(1, "192.168.0.5", unix.RTM_DELADDR), - newAddrMsg(1, "192.168.0.5", unix.RTM_NEWADDR), - }, - addrCache: make(map[uint32]map[netip.Addr]bool), - } - - msg := mustReceive(&c) - if _, ok := msg.(*newAddrMessage); !ok { - t.Fatalf("want newAddrMessage, got %T %v", msg, msg) - } - - msg = mustReceive(&c) - if m, ok := msg.(*newAddrMessage); !ok { - t.Fatalf("want newAddrMessage, got %T %v", msg, msg) - } else { - if !m.Delete { - t.Fatalf("want delete, got %#v", m) - } - } - - msg = mustReceive(&c) - if _, ok := msg.(*newAddrMessage); !ok { - t.Fatalf("want newAddrMessage, got %T %v", msg, msg) - } - }) -} diff --git a/net/netmon/netmon_polling.go b/net/netmon/netmon_polling.go index 3d6f94731077a..cadea1a4e1485 100644 --- a/net/netmon/netmon_polling.go +++ b/net/netmon/netmon_polling.go @@ -6,7 +6,7 @@ package netmon import ( - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) func newOSMon(logf logger.Logf, m *Monitor) (osMon, error) { diff --git a/net/netmon/netmon_test.go b/net/netmon/netmon_test.go deleted file mode 100644 index ce55d19464100..0000000000000 --- a/net/netmon/netmon_test.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmon - -import ( - "flag" - "net" - "net/netip" - "sync/atomic" - "testing" - "time" - - "tailscale.com/util/mak" -) - -func TestMonitorStartClose(t *testing.T) { - mon, err := New(t.Logf) - if err != nil { - t.Fatal(err) - } - mon.Start() - if err := mon.Close(); err != nil { - t.Fatal(err) - } -} - -func TestMonitorJustClose(t *testing.T) { - mon, err := New(t.Logf) - if err != nil { - t.Fatal(err) - } - if err := mon.Close(); err != nil { - t.Fatal(err) - } -} - -func TestMonitorInjectEvent(t *testing.T) { - mon, err := New(t.Logf) - if err != nil { - t.Fatal(err) - } - defer mon.Close() - got := make(chan bool, 1) - mon.RegisterChangeCallback(func(*ChangeDelta) { - select { - case got <- true: - default: - } - }) - mon.Start() - mon.InjectEvent() - select { - case <-got: - // Pass. - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for callback") - } -} - -var ( - monitor = flag.String("monitor", "", `go into monitor mode like 'route monitor'; test never terminates. Value can be either "raw" or "callback"`) - monitorDuration = flag.Duration("monitor-duration", 0, "if non-zero, how long to run TestMonitorMode. Zero means forever.") -) - -func TestMonitorMode(t *testing.T) { - switch *monitor { - case "": - t.Skip("skipping non-test without --monitor") - case "raw", "callback": - default: - t.Skipf(`invalid --monitor value: must be "raw" or "callback"`) - } - mon, err := New(t.Logf) - if err != nil { - t.Fatal(err) - } - switch *monitor { - case "raw": - var closed atomic.Bool - if *monitorDuration != 0 { - t := time.AfterFunc(*monitorDuration, func() { - closed.Store(true) - mon.Close() - }) - defer t.Stop() - } - for { - msg, err := mon.om.Receive() - if closed.Load() { - return - } - if err != nil { - t.Fatal(err) - } - t.Logf("msg: %#v", msg) - } - case "callback": - var done <-chan time.Time - if *monitorDuration != 0 { - t := time.NewTimer(*monitorDuration) - defer t.Stop() - done = t.C - } - n := 0 - mon.RegisterChangeCallback(func(d *ChangeDelta) { - n++ - t.Logf("cb: changed=%v, ifSt=%v", d.Major, d.New) - }) - mon.Start() - <-done - t.Logf("%v callbacks", n) - } -} - -// tests (*State).IsMajorChangeFrom -func TestIsMajorChangeFrom(t *testing.T) { - tests := []struct { - name string - s1, s2 *State - want bool - }{ - { - name: "eq_nil", - want: false, - }, - { - name: "nil_mix", - s2: new(State), - want: true, - }, - { - name: "eq", - s1: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - s2: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - want: false, - }, - { - name: "default-route-changed", - s1: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - s2: &State{ - DefaultRouteInterface: "bar", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - want: true, - }, - { - name: "some-interesting-ip-changed", - s1: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - s2: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.3/16")}, - }, - }, - want: true, - }, - { - name: "ipv6-ula-addressed-appeared", - s1: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": {netip.MustParsePrefix("10.0.1.2/16")}, - }, - }, - s2: &State{ - DefaultRouteInterface: "foo", - InterfaceIPs: map[string][]netip.Prefix{ - "foo": { - netip.MustParsePrefix("10.0.1.2/16"), - // Brad saw this address coming & going on his home LAN, possibly - // via an Apple TV Thread routing advertisement? (Issue 9040) - netip.MustParsePrefix("fd15:bbfa:c583:4fce:f4fb:4ff:fe1a:4148/64"), - }, - }, - }, - want: true, // TODO(bradfitz): want false (ignore the IPv6 ULA address on foo) - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Populate dummy interfaces where missing. - for _, s := range []*State{tt.s1, tt.s2} { - if s == nil { - continue - } - for name := range s.InterfaceIPs { - if _, ok := s.Interface[name]; !ok { - mak.Set(&s.Interface, name, Interface{Interface: &net.Interface{ - Name: name, - }}) - } - } - } - - var m Monitor - m.om = &testOSMon{ - Interesting: func(name string) bool { return true }, - } - if got := m.IsMajorChangeFrom(tt.s1, tt.s2); got != tt.want { - t.Errorf("IsMajorChange = %v; want %v", got, tt.want) - } - }) - } -} - -type testOSMon struct { - osMon - Interesting func(name string) bool -} - -func (m *testOSMon) IsInterestingInterface(name string) bool { - if m.Interesting == nil { - return true - } - return m.Interesting(name) -} diff --git a/net/netmon/netmon_windows.go b/net/netmon/netmon_windows.go index ddf13a2e453b2..c5032410875db 100644 --- a/net/netmon/netmon_windows.go +++ b/net/netmon/netmon_windows.go @@ -10,9 +10,9 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" ) var ( diff --git a/net/netmon/polling.go b/net/netmon/polling.go index ce1618ed6c987..148116c140d55 100644 --- a/net/netmon/polling.go +++ b/net/netmon/polling.go @@ -13,7 +13,7 @@ import ( "sync" "time" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) func newPollingMon(logf logger.Logf, m *Monitor) (osMon, error) { diff --git a/net/netmon/state.go b/net/netmon/state.go index d9b360f5eee45..8616c83940c96 100644 --- a/net/netmon/state.go +++ b/net/netmon/state.go @@ -14,11 +14,11 @@ import ( "sort" "strings" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/net/netaddr" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tshttpproxy" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/net/tshttpproxy" ) // LoginEndpointForProxyDetermination is the URL used for testing diff --git a/net/netns/netns.go b/net/netns/netns.go index a473506fac024..f26578637c19f 100644 --- a/net/netns/netns.go +++ b/net/netns/netns.go @@ -19,9 +19,9 @@ import ( "net/netip" "sync/atomic" - "tailscale.com/net/netknob" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/netknob" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" ) var disabled atomic.Bool diff --git a/net/netns/netns_android.go b/net/netns/netns_android.go index 162e5c79a62fa..795f100323df7 100644 --- a/net/netns/netns_android.go +++ b/net/netns/netns_android.go @@ -10,8 +10,8 @@ import ( "sync" "syscall" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" ) var ( diff --git a/net/netns/netns_darwin.go b/net/netns/netns_darwin.go index ac5e89d76cc2e..fdd0f7091a0f7 100644 --- a/net/netns/netns_darwin.go +++ b/net/netns/netns_darwin.go @@ -15,12 +15,12 @@ import ( "strings" "syscall" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/net/route" "golang.org/x/sys/unix" - "tailscale.com/envknob" - "tailscale.com/net/netmon" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" ) func control(logf logger.Logf, netMon *netmon.Monitor) func(network, address string, c syscall.RawConn) error { diff --git a/net/netns/netns_darwin_test.go b/net/netns/netns_darwin_test.go deleted file mode 100644 index 2030c169ef68b..0000000000000 --- a/net/netns/netns_darwin_test.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netns - -import ( - "testing" - - "tailscale.com/net/netmon" -) - -func TestGetInterfaceIndex(t *testing.T) { - oldVal := bindToInterfaceByRoute.Load() - t.Cleanup(func() { bindToInterfaceByRoute.Store(oldVal) }) - bindToInterfaceByRoute.Store(true) - - tests := []struct { - name string - addr string - err string - }{ - { - name: "IP_and_port", - addr: "8.8.8.8:53", - }, - { - name: "bare_ip", - addr: "8.8.8.8", - }, - { - name: "invalid", - addr: "!!!!!", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - idx, err := getInterfaceIndex(t.Logf, nil, tc.addr) - if err != nil { - if tc.err == "" { - t.Fatalf("got unexpected error: %v", err) - } - if errstr := err.Error(); errstr != tc.err { - t.Errorf("expected error %q, got %q", errstr, tc.err) - } - } else { - t.Logf("getInterfaceIndex(%q) = %d", tc.addr, idx) - if tc.err != "" { - t.Fatalf("wanted error %q", tc.err) - } - if idx < 0 { - t.Fatalf("got invalid index %d", idx) - } - } - }) - } - - t.Run("NoTailscale", func(t *testing.T) { - tsif, err := tailscaleInterface() - if err != nil { - t.Fatal(err) - } - if tsif == nil { - t.Skip("no tailscale interface on this machine") - } - - defaultIdx, err := netmon.DefaultRouteInterfaceIndex() - if err != nil { - t.Fatal(err) - } - - idx, err := getInterfaceIndex(t.Logf, nil, "100.100.100.100:53") - if err != nil { - t.Fatal(err) - } - - t.Logf("tailscaleIdx=%d defaultIdx=%d idx=%d", tsif.Index, defaultIdx, idx) - - if idx == tsif.Index { - t.Fatalf("got idx=%d; wanted not Tailscale interface", idx) - } else if idx != defaultIdx { - t.Fatalf("got idx=%d, want %d", idx, defaultIdx) - } - }) -} diff --git a/net/netns/netns_default.go b/net/netns/netns_default.go index 94f24d8fa4e19..c4a83e0eb64c1 100644 --- a/net/netns/netns_default.go +++ b/net/netns/netns_default.go @@ -8,8 +8,8 @@ package netns import ( "syscall" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" ) func control(logger.Logf, *netmon.Monitor) func(network, address string, c syscall.RawConn) error { diff --git a/net/netns/netns_linux.go b/net/netns/netns_linux.go index aaf6dab4a9d64..d15d2624652b0 100644 --- a/net/netns/netns_linux.go +++ b/net/netns/netns_linux.go @@ -12,11 +12,11 @@ import ( "sync" "syscall" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/linuxfw" "golang.org/x/sys/unix" - "tailscale.com/envknob" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" - "tailscale.com/util/linuxfw" ) // socketMarkWorksOnce is the sync.Once & cached value for useSocketMark. diff --git a/net/netns/netns_linux_test.go b/net/netns/netns_linux_test.go deleted file mode 100644 index a5000f37f0a44..0000000000000 --- a/net/netns/netns_linux_test.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netns - -import ( - "testing" -) - -func TestSocketMarkWorks(t *testing.T) { - _ = socketMarkWorks() - // we cannot actually assert whether the test runner has SO_MARK available - // or not, as we don't know. We're just checking that it doesn't panic. -} diff --git a/net/netns/netns_test.go b/net/netns/netns_test.go deleted file mode 100644 index 82f919b946d4a..0000000000000 --- a/net/netns/netns_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package netns contains the common code for using the Go net package -// in a logical "network namespace" to avoid routing loops where -// Tailscale-created packets would otherwise loop back through -// Tailscale routes. -// -// Despite the name netns, the exact mechanism used differs by -// operating system, and perhaps even by version of the OS. -// -// The netns package also handles connecting via SOCKS proxies when -// configured by the environment. -package netns - -import ( - "flag" - "testing" -) - -var extNetwork = flag.Bool("use-external-network", false, "use the external network in tests") - -func TestDial(t *testing.T) { - if !*extNetwork { - t.Skip("skipping test without --use-external-network") - } - d := NewDialer(t.Logf, nil) - c, err := d.Dial("tcp", "google.com:80") - if err != nil { - t.Fatal(err) - } - defer c.Close() - t.Logf("got addr %v", c.RemoteAddr()) - - c, err = d.Dial("tcp4", "google.com:80") - if err != nil { - t.Fatal(err) - } - defer c.Close() - t.Logf("got addr %v", c.RemoteAddr()) -} - -func TestIsLocalhost(t *testing.T) { - tests := []struct { - name string - host string - want bool - }{ - {"IPv4 loopback", "127.0.0.1", true}, - {"IPv4 !loopback", "192.168.0.1", false}, - {"IPv4 loopback with port", "127.0.0.1:1", true}, - {"IPv4 !loopback with port", "192.168.0.1:1", false}, - {"IPv4 unspecified", "0.0.0.0", false}, - {"IPv4 unspecified with port", "0.0.0.0:1", false}, - {"IPv6 loopback", "::1", true}, - {"IPv6 !loopback", "2001:4860:4860::8888", false}, - {"IPv6 loopback with port", "[::1]:1", true}, - {"IPv6 !loopback with port", "[2001:4860:4860::8888]:1", false}, - {"IPv6 unspecified", "::", false}, - {"IPv6 unspecified with port", "[::]:1", false}, - {"empty", "", false}, - {"hostname", "example.com", false}, - {"localhost", "localhost", true}, - {"localhost6", "localhost6", true}, - {"localhost with port", "localhost:1", true}, - {"localhost6 with port", "localhost6:1", true}, - {"ip6-localhost", "ip6-localhost", true}, - {"ip6-localhost with port", "ip6-localhost:1", true}, - {"ip6-loopback", "ip6-loopback", true}, - {"ip6-loopback with port", "ip6-loopback:1", true}, - } - - for _, test := range tests { - if got := isLocalhost(test.host); got != test.want { - t.Errorf("isLocalhost(%q) = %v, want %v", test.name, got, test.want) - } - } -} diff --git a/net/netns/netns_windows.go b/net/netns/netns_windows.go index afbda0f47ece6..d32aac88ee327 100644 --- a/net/netns/netns_windows.go +++ b/net/netns/netns_windows.go @@ -10,13 +10,13 @@ import ( "strings" "syscall" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/tsconst" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/sys/cpu" "golang.org/x/sys/windows" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/envknob" - "tailscale.com/net/netmon" - "tailscale.com/tsconst" - "tailscale.com/types/logger" ) func interfaceIndex(iface *winipcfg.IPAdapterAddresses) uint32 { diff --git a/net/netns/netns_windows_test.go b/net/netns/netns_windows_test.go deleted file mode 100644 index 390604f465041..0000000000000 --- a/net/netns/netns_windows_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netns - -import ( - "strings" - "testing" - - "golang.org/x/sys/windows" - "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/tsconst" -) - -func TestGetInterfaceIndex(t *testing.T) { - oldVal := bindToInterfaceByRoute.Load() - t.Cleanup(func() { bindToInterfaceByRoute.Store(oldVal) }) - bindToInterfaceByRoute.Store(true) - - defIfaceIdxV4, err := defaultInterfaceIndex(windows.AF_INET) - if err != nil { - t.Fatalf("defaultInterfaceIndex(AF_INET) failed: %v", err) - } - - tests := []struct { - name string - addr string - err string - }{ - { - name: "IP_and_port", - addr: "8.8.8.8:53", - }, - { - name: "bare_ip", - addr: "8.8.8.8", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - addr, err := parseAddress(tc.addr) - if err != nil { - t.Fatal(err) - } - - idx, err := getInterfaceIndex(t.Logf, addr, defIfaceIdxV4) - if err != nil { - if tc.err == "" { - t.Fatalf("got unexpected error: %v", err) - } - if errstr := err.Error(); errstr != tc.err { - t.Errorf("expected error %q, got %q", errstr, tc.err) - } - } else { - t.Logf("getInterfaceIndex(%q) = %d", tc.addr, idx) - if tc.err != "" { - t.Fatalf("wanted error %q", tc.err) - } - } - }) - } - - t.Run("NoTailscale", func(t *testing.T) { - tsIdx, ok, err := tailscaleInterfaceIndex() - if err != nil { - t.Fatal(err) - } - if !ok { - t.Skip("no tailscale interface on this machine") - } - - defaultIdx, err := defaultInterfaceIndex(windows.AF_INET) - if err != nil { - t.Fatalf("defaultInterfaceIndex(AF_INET) failed: %v", err) - } - - addr, err := parseAddress("100.100.100.100:53") - if err != nil { - t.Fatal(err) - } - - idx, err := getInterfaceIndex(t.Logf, addr, defaultIdx) - if err != nil { - t.Fatal(err) - } - - t.Logf("tailscaleIdx=%d defaultIdx=%d idx=%d", tsIdx, defaultIdx, idx) - - if idx == tsIdx { - t.Fatalf("got idx=%d; wanted not Tailscale interface", idx) - } else if idx != defaultIdx { - t.Fatalf("got idx=%d, want %d", idx, defaultIdx) - } - }) -} - -func tailscaleInterfaceIndex() (idx uint32, found bool, err error) { - ifs, err := winipcfg.GetAdaptersAddresses(windows.AF_INET, winipcfg.GAAFlagIncludeAllInterfaces) - if err != nil { - return idx, false, err - } - - for _, iface := range ifs { - if iface.IfType != winipcfg.IfTypePropVirtual { - continue - } - if strings.Contains(iface.Description(), tsconst.WintunInterfaceDesc) { - return iface.IfIndex, true, nil - } - } - return idx, false, nil -} diff --git a/net/netstat/netstat_test.go b/net/netstat/netstat_test.go deleted file mode 100644 index 38827df5ef65a..0000000000000 --- a/net/netstat/netstat_test.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netstat - -import ( - "testing" -) - -func TestGet(t *testing.T) { - nt, err := Get() - if err == ErrNotImplemented { - t.Skip("TODO: not implemented") - } - if err != nil { - t.Fatal(err) - } - for _, e := range nt.Entries { - t.Logf("Entry: %+v", e) - } -} diff --git a/net/netstat/netstat_windows.go b/net/netstat/netstat_windows.go index 24191a50eadab..e263b4113c5ad 100644 --- a/net/netstat/netstat_windows.go +++ b/net/netstat/netstat_windows.go @@ -10,9 +10,9 @@ import ( "net/netip" "unsafe" + "github.com/sagernet/tailscale/net/netaddr" "golang.org/x/sys/cpu" "golang.org/x/sys/windows" - "tailscale.com/net/netaddr" ) // OSMetadata includes any additional OS-specific information that may be diff --git a/net/netutil/default_interface_portable_test.go b/net/netutil/default_interface_portable_test.go deleted file mode 100644 index 03dce340505a5..0000000000000 --- a/net/netutil/default_interface_portable_test.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netutil - -import ( - "testing" -) - -func TestDefaultInterfacePortable(t *testing.T) { - ifName, addr, err := DefaultInterfacePortable() - if err != nil { - t.Fatal(err) - } - - t.Logf("Default interface: %s", ifName) - t.Logf("Default address: %s", addr) - - if ifName == "" { - t.Fatal("Default interface name is empty") - } - if !addr.IsValid() { - t.Fatal("Default address is invalid") - } -} diff --git a/net/netutil/ip_forward.go b/net/netutil/ip_forward.go index 48cee68eaff88..3da5a7df40fc1 100644 --- a/net/netutil/ip_forward.go +++ b/net/netutil/ip_forward.go @@ -15,7 +15,7 @@ import ( "strconv" "strings" - "tailscale.com/net/netmon" + "github.com/sagernet/tailscale/net/netmon" ) // protocolsRequiredForForwarding reports whether IPv4 and/or IPv6 protocols are diff --git a/net/netutil/netutil_test.go b/net/netutil/netutil_test.go deleted file mode 100644 index fdc26b02f09aa..0000000000000 --- a/net/netutil/netutil_test.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netutil - -import ( - "io" - "net" - "runtime" - "testing" - - "tailscale.com/net/netmon" -) - -type conn struct { - net.Conn -} - -func TestOneConnListener(t *testing.T) { - c1 := new(conn) - a1 := dummyAddr("a1") - - // Two Accepts - ln := NewOneConnListener(c1, a1) - if got := ln.Addr(); got != a1 { - t.Errorf("Addr = %#v; want %#v", got, a1) - } - c, err := ln.Accept() - if err != nil { - t.Fatal(err) - } - if c != c1 { - t.Fatalf("didn't get c1; got %p", c) - } - c, err = ln.Accept() - if err != io.EOF { - t.Errorf("got %v; want EOF", err) - } - if c != nil { - t.Errorf("unexpected non-nil Conn") - } - - // Close before Accept - ln = NewOneConnListener(c1, a1) - ln.Close() - _, err = ln.Accept() - if err != io.EOF { - t.Fatalf("got %v; want EOF", err) - } - - // Implicit addr - ln = NewOneConnListener(c1, nil) - if ln.Addr() == nil { - t.Errorf("nil Addr") - } -} - -func TestIPForwardingEnabledLinux(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skipf("skipping on %s", runtime.GOOS) - } - got, err := ipForwardingEnabledLinux(ipv4, "some-not-found-interface") - if err != nil { - t.Fatal(err) - } - if got { - t.Errorf("got true; want false") - } -} - -func TestCheckReversePathFiltering(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skipf("skipping on %s", runtime.GOOS) - } - netMon, err := netmon.New(t.Logf) - if err != nil { - t.Fatal(err) - } - defer netMon.Close() - - warn, err := CheckReversePathFiltering(netMon.InterfaceState()) - t.Logf("err: %v", err) - t.Logf("warnings: %v", warn) -} diff --git a/net/netutil/routes.go b/net/netutil/routes.go index 7d67d3695e10d..76df1f1ba7702 100644 --- a/net/netutil/routes.go +++ b/net/netutil/routes.go @@ -10,7 +10,7 @@ import ( "sort" "strings" - "tailscale.com/net/tsaddr" + "github.com/sagernet/tailscale/net/tsaddr" ) func validateViaPrefix(ipp netip.Prefix) error { diff --git a/net/packet/checksum/checksum.go b/net/packet/checksum/checksum.go index 547ea3a3577ed..acc353c3c9a5c 100644 --- a/net/packet/checksum/checksum.go +++ b/net/packet/checksum/checksum.go @@ -8,10 +8,10 @@ import ( "encoding/binary" "net/netip" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/header" - "tailscale.com/net/packet" - "tailscale.com/types/ipproto" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/types/ipproto" ) // UpdateSrcAddr updates the source address in the packet buffer (e.g. during diff --git a/net/packet/checksum/checksum_test.go b/net/packet/checksum/checksum_test.go deleted file mode 100644 index bf818743d3dbf..0000000000000 --- a/net/packet/checksum/checksum_test.go +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package checksum - -import ( - "encoding/binary" - "math/rand/v2" - "net/netip" - "testing" - - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/checksum" - "gvisor.dev/gvisor/pkg/tcpip/header" - "tailscale.com/net/packet" -) - -func fullHeaderChecksumV4(b []byte) uint16 { - s := uint32(0) - for i := 0; i < len(b); i += 2 { - if i == 10 { - // Skip checksum field. - continue - } - s += uint32(binary.BigEndian.Uint16(b[i : i+2])) - } - for s>>16 > 0 { - s = s&0xFFFF + s>>16 - } - return ^uint16(s) -} - -func TestHeaderChecksumsV4(t *testing.T) { - // This is not a good enough test, because it doesn't - // check the various packet types or the many edge cases - // of the checksum algorithm. But it's a start. - - tests := []struct { - name string - packet []byte - }{ - { - name: "ICMPv4", - packet: []byte{ - 0x45, 0x00, 0x00, 0x54, 0xb7, 0x96, 0x40, 0x00, 0x40, 0x01, 0x7a, 0x06, 0x64, 0x7f, 0x3f, 0x4c, 0x64, 0x40, 0x01, 0x01, 0x08, 0x00, 0x47, 0x1a, 0x00, 0x11, 0x01, 0xac, 0xcc, 0xf5, 0x95, 0x63, 0x00, 0x00, 0x00, 0x00, 0x8d, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, - }, - }, - { - name: "TLS", - packet: []byte{ - 0x45, 0x00, 0x00, 0x3c, 0x54, 0x29, 0x40, 0x00, 0x40, 0x06, 0xb1, 0xac, 0x64, 0x42, 0xd4, 0x33, 0x64, 0x61, 0x98, 0x0f, 0xb1, 0x94, 0x01, 0xbb, 0x0a, 0x51, 0xce, 0x7c, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x02, 0xfb, 0xe0, 0x38, 0xf6, 0x00, 0x00, 0x02, 0x04, 0x04, 0xd8, 0x04, 0x02, 0x08, 0x0a, 0x86, 0x2b, 0xcc, 0xd5, 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x03, 0x07, - }, - }, - { - name: "DNS", - packet: []byte{ - 0x45, 0x00, 0x00, 0x74, 0xe2, 0x85, 0x00, 0x00, 0x40, 0x11, 0x96, 0xb5, 0x64, 0x64, 0x64, 0x64, 0x64, 0x42, 0xd4, 0x33, 0x00, 0x35, 0xec, 0x55, 0x00, 0x60, 0xd9, 0x19, 0xed, 0xfd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x34, 0x06, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 0xc0, 0x0c, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x01, 0x1e, 0x00, 0x0c, 0x07, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x73, 0x01, 0x6c, 0xc0, 0x15, 0xc0, 0x31, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x1e, 0x00, 0x04, 0x8e, 0xfa, 0xbd, 0xce, 0x00, 0x00, 0x29, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - }, - }, - { - name: "DCCP", - packet: []byte{ - 0x45, 0x00, 0x00, 0x28, 0x15, 0x06, 0x40, 0x00, 0x40, 0x21, 0x5f, 0x2f, 0xc0, 0xa8, 0x01, 0x1f, 0xc9, 0x0b, 0x3b, 0xad, 0x80, 0x04, 0x13, 0x89, 0x05, 0x00, 0x08, 0xdb, 0x01, 0x00, 0x00, 0x04, 0x29, 0x01, 0x6d, 0xdc, 0x00, 0x00, 0x00, 0x00, - }, - }, - { - name: "SCTP", - packet: []byte{ - 0x45, 0x00, 0x00, 0x30, 0x09, 0xd9, 0x40, 0x00, 0xff, 0x84, 0x50, 0xe2, 0x0a, 0x1c, 0x06, 0x2c, 0x0a, 0x1c, 0x06, 0x2b, 0x0b, 0x80, 0x40, 0x00, 0x21, 0x44, 0x15, 0x23, 0x2b, 0xf2, 0x02, 0x4e, 0x03, 0x00, 0x00, 0x10, 0x28, 0x02, 0x43, 0x45, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, - }, - }, - // TODO(maisem): add test for GRE. - } - var p packet.Parsed - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p.Decode(tt.packet) - t.Log(p.String()) - UpdateSrcAddr(&p, netip.MustParseAddr("100.64.0.1")) - - got := binary.BigEndian.Uint16(tt.packet[10:12]) - want := fullHeaderChecksumV4(tt.packet[:20]) - if got != want { - t.Fatalf("got %x want %x", got, want) - } - - UpdateDstAddr(&p, netip.MustParseAddr("100.64.0.2")) - got = binary.BigEndian.Uint16(tt.packet[10:12]) - want = fullHeaderChecksumV4(tt.packet[:20]) - if got != want { - t.Fatalf("got %x want %x", got, want) - } - }) - } -} - -func TestNatChecksumsV6UDP(t *testing.T) { - a1, a2 := randV6Addr(), randV6Addr() - - // Make a fake UDP packet with 32 bytes of zeros as the datagram payload. - b := header.IPv6(make([]byte, header.IPv6MinimumSize+header.UDPMinimumSize+32)) - b.Encode(&header.IPv6Fields{ - PayloadLength: header.UDPMinimumSize + 32, - TransportProtocol: header.UDPProtocolNumber, - HopLimit: 16, - SrcAddr: tcpip.AddrFrom16Slice(a1.AsSlice()), - DstAddr: tcpip.AddrFrom16Slice(a2.AsSlice()), - }) - udp := header.UDP(b[header.IPv6MinimumSize:]) - udp.Encode(&header.UDPFields{ - SrcPort: 42, - DstPort: 43, - Length: header.UDPMinimumSize + 32, - }) - xsum := header.PseudoHeaderChecksum( - header.UDPProtocolNumber, - tcpip.AddrFrom16Slice(a1.AsSlice()), - tcpip.AddrFrom16Slice(a2.AsSlice()), - uint16(header.UDPMinimumSize+32), - ) - xsum = checksum.Checksum(b.Payload()[header.UDPMinimumSize:], xsum) - udp.SetChecksum(^udp.CalculateChecksum(xsum)) - if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a1.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) { - t.Fatal("test broken; initial packet has incorrect checksum") - } - - // Parse the packet. - var p, p2 packet.Parsed - p.Decode(b) - t.Log(p.String()) - - // Update the source address of the packet to be the same as the dest. - UpdateSrcAddr(&p, a2) - p2.Decode(p.Buffer()) - if p2.Src.Addr() != a2 { - t.Fatalf("got %v, want %v", p2.Src, a2) - } - if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) { - t.Fatal("incorrect checksum after updating source address") - } - - // Update the dest address of the packet to be the original source address. - UpdateDstAddr(&p, a1) - p2.Decode(p.Buffer()) - if p2.Dst.Addr() != a1 { - t.Fatalf("got %v, want %v", p2.Dst, a1) - } - if !udp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a1.AsSlice()), checksum.Checksum(b.Payload()[header.UDPMinimumSize:], 0)) { - t.Fatal("incorrect checksum after updating destination address") - } -} - -func randV6Addr() netip.Addr { - a1, a2 := rand.Int64(), rand.Int64() - return netip.AddrFrom16([16]byte{ - byte(a1 >> 56), byte(a1 >> 48), byte(a1 >> 40), byte(a1 >> 32), - byte(a1 >> 24), byte(a1 >> 16), byte(a1 >> 8), byte(a1), - byte(a2 >> 56), byte(a2 >> 48), byte(a2 >> 40), byte(a2 >> 32), - byte(a2 >> 24), byte(a2 >> 16), byte(a2 >> 8), byte(a2), - }) -} - -func TestNatChecksumsV6TCP(t *testing.T) { - a1, a2 := randV6Addr(), randV6Addr() - - // Make a fake TCP packet with no payload. - b := header.IPv6(make([]byte, header.IPv6MinimumSize+header.TCPMinimumSize)) - b.Encode(&header.IPv6Fields{ - PayloadLength: header.TCPMinimumSize, - TransportProtocol: header.TCPProtocolNumber, - HopLimit: 16, - SrcAddr: tcpip.AddrFrom16Slice(a1.AsSlice()), - DstAddr: tcpip.AddrFrom16Slice(a2.AsSlice()), - }) - tcp := header.TCP(b[header.IPv6MinimumSize:]) - tcp.Encode(&header.TCPFields{ - SrcPort: 42, - DstPort: 43, - SeqNum: 1, - AckNum: 2, - DataOffset: header.TCPMinimumSize, - Flags: 3, - WindowSize: 4, - Checksum: 0, - UrgentPointer: 5, - }) - xsum := header.PseudoHeaderChecksum( - header.TCPProtocolNumber, - tcpip.AddrFrom16Slice(a1.AsSlice()), - tcpip.AddrFrom16Slice(a2.AsSlice()), - uint16(header.TCPMinimumSize), - ) - tcp.SetChecksum(^tcp.CalculateChecksum(xsum)) - - if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a1.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), 0, 0) { - t.Fatal("test broken; initial packet has incorrect checksum") - } - - // Parse the packet. - var p, p2 packet.Parsed - p.Decode(b) - t.Log(p.String()) - - // Update the source address of the packet to be the same as the dest. - UpdateSrcAddr(&p, a2) - p2.Decode(p.Buffer()) - if p2.Src.Addr() != a2 { - t.Fatalf("got %v, want %v", p2.Src, a2) - } - if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a2.AsSlice()), 0, 0) { - t.Fatal("incorrect checksum after updating source address") - } - - // Update the dest address of the packet to be the original source address. - UpdateDstAddr(&p, a1) - p2.Decode(p.Buffer()) - if p2.Dst.Addr() != a1 { - t.Fatalf("got %v, want %v", p2.Dst, a1) - } - if !tcp.IsChecksumValid(tcpip.AddrFrom16Slice(a2.AsSlice()), tcpip.AddrFrom16Slice(a1.AsSlice()), 0, 0) { - t.Fatal("incorrect checksum after updating destination address") - } -} diff --git a/net/packet/icmp.go b/net/packet/icmp.go index 89a7aaa32bec4..68a29f216ebdd 100644 --- a/net/packet/icmp.go +++ b/net/packet/icmp.go @@ -5,7 +5,6 @@ package packet import ( crand "crypto/rand" - "encoding/binary" ) diff --git a/net/packet/icmp4.go b/net/packet/icmp4.go index 06780e0bb48ff..aa54d911fba15 100644 --- a/net/packet/icmp4.go +++ b/net/packet/icmp4.go @@ -6,7 +6,7 @@ package packet import ( "encoding/binary" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/types/ipproto" ) // icmp4HeaderLength is the size of the ICMPv4 packet header, not diff --git a/net/packet/icmp6.go b/net/packet/icmp6.go index f78db1f4a8c3c..afb6e97f919a2 100644 --- a/net/packet/icmp6.go +++ b/net/packet/icmp6.go @@ -6,7 +6,7 @@ package packet import ( "encoding/binary" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/types/ipproto" ) // icmp6HeaderLength is the size of the ICMPv6 packet header, not diff --git a/net/packet/icmp6_test.go b/net/packet/icmp6_test.go deleted file mode 100644 index f34883ca41e7e..0000000000000 --- a/net/packet/icmp6_test.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package packet - -import ( - "net/netip" - "testing" - - "tailscale.com/types/ipproto" -) - -func TestICMPv6PingResponse(t *testing.T) { - pingHdr := ICMP6Header{ - IP6Header: IP6Header{ - Src: netip.MustParseAddr("1::1"), - Dst: netip.MustParseAddr("2::2"), - IPProto: ipproto.ICMPv6, - }, - Type: ICMP6EchoRequest, - Code: ICMP6NoCode, - } - - // echoReqLen is 2 bytes identifier + 2 bytes seq number. - // https://datatracker.ietf.org/doc/html/rfc4443#section-4.1 - // Packet.IsEchoRequest verifies that these 4 bytes are present. - const echoReqLen = 4 - buf := make([]byte, pingHdr.Len()+echoReqLen) - if err := pingHdr.Marshal(buf); err != nil { - t.Fatal(err) - } - - var p Parsed - p.Decode(buf) - if !p.IsEchoRequest() { - t.Fatalf("not an echo request, got: %+v", p) - } - - pingHdr.ToResponse() - buf = make([]byte, pingHdr.Len()+echoReqLen) - if err := pingHdr.Marshal(buf); err != nil { - t.Fatal(err) - } - - p.Decode(buf) - if p.IsEchoRequest() { - t.Fatalf("unexpectedly still an echo request: %+v", p) - } - if !p.IsEchoResponse() { - t.Fatalf("not an echo response: %+v", p) - } -} - -func TestICMPv6Checksum(t *testing.T) { - const req = "\x60\x0f\x07\x00\x00\x10\x3a\x40\xfd\x7a\x11\x5c\xa1\xe0\xab\x12" + - "\x48\x43\xcd\x96\x62\x7b\x65\x28\x26\x07\xf8\xb0\x40\x0a\x08\x07" + - "\x00\x00\x00\x00\x00\x00\x20\x0e\x80\x00\x4a\x9a\x2e\xea\x00\x02" + - "\x61\xb1\x9e\xad\x00\x06\x45\xaa" - // The packet that we'd originally generated incorrectly, but with the checksum - // bytes fixed per WireShark's correct calculation: - const wantRes = "\x60\x00\xf8\xff\x00\x10\x3a\x40\x26\x07\xf8\xb0\x40\x0a\x08\x07" + - "\x00\x00\x00\x00\x00\x00\x20\x0e\xfd\x7a\x11\x5c\xa1\xe0\xab\x12" + - "\x48\x43\xcd\x96\x62\x7b\x65\x28\x81\x00\x49\x9a\x2e\xea\x00\x02" + - "\x61\xb1\x9e\xad\x00\x06\x45\xaa" - - var p Parsed - p.Decode([]byte(req)) - if !p.IsEchoRequest() { - t.Fatalf("not an echo request, got: %+v", p) - } - - h := p.ICMP6Header() - h.ToResponse() - pong := Generate(&h, p.Payload()) - - if string(pong) != wantRes { - t.Errorf("wrong packet\n\n got: %x\nwant: %x", pong, wantRes) - } -} diff --git a/net/packet/ip4.go b/net/packet/ip4.go index 967a8dba7f57b..882fdc37805b5 100644 --- a/net/packet/ip4.go +++ b/net/packet/ip4.go @@ -8,7 +8,7 @@ import ( "errors" "net/netip" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/types/ipproto" ) // ip4HeaderLength is the length of an IPv4 header with no IP options. diff --git a/net/packet/ip6.go b/net/packet/ip6.go index d26b9a1619b31..d10aabd774a83 100644 --- a/net/packet/ip6.go +++ b/net/packet/ip6.go @@ -7,7 +7,7 @@ import ( "encoding/binary" "net/netip" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/types/ipproto" ) // ip6HeaderLength is the length of an IPv6 header with no IP options. diff --git a/net/packet/packet.go b/net/packet/packet.go index c9521ad4667c2..472fcc617db6d 100644 --- a/net/packet/packet.go +++ b/net/packet/packet.go @@ -10,8 +10,8 @@ import ( "net/netip" "strings" - "tailscale.com/net/netaddr" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/types/ipproto" ) const unknown = ipproto.Unknown diff --git a/net/packet/packet_test.go b/net/packet/packet_test.go deleted file mode 100644 index 4fc804a4fea21..0000000000000 --- a/net/packet/packet_test.go +++ /dev/null @@ -1,632 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package packet - -import ( - "bytes" - "encoding/hex" - "net/netip" - "reflect" - "strings" - "testing" - "unicode" - - "tailscale.com/tstest" - "tailscale.com/types/ipproto" - "tailscale.com/util/must" -) - -const ( - Unknown = ipproto.Unknown - TCP = ipproto.TCP - UDP = ipproto.UDP - SCTP = ipproto.SCTP - IGMP = ipproto.IGMP - ICMPv4 = ipproto.ICMPv4 - ICMPv6 = ipproto.ICMPv6 - TSMP = ipproto.TSMP - Fragment = ipproto.Fragment -) - -func mustIPPort(s string) netip.AddrPort { - ipp, err := netip.ParseAddrPort(s) - if err != nil { - panic(err) - } - return ipp -} - -var icmp4RequestBuffer = []byte{ - // IP header up to checksum - 0x45, 0x00, 0x00, 0x27, 0xde, 0xad, 0x00, 0x00, 0x40, 0x01, 0x8c, 0x15, - // source IP - 0x01, 0x02, 0x03, 0x04, - // destination IP - 0x05, 0x06, 0x07, 0x08, - // ICMP header - 0x08, 0x00, 0x7d, 0x22, - // "request_payload" - 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, -} - -var icmp4RequestDecode = Parsed{ - b: icmp4RequestBuffer, - subofs: 20, - dataofs: 24, - length: len(icmp4RequestBuffer), - - IPVersion: 4, - IPProto: ICMPv4, - Src: mustIPPort("1.2.3.4:0"), - Dst: mustIPPort("5.6.7.8:0"), -} - -var icmp4ReplyBuffer = []byte{ - 0x45, 0x00, 0x00, 0x25, 0x21, 0x52, 0x00, 0x00, 0x40, 0x01, 0x49, 0x73, - // source IP - 0x05, 0x06, 0x07, 0x08, - // destination IP - 0x01, 0x02, 0x03, 0x04, - // ICMP header - 0x00, 0x00, 0xe6, 0x9e, - // "reply_payload" - 0x72, 0x65, 0x70, 0x6c, 0x79, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, -} - -// ICMPv6 Router Solicitation -var icmp6PacketBuffer = []byte{ - 0x60, 0x00, 0x00, 0x00, 0x00, 0x08, 0x3a, 0xff, - 0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xfb, 0x57, 0x1d, 0xea, 0x9c, 0x39, 0x8f, 0xb7, - 0xff, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, - 0x85, 0x00, 0x38, 0x04, 0x00, 0x00, 0x00, 0x00, -} - -var icmp6PacketDecode = Parsed{ - b: icmp6PacketBuffer, - subofs: 40, - dataofs: 44, - length: len(icmp6PacketBuffer), - IPVersion: 6, - IPProto: ICMPv6, - Src: mustIPPort("[fe80::fb57:1dea:9c39:8fb7]:0"), - Dst: mustIPPort("[ff02::2]:0"), -} - -// This is a malformed IPv4 packet. -// Namely, the string "tcp_payload" follows the first byte of the IPv4 header. -var unknownPacketBuffer = []byte{ - 0x45, 0x74, 0x63, 0x70, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, -} - -var unknownPacketDecode = Parsed{ - b: unknownPacketBuffer, - IPVersion: 0, - IPProto: Unknown, -} - -var tcp4PacketBuffer = []byte{ - // IP header up to checksum - 0x45, 0x00, 0x00, 0x37, 0xde, 0xad, 0x00, 0x00, 0x40, 0x06, 0x49, 0x5f, - // source IP - 0x01, 0x02, 0x03, 0x04, - // destination IP - 0x05, 0x06, 0x07, 0x08, - // TCP header with SYN, ACK set - 0x00, 0x7b, 0x02, 0x37, 0x00, 0x00, 0x12, 0x34, 0x00, 0x00, 0x00, 0x00, - 0x50, 0x12, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, - // "request_payload" - 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, -} - -var tcp4PacketDecode = Parsed{ - b: tcp4PacketBuffer, - subofs: 20, - dataofs: 40, - length: len(tcp4PacketBuffer), - - IPVersion: 4, - IPProto: TCP, - Src: mustIPPort("1.2.3.4:123"), - Dst: mustIPPort("5.6.7.8:567"), - TCPFlags: TCPSynAck, -} - -var tcp6RequestBuffer = []byte{ - // IPv6 header up to hop limit - 0x60, 0x06, 0xef, 0xcc, 0x00, 0x28, 0x06, 0x40, - // Src addr - 0x20, 0x01, 0x05, 0x59, 0xbc, 0x13, 0x54, 0x00, 0x17, 0x49, 0x46, 0x28, 0x39, 0x34, 0x0e, 0x1b, - // Dst addr - 0x26, 0x07, 0xf8, 0xb0, 0x40, 0x0a, 0x08, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0e, - // TCP SYN segment, no payload - 0xa4, 0x60, 0x00, 0x50, 0xf3, 0x82, 0xa1, 0x25, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x02, 0xfd, 0x20, - 0xb1, 0xc6, 0x00, 0x00, 0x02, 0x04, 0x05, 0xa0, 0x04, 0x02, 0x08, 0x0a, 0xca, 0x76, 0xa6, 0x8e, - 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x03, 0x07, -} - -var tcp6RequestDecode = Parsed{ - b: tcp6RequestBuffer, - subofs: 40, - dataofs: len(tcp6RequestBuffer), - length: len(tcp6RequestBuffer), - - IPVersion: 6, - IPProto: TCP, - Src: mustIPPort("[2001:559:bc13:5400:1749:4628:3934:e1b]:42080"), - Dst: mustIPPort("[2607:f8b0:400a:809::200e]:80"), - TCPFlags: TCPSyn, -} - -var udp4RequestBuffer = []byte{ - // IP header up to checksum - 0x45, 0x00, 0x00, 0x2b, 0xde, 0xad, 0x00, 0x00, 0x40, 0x11, 0x8c, 0x01, - // source IP - 0x01, 0x02, 0x03, 0x04, - // destination IP - 0x05, 0x06, 0x07, 0x08, - // UDP header - 0x00, 0x7b, 0x02, 0x37, 0x00, 0x17, 0x72, 0x1d, - // "request_payload" - 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, -} - -var udp4RequestDecode = Parsed{ - b: udp4RequestBuffer, - subofs: 20, - dataofs: 28, - length: len(udp4RequestBuffer), - - IPVersion: 4, - IPProto: UDP, - Src: mustIPPort("1.2.3.4:123"), - Dst: mustIPPort("5.6.7.8:567"), -} - -var invalid4RequestBuffer = []byte{ - // IP header up to checksum. IHL field points beyond end of packet. - 0x4a, 0x00, 0x00, 0x14, 0xde, 0xad, 0x00, 0x00, 0x40, 0x11, 0x8c, 0x01, - // source IP - 0x01, 0x02, 0x03, 0x04, - // destination IP - 0x05, 0x06, 0x07, 0x08, -} - -// Regression check for the IHL field pointing beyond the end of the -// packet. -var invalid4RequestDecode = Parsed{ - b: invalid4RequestBuffer, - subofs: 40, - length: len(invalid4RequestBuffer), - - IPVersion: 4, - IPProto: Unknown, - Src: mustIPPort("1.2.3.4:0"), - Dst: mustIPPort("5.6.7.8:0"), -} - -var udp6RequestBuffer = []byte{ - // IPv6 header up to hop limit - 0x60, 0x0e, 0xc9, 0x67, 0x00, 0x29, 0x11, 0x40, - // Src addr - 0x20, 0x01, 0x05, 0x59, 0xbc, 0x13, 0x54, 0x00, 0x17, 0x49, 0x46, 0x28, 0x39, 0x34, 0x0e, 0x1b, - // Dst addr - 0x26, 0x07, 0xf8, 0xb0, 0x40, 0x0a, 0x08, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0e, - // UDP header - 0xd4, 0x04, 0x01, 0xbb, 0x00, 0x29, 0x96, 0x84, - // Payload - 0x5c, 0x06, 0xae, 0x85, 0x02, 0xf5, 0xdb, 0x90, 0xe0, 0xe0, 0x93, 0xed, 0x9a, 0xd9, 0x92, 0x69, 0xbe, 0x36, 0x8a, 0x7d, 0xd7, 0xce, 0xd0, 0x8a, 0xf2, 0x51, 0x95, 0xff, 0xb6, 0x92, 0x70, 0x10, 0xd7, -} - -var udp6RequestDecode = Parsed{ - b: udp6RequestBuffer, - subofs: 40, - dataofs: 48, - length: len(udp6RequestBuffer), - - IPVersion: 6, - IPProto: UDP, - Src: mustIPPort("[2001:559:bc13:5400:1749:4628:3934:e1b]:54276"), - Dst: mustIPPort("[2607:f8b0:400a:809::200e]:443"), -} - -var udp4ReplyBuffer = []byte{ - // IP header up to checksum - 0x45, 0x00, 0x00, 0x29, 0x21, 0x52, 0x00, 0x00, 0x40, 0x11, 0x49, 0x5f, - // source IP - 0x05, 0x06, 0x07, 0x08, - // destination IP - 0x01, 0x02, 0x03, 0x04, - // UDP header - 0x02, 0x37, 0x00, 0x7b, 0x00, 0x15, 0xd3, 0x9d, - // "reply_payload" - 0x72, 0x65, 0x70, 0x6c, 0x79, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, -} - -// First TCP fragment of a packet with leading 24 bytes of 'a's -var tcp4MediumFragmentBuffer = []byte{ - // IP header up to checksum - 0x45, 0x20, 0x00, 0x4c, 0x2c, 0x62, 0x20, 0x00, 0x22, 0x06, 0x3a, 0x0f, - // source IP - 0x01, 0x02, 0x03, 0x04, - // destination IP - 0x05, 0x06, 0x07, 0x08, - // TCP header - 0x00, 0x50, 0xf3, 0x8c, 0x58, 0xad, 0x60, 0x94, 0x25, 0xe4, 0x23, 0xa8, 0x80, - 0x10, 0x01, 0xfd, 0xc6, 0x6e, 0x00, 0x00, 0x01, 0x01, 0x08, 0x0a, 0xff, 0x60, - 0xfb, 0xfe, 0xba, 0x31, 0x78, 0x6a, - // data - 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, - 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, -} - -var tcp4MediumFragmentDecode = Parsed{ - b: tcp4MediumFragmentBuffer, - subofs: 20, - dataofs: 52, - length: len(tcp4MediumFragmentBuffer), - - IPVersion: 4, - IPProto: TCP, - Src: mustIPPort("1.2.3.4:80"), - Dst: mustIPPort("5.6.7.8:62348"), - TCPFlags: 0x10, -} - -var tcp4ShortFragmentBuffer = []byte{ - // IP header up to checksum - 0x45, 0x20, 0x00, 0x1e, 0x2c, 0x62, 0x20, 0x00, 0x22, 0x06, 0x3c, 0x4f, - // source IP - 0x01, 0x02, 0x03, 0x04, - // destination IP - 0x05, 0x06, 0x07, 0x08, - // partial TCP header - 0x00, 0x50, 0xf3, 0x8c, 0x58, 0xad, 0x60, 0x94, 0x00, 0x00, -} - -var tcp4ShortFragmentDecode = Parsed{ - b: tcp4ShortFragmentBuffer, - subofs: 20, - dataofs: 0, - length: len(tcp4ShortFragmentBuffer), - // short fragments are rejected (marked unknown) to avoid header attacks as described in RFC 1858 - IPProto: ipproto.Unknown, - IPVersion: 4, - Src: mustIPPort("1.2.3.4:0"), - Dst: mustIPPort("5.6.7.8:0"), -} - -var igmpPacketBuffer = []byte{ - // IP header up to checksum - 0x46, 0xc0, 0x00, 0x20, 0x00, 0x00, 0x40, 0x00, 0x01, 0x02, 0x41, 0x22, - // source IP - 0xc0, 0xa8, 0x01, 0x52, - // destination IP - 0xe0, 0x00, 0x00, 0xfb, - // IGMP Membership Report - 0x94, 0x04, 0x00, 0x00, 0x16, 0x00, 0x09, 0x04, 0xe0, 0x00, 0x00, 0xfb, - //0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -} - -var igmpPacketDecode = Parsed{ - b: igmpPacketBuffer, - subofs: 24, - length: len(igmpPacketBuffer), - - IPVersion: 4, - IPProto: IGMP, - Src: mustIPPort("192.168.1.82:0"), - Dst: mustIPPort("224.0.0.251:0"), -} - -var ipv4TSMPBuffer = []byte{ - // IPv4 header: - 0x45, 0x00, - 0x00, 0x1b, // 20 + 7 bytes total - 0x00, 0x00, // ID - 0x00, 0x00, // Fragment - 0x40, // TTL - byte(TSMP), - 0x5f, 0xc3, // header checksum (wrong here) - // source IP: - 0x64, 0x5e, 0x0c, 0x0e, - // dest IP: - 0x64, 0x4a, 0x46, 0x03, - byte(TSMPTypeRejectedConn), - byte(TCP), - byte(RejectedDueToACLs), - 0x00, 123, // src port - 0x00, 80, // dst port -} - -var ipv4TSMPDecode = Parsed{ - b: ipv4TSMPBuffer, - subofs: 20, - dataofs: 20, - length: 27, - IPVersion: 4, - IPProto: TSMP, - Src: mustIPPort("100.94.12.14:0"), - Dst: mustIPPort("100.74.70.3:0"), -} - -// IPv4 SCTP -var sctpBuffer = []byte{ - // IPv4 header: - 0x45, 0x00, - 0x00, 0x20, // 20 + 12 bytes total - 0x00, 0x00, // ID - 0x00, 0x00, // Fragment - 0x40, // TTL - byte(SCTP), - // Checksum, unchecked: - 1, 2, - // source IP: - 0x64, 0x5e, 0x0c, 0x0e, - // dest IP: - 0x64, 0x4a, 0x46, 0x03, - // Src Port, Dest Port: - 0x00, 0x7b, 0x01, 0xc8, - // Verification tag: - 1, 2, 3, 4, - // Checksum: (unchecked) - 5, 6, 7, 8, -} - -var sctpDecode = Parsed{ - b: sctpBuffer, - subofs: 20, - length: 20 + 12, - IPVersion: 4, - IPProto: SCTP, - Src: mustIPPort("100.94.12.14:123"), - Dst: mustIPPort("100.74.70.3:456"), -} - -func TestParsedString(t *testing.T) { - tests := []struct { - name string - qdecode Parsed - want string - }{ - {"tcp4", tcp4PacketDecode, "TCP{1.2.3.4:123 > 5.6.7.8:567}"}, - {"tcp6", tcp6RequestDecode, "TCP{[2001:559:bc13:5400:1749:4628:3934:e1b]:42080 > [2607:f8b0:400a:809::200e]:80}"}, - {"udp4", udp4RequestDecode, "UDP{1.2.3.4:123 > 5.6.7.8:567}"}, - {"udp6", udp6RequestDecode, "UDP{[2001:559:bc13:5400:1749:4628:3934:e1b]:54276 > [2607:f8b0:400a:809::200e]:443}"}, - {"icmp4", icmp4RequestDecode, "ICMPv4{1.2.3.4:0 > 5.6.7.8:0}"}, - {"icmp6", icmp6PacketDecode, "ICMPv6{[fe80::fb57:1dea:9c39:8fb7]:0 > [ff02::2]:0}"}, - {"igmp", igmpPacketDecode, "IGMP{192.168.1.82:0 > 224.0.0.251:0}"}, - {"unknown", unknownPacketDecode, "Unknown{???}"}, - {"ipv4_tsmp", ipv4TSMPDecode, "TSMP{100.94.12.14:0 > 100.74.70.3:0}"}, - {"sctp", sctpDecode, "SCTP{100.94.12.14:123 > 100.74.70.3:456}"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.qdecode.String() - if got != tt.want { - t.Errorf("got %q; want %q", got, tt.want) - } - }) - } - - err := tstest.MinAllocsPerRun(t, 1, func() { - sinkString = tests[0].qdecode.String() - }) - if err != nil { - t.Error(err) - } -} - -// mustHexDecode is like hex.DecodeString, but panics on error -// and ignores whitespace in s. -func mustHexDecode(s string) []byte { - return must.Get(hex.DecodeString(strings.Map(func(r rune) rune { - if unicode.IsSpace(r) { - return -1 - } - return r - }, s))) -} - -func TestDecode(t *testing.T) { - tests := []struct { - name string - buf []byte - want Parsed - }{ - {"icmp4", icmp4RequestBuffer, icmp4RequestDecode}, - {"icmp6", icmp6PacketBuffer, icmp6PacketDecode}, - {"tcp4", tcp4PacketBuffer, tcp4PacketDecode}, - {"tcp6", tcp6RequestBuffer, tcp6RequestDecode}, - {"udp4", udp4RequestBuffer, udp4RequestDecode}, - {"udp6", udp6RequestBuffer, udp6RequestDecode}, - {"igmp", igmpPacketBuffer, igmpPacketDecode}, - {"unknown", unknownPacketBuffer, unknownPacketDecode}, - {"invalid4", invalid4RequestBuffer, invalid4RequestDecode}, - {"ipv4_tsmp", ipv4TSMPBuffer, ipv4TSMPDecode}, - {"ipv4_sctp", sctpBuffer, sctpDecode}, - {"ipv4_frag", tcp4MediumFragmentBuffer, tcp4MediumFragmentDecode}, - {"ipv4_fragtooshort", tcp4ShortFragmentBuffer, tcp4ShortFragmentDecode}, - - {"ip97", mustHexDecode("4500 0019 d186 4000 4061 751d 644a 4603 6449 e549 6865 6c6c 6f"), Parsed{ - IPVersion: 4, - IPProto: 97, - Src: netip.MustParseAddrPort("100.74.70.3:0"), - Dst: netip.MustParseAddrPort("100.73.229.73:0"), - b: mustHexDecode("4500 0019 d186 4000 4061 751d 644a 4603 6449 e549 6865 6c6c 6f"), - length: 25, - subofs: 20, - }}, - - // This packet purports to use protocol 0xFF, which is verboten and - // used internally as a sentinel value for fragments. So test that - // we map packets using 0xFF to Unknown (0) instead. - {"bogus_proto_ff", mustHexDecode("4500 0019 d186 4000 40" + "FF" /* bogus FF */ + " 751d 644a 4603 6449 e549 6865 6c6c 6f"), Parsed{ - IPVersion: 4, - IPProto: ipproto.Unknown, // 0, not bogus 0xFF - Src: netip.MustParseAddrPort("100.74.70.3:0"), - Dst: netip.MustParseAddrPort("100.73.229.73:0"), - b: mustHexDecode("4500 0019 d186 4000 40" + "FF" /* bogus FF */ + " 751d 644a 4603 6449 e549 6865 6c6c 6f"), - length: 25, - subofs: 20, - }}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var got Parsed - got.Decode(tt.buf) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("mismatch\n got: %s %#v\nwant: %s %#v", got.String(), got, tt.want.String(), tt.want) - } - }) - } - - err := tstest.MinAllocsPerRun(t, 0, func() { - var got Parsed - got.Decode(tests[0].buf) - }) - if err != nil { - t.Error(err) - } -} - -func BenchmarkDecode(b *testing.B) { - benches := []struct { - name string - buf []byte - }{ - {"tcp4", tcp4PacketBuffer}, - {"tcp6", tcp6RequestBuffer}, - {"udp4", udp4RequestBuffer}, - {"udp6", udp6RequestBuffer}, - {"icmp4", icmp4RequestBuffer}, - {"icmp6", icmp6PacketBuffer}, - {"igmp", igmpPacketBuffer}, - {"unknown", unknownPacketBuffer}, - } - - for _, bench := range benches { - b.Run(bench.name, func(b *testing.B) { - b.ReportAllocs() - for range b.N { - var p Parsed - p.Decode(bench.buf) - } - }) - } -} - -func TestMarshalRequest(t *testing.T) { - // Too small to hold our packets, but only barely. - var small [20]byte - var large [64]byte - - icmpHeader := icmp4RequestDecode.ICMP4Header() - udpHeader := udp4RequestDecode.UDP4Header() - tests := []struct { - name string - header Header - want []byte - }{ - {"icmp", &icmpHeader, icmp4RequestBuffer}, - {"udp", &udpHeader, udp4RequestBuffer}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.header.Marshal(small[:]) - if err != errSmallBuffer { - t.Errorf("got err: nil; want: %s", errSmallBuffer) - } - - dataOffset := tt.header.Len() - dataLength := copy(large[dataOffset:], []byte("request_payload")) - end := dataOffset + dataLength - err = tt.header.Marshal(large[:end]) - - if err != nil { - t.Errorf("got err: %s; want nil", err) - } - - if !bytes.Equal(large[:end], tt.want) { - t.Errorf("got %x; want %x", large[:end], tt.want) - } - }) - } -} - -func TestMarshalResponse(t *testing.T) { - var buf [64]byte - - icmpHeader := icmp4RequestDecode.ICMP4Header() - udpHeader := udp4RequestDecode.UDP4Header() - - type HeaderToResponser interface { - Header - // ToResponse transforms the header into one for a response packet. - // For instance, this swaps the source and destination IPs. - ToResponse() - } - - tests := []struct { - name string - header HeaderToResponser - want []byte - }{ - {"icmp", &icmpHeader, icmp4ReplyBuffer}, - {"udp", &udpHeader, udp4ReplyBuffer}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.header.ToResponse() - - dataOffset := tt.header.Len() - dataLength := copy(buf[dataOffset:], []byte("reply_payload")) - end := dataOffset + dataLength - err := tt.header.Marshal(buf[:end]) - - if err != nil { - t.Errorf("got err: %s; want nil", err) - } - - if !bytes.Equal(buf[:end], tt.want) { - t.Errorf("got %x; want %x", buf[:end], tt.want) - } - }) - } -} - -var sinkString string - -func BenchmarkString(b *testing.B) { - benches := []struct { - name string - buf []byte - }{ - {"tcp4", tcp4PacketBuffer}, - {"tcp6", tcp6RequestBuffer}, - {"udp4", udp4RequestBuffer}, - {"udp6", udp6RequestBuffer}, - {"icmp4", icmp4RequestBuffer}, - {"icmp6", icmp6PacketBuffer}, - {"igmp", igmpPacketBuffer}, - {"unknown", unknownPacketBuffer}, - } - - for _, bench := range benches { - b.Run(bench.name, func(b *testing.B) { - b.ReportAllocs() - var p Parsed - p.Decode(bench.buf) - b.ResetTimer() - for range b.N { - sinkString = p.String() - } - }) - } -} diff --git a/net/packet/tsmp.go b/net/packet/tsmp.go index 4e004cca2cb7a..57812d699d004 100644 --- a/net/packet/tsmp.go +++ b/net/packet/tsmp.go @@ -15,8 +15,8 @@ import ( "fmt" "net/netip" - "tailscale.com/net/flowtrack" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/net/flowtrack" + "github.com/sagernet/tailscale/types/ipproto" ) // TailscaleRejectedHeader is a TSMP message that says that one diff --git a/net/packet/tsmp_test.go b/net/packet/tsmp_test.go deleted file mode 100644 index e261e6a4199b3..0000000000000 --- a/net/packet/tsmp_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package packet - -import ( - "net/netip" - "testing" -) - -func TestTailscaleRejectedHeader(t *testing.T) { - tests := []struct { - h TailscaleRejectedHeader - wantStr string - }{ - { - h: TailscaleRejectedHeader{ - IPSrc: netip.MustParseAddr("5.5.5.5"), - IPDst: netip.MustParseAddr("1.2.3.4"), - Src: netip.MustParseAddrPort("1.2.3.4:567"), - Dst: netip.MustParseAddrPort("5.5.5.5:443"), - Proto: TCP, - Reason: RejectedDueToACLs, - }, - wantStr: "TSMP-reject-flow{TCP 1.2.3.4:567 > 5.5.5.5:443}: acl", - }, - { - h: TailscaleRejectedHeader{ - IPSrc: netip.MustParseAddr("2::2"), - IPDst: netip.MustParseAddr("1::1"), - Src: netip.MustParseAddrPort("[1::1]:567"), - Dst: netip.MustParseAddrPort("[2::2]:443"), - Proto: UDP, - Reason: RejectedDueToShieldsUp, - }, - wantStr: "TSMP-reject-flow{UDP [1::1]:567 > [2::2]:443}: shields", - }, - { - h: TailscaleRejectedHeader{ - IPSrc: netip.MustParseAddr("2::2"), - IPDst: netip.MustParseAddr("1::1"), - Src: netip.MustParseAddrPort("[1::1]:567"), - Dst: netip.MustParseAddrPort("[2::2]:443"), - Proto: UDP, - Reason: RejectedDueToIPForwarding, - MaybeBroken: true, - }, - wantStr: "TSMP-reject-flow{UDP [1::1]:567 > [2::2]:443}: host-ip-forwarding-unavailable", - }, - } - for i, tt := range tests { - gotStr := tt.h.String() - if gotStr != tt.wantStr { - t.Errorf("%v. String = %q; want %q", i, gotStr, tt.wantStr) - continue - } - pkt := make([]byte, tt.h.Len()) - tt.h.Marshal(pkt) - - var p Parsed - p.Decode(pkt) - t.Logf("Parsed: %+v", p) - t.Logf("Parsed: %s", p.String()) - back, ok := p.AsTailscaleRejectedHeader() - if !ok { - t.Errorf("%v. %q (%02x) didn't parse back", i, gotStr, pkt) - continue - } - if back != tt.h { - t.Errorf("%v. %q parsed back as %q", i, tt.h, back) - } - } -} diff --git a/net/packet/udp4.go b/net/packet/udp4.go index 0d5bca73e8c89..78fb189a44e1f 100644 --- a/net/packet/udp4.go +++ b/net/packet/udp4.go @@ -6,7 +6,7 @@ package packet import ( "encoding/binary" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/types/ipproto" ) // udpHeaderLength is the size of the UDP packet header, not including diff --git a/net/packet/udp6.go b/net/packet/udp6.go index 10fdcb99e525c..93ac0b0a0879e 100644 --- a/net/packet/udp6.go +++ b/net/packet/udp6.go @@ -6,7 +6,7 @@ package packet import ( "encoding/binary" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/types/ipproto" ) // UDP6Header is an IPv6+UDP header. diff --git a/net/ping/ping.go b/net/ping/ping.go index 01f3dcf2c4976..c52afdcd80e5f 100644 --- a/net/ping/ping.go +++ b/net/ping/ping.go @@ -19,12 +19,12 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/multierr" "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" - "tailscale.com/types/logger" - "tailscale.com/util/mak" - "tailscale.com/util/multierr" ) const ( diff --git a/net/ping/ping_test.go b/net/ping/ping_test.go deleted file mode 100644 index bbedbcad80e44..0000000000000 --- a/net/ping/ping_test.go +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ping - -import ( - "context" - "errors" - "fmt" - "net" - "testing" - "time" - - "golang.org/x/net/icmp" - "golang.org/x/net/ipv4" - "golang.org/x/net/ipv6" - "tailscale.com/tstest" - "tailscale.com/util/mak" -) - -var ( - localhost = &net.IPAddr{IP: net.IPv4(127, 0, 0, 1)} -) - -func TestPinger(t *testing.T) { - clock := &tstest.Clock{} - - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - p, closeP := mockPinger(t, clock) - defer closeP() - - bodyData := []byte("data goes here") - - // Start a ping in the background - r := make(chan time.Duration, 1) - go func() { - dur, err := p.Send(ctx, localhost, bodyData) - if err != nil { - t.Errorf("p.Send: %v", err) - r <- 0 - } else { - r <- dur - } - }() - - p.waitOutstanding(t, ctx, 1) - - // Fake a response from ourself - fakeResponse := mustMarshal(t, &icmp.Message{ - Type: ipv4.ICMPTypeEchoReply, - Code: ipv4.ICMPTypeEchoReply.Protocol(), - Body: &icmp.Echo{ - ID: 1234, - Seq: 1, - Data: bodyData, - }, - }) - - const fakeDuration = 100 * time.Millisecond - p.handleResponse(fakeResponse, clock.Now().Add(fakeDuration), v4Type) - - select { - case dur := <-r: - want := fakeDuration - if dur != want { - t.Errorf("wanted ping response time = %d; got %d", want, dur) - } - case <-ctx.Done(): - t.Fatal("did not get response by timeout") - } -} - -func TestV6Pinger(t *testing.T) { - if c, err := net.ListenPacket("udp6", "::1"); err != nil { - // skip test if we can't use IPv6. - t.Skipf("IPv6 not supported: %s", err) - } else { - c.Close() - } - - clock := &tstest.Clock{} - - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - p, closeP := mockPinger(t, clock) - defer closeP() - - bodyData := []byte("data goes here") - - // Start a ping in the background - r := make(chan time.Duration, 1) - go func() { - dur, err := p.Send(ctx, &net.IPAddr{IP: net.ParseIP("::")}, bodyData) - if err != nil { - t.Errorf("p.Send: %v", err) - r <- 0 - } else { - r <- dur - } - }() - - p.waitOutstanding(t, ctx, 1) - - // Fake a response from ourself - fakeResponse := mustMarshal(t, &icmp.Message{ - Type: ipv6.ICMPTypeEchoReply, - Code: ipv6.ICMPTypeEchoReply.Protocol(), - Body: &icmp.Echo{ - ID: 1234, - Seq: 1, - Data: bodyData, - }, - }) - - const fakeDuration = 100 * time.Millisecond - p.handleResponse(fakeResponse, clock.Now().Add(fakeDuration), v6Type) - - select { - case dur := <-r: - want := fakeDuration - if dur != want { - t.Errorf("wanted ping response time = %d; got %d", want, dur) - } - case <-ctx.Done(): - t.Fatal("did not get response by timeout") - } -} - -func TestPingerTimeout(t *testing.T) { - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - clock := &tstest.Clock{} - p, closeP := mockPinger(t, clock) - defer closeP() - - // Send a ping in the background - r := make(chan error, 1) - go func() { - _, err := p.Send(ctx, localhost, []byte("data goes here")) - r <- err - }() - - // Wait until we're blocking - p.waitOutstanding(t, ctx, 1) - - // Close everything down - p.cleanupOutstanding() - - // Should have got an error from the ping - err := <-r - if !errors.Is(err, net.ErrClosed) { - t.Errorf("wanted errors.Is(err, net.ErrClosed); got=%v", err) - } -} - -func TestPingerMismatch(t *testing.T) { - clock := &tstest.Clock{} - - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, 1*time.Second) // intentionally short - defer cancel() - - p, closeP := mockPinger(t, clock) - defer closeP() - - bodyData := []byte("data goes here") - - // Start a ping in the background - r := make(chan time.Duration, 1) - go func() { - dur, err := p.Send(ctx, localhost, bodyData) - if err != nil && !errors.Is(err, context.DeadlineExceeded) { - t.Errorf("p.Send: %v", err) - r <- 0 - } else { - r <- dur - } - }() - - p.waitOutstanding(t, ctx, 1) - - // "Receive" a bunch of intentionally malformed packets that should not - // result in the Send call above returning - badPackets := []struct { - name string - pkt *icmp.Message - }{ - { - name: "wrong type", - pkt: &icmp.Message{ - Type: ipv4.ICMPTypeDestinationUnreachable, - Code: 0, - Body: &icmp.DstUnreach{}, - }, - }, - { - name: "wrong id", - pkt: &icmp.Message{ - Type: ipv4.ICMPTypeEchoReply, - Code: 0, - Body: &icmp.Echo{ - ID: 9999, - Seq: 1, - Data: bodyData, - }, - }, - }, - { - name: "wrong seq", - pkt: &icmp.Message{ - Type: ipv4.ICMPTypeEchoReply, - Code: 0, - Body: &icmp.Echo{ - ID: 1234, - Seq: 5, - Data: bodyData, - }, - }, - }, - { - name: "bad body", - pkt: &icmp.Message{ - Type: ipv4.ICMPTypeEchoReply, - Code: 0, - Body: &icmp.Echo{ - ID: 1234, - Seq: 1, - - // Intentionally missing first byte - Data: bodyData[1:], - }, - }, - }, - } - - const fakeDuration = 100 * time.Millisecond - tm := clock.Now().Add(fakeDuration) - - for _, tt := range badPackets { - fakeResponse := mustMarshal(t, tt.pkt) - p.handleResponse(fakeResponse, tm, v4Type) - } - - // Also "receive" a packet that does not unmarshal as an ICMP packet - p.handleResponse([]byte("foo"), tm, v4Type) - - select { - case <-r: - t.Fatal("wanted timeout") - case <-ctx.Done(): - t.Logf("test correctly timed out") - } -} - -// udpingPacketConn will convert potentially ICMP destination addrs to UDP -// destination addrs in WriteTo so that a test that is intending to send ICMP -// traffic will instead send UDP traffic, without the higher level Pinger being -// aware of this difference. -type udpingPacketConn struct { - net.PacketConn - // destPort will be configured by the test to be the peer expected to respond to a ping. - destPort uint16 -} - -func (u *udpingPacketConn) WriteTo(body []byte, dest net.Addr) (int, error) { - switch d := dest.(type) { - case *net.IPAddr: - udpAddr := &net.UDPAddr{ - IP: d.IP, - Port: int(u.destPort), - Zone: d.Zone, - } - return u.PacketConn.WriteTo(body, udpAddr) - } - return 0, fmt.Errorf("unimplemented udpingPacketConn for %T", dest) -} - -func mockPinger(t *testing.T, clock *tstest.Clock) (*Pinger, func()) { - p := New(context.Background(), t.Logf, nil) - p.timeNow = clock.Now - p.Verbose = true - p.id = 1234 - - // In tests, we use UDP so that we can test without being root; this - // doesn't matter because we mock out the ICMP reply below to be a real - // ICMP echo reply packet. - conn4, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("net.ListenPacket: %v", err) - } - - conn6, err := net.ListenPacket("udp6", "[::]:0") - if err != nil { - t.Fatalf("net.ListenPacket: %v", err) - } - - conn4 = &udpingPacketConn{ - destPort: 12345, - PacketConn: conn4, - } - conn6 = &udpingPacketConn{ - PacketConn: conn6, - destPort: 12345, - } - - mak.Set(&p.conns, v4Type, conn4) - mak.Set(&p.conns, v6Type, conn6) - done := func() { - if err := p.Close(); err != nil { - t.Errorf("error on close: %v", err) - } - } - return p, done -} - -func mustMarshal(t *testing.T, m *icmp.Message) []byte { - t.Helper() - - b, err := m.Marshal(nil) - if err != nil { - t.Fatal(err) - } - return b -} - -func (p *Pinger) waitOutstanding(t *testing.T, ctx context.Context, count int) { - // This is a bit janky, but... we busy-loop to wait for the Send call - // to write to our map so we know that a response will be handled. - var haveMapEntry bool - for !haveMapEntry { - time.Sleep(10 * time.Millisecond) - select { - case <-ctx.Done(): - t.Error("no entry in ping map before timeout") - return - default: - } - - p.mu.Lock() - haveMapEntry = len(p.pings) == count - p.mu.Unlock() - } -} diff --git a/net/portmapper/igd_test.go b/net/portmapper/igd_test.go deleted file mode 100644 index 5c24d03aadde1..0000000000000 --- a/net/portmapper/igd_test.go +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portmapper - -import ( - "bytes" - "fmt" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "sync" - "sync/atomic" - "testing" - - "tailscale.com/control/controlknobs" - "tailscale.com/net/netaddr" - "tailscale.com/net/netmon" - "tailscale.com/syncs" - "tailscale.com/types/logger" -) - -// TestIGD is an IGD (Internet Gateway Device) for testing. It supports fake -// implementations of NAT-PMP, PCP, and/or UPnP to test clients against. -type TestIGD struct { - upnpConn net.PacketConn // for UPnP discovery - pxpConn net.PacketConn // for NAT-PMP and/or PCP - ts *httptest.Server - upnpHTTP syncs.AtomicValue[http.Handler] - logf logger.Logf - closed atomic.Bool - - // do* will log which packets are sent, but will not reply to unexpected packets. - - doPMP bool - doPCP bool - doUPnP bool - - mu sync.Mutex // guards below - counters igdCounters -} - -// TestIGDOptions are options -type TestIGDOptions struct { - PMP bool - PCP bool - UPnP bool // TODO: more options for 3 flavors of UPnP services -} - -type igdCounters struct { - numUPnPDiscoRecv int32 - numUPnPOtherUDPRecv int32 - numPMPRecv int32 - numPCPRecv int32 - numPCPDiscoRecv int32 - numPCPMapRecv int32 - numPCPOtherRecv int32 - numPMPPublicAddrRecv int32 - numPMPBogusRecv int32 - - numFailedWrites int32 - invalidPCPMapPkt int32 -} - -func NewTestIGD(logf logger.Logf, t TestIGDOptions) (*TestIGD, error) { - d := &TestIGD{ - doPMP: t.PMP, - doPCP: t.PCP, - doUPnP: t.UPnP, - } - d.logf = func(msg string, args ...any) { - // Don't log after the device has closed; - // stray trailing logging angers testing.T.Logf. - if d.closed.Load() { - return - } - logf(msg, args...) - } - var err error - if d.upnpConn, err = testListenUDP(); err != nil { - return nil, err - } - if d.pxpConn, err = testListenUDP(); err != nil { - d.upnpConn.Close() - return nil, err - } - d.ts = httptest.NewServer(http.HandlerFunc(d.serveUPnPHTTP)) - go d.serveUPnPDiscovery() - go d.servePxP() - return d, nil -} - -func testListenUDP() (net.PacketConn, error) { - return net.ListenPacket("udp4", "127.0.0.1:0") -} - -func (d *TestIGD) TestPxPPort() uint16 { - return uint16(d.pxpConn.LocalAddr().(*net.UDPAddr).Port) -} - -func (d *TestIGD) TestUPnPPort() uint16 { - return uint16(d.upnpConn.LocalAddr().(*net.UDPAddr).Port) -} - -func testIPAndGateway() (gw, ip netip.Addr, ok bool) { - return netaddr.IPv4(127, 0, 0, 1), netaddr.IPv4(1, 2, 3, 4), true -} - -func (d *TestIGD) Close() error { - d.closed.Store(true) - d.ts.Close() - d.upnpConn.Close() - d.pxpConn.Close() - return nil -} - -func (d *TestIGD) inc(p *int32) { - d.mu.Lock() - defer d.mu.Unlock() - (*p)++ -} - -func (d *TestIGD) stats() igdCounters { - d.mu.Lock() - defer d.mu.Unlock() - return d.counters -} - -func (d *TestIGD) SetUPnPHandler(h http.Handler) { - d.upnpHTTP.Store(h) -} - -func (d *TestIGD) serveUPnPHTTP(w http.ResponseWriter, r *http.Request) { - if handler := d.upnpHTTP.Load(); handler != nil { - handler.ServeHTTP(w, r) - return - } - - http.NotFound(w, r) -} - -func (d *TestIGD) serveUPnPDiscovery() { - buf := make([]byte, 1500) - for { - n, src, err := d.upnpConn.ReadFrom(buf) - if err != nil { - if !d.closed.Load() { - d.logf("serveUPnP failed: %v", err) - } - return - } - pkt := buf[:n] - if bytes.Equal(pkt, uPnPPacket) { // a super lazy "parse" - d.inc(&d.counters.numUPnPDiscoRecv) - resPkt := fmt.Appendf(nil, "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nEXT:\r\nSERVER: Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1\r\nLOCATION: %s\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1627958564\r\nBOOTID.UPNP.ORG: 1627958564\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n", d.ts.URL+"/rootDesc.xml") - if d.doUPnP { - _, err = d.upnpConn.WriteTo(resPkt, src) - if err != nil { - d.inc(&d.counters.numFailedWrites) - } - } - } else { - d.inc(&d.counters.numUPnPOtherUDPRecv) - } - } -} - -// servePxP serves NAT-PMP and PCP, which share a port number. -func (d *TestIGD) servePxP() { - buf := make([]byte, 1500) - for { - n, a, err := d.pxpConn.ReadFrom(buf) - if err != nil { - if !d.closed.Load() { - d.logf("servePxP failed: %v", err) - } - return - } - src := netaddr.Unmap(a.(*net.UDPAddr).AddrPort()) - if !src.IsValid() { - panic("bogus addr") - } - pkt := buf[:n] - if len(pkt) < 2 { - continue - } - ver := pkt[0] - switch ver { - default: - continue - case pmpVersion: - d.handlePMPQuery(pkt, src) - case pcpVersion: - d.handlePCPQuery(pkt, src) - } - } -} - -func (d *TestIGD) handlePMPQuery(pkt []byte, src netip.AddrPort) { - d.inc(&d.counters.numPMPRecv) - if len(pkt) < 2 { - return - } - op := pkt[1] - switch op { - case pmpOpMapPublicAddr: - if len(pkt) != 2 { - d.inc(&d.counters.numPMPBogusRecv) - return - } - d.inc(&d.counters.numPMPPublicAddrRecv) - - } - // TODO -} - -func (d *TestIGD) handlePCPQuery(pkt []byte, src netip.AddrPort) { - d.inc(&d.counters.numPCPRecv) - if len(pkt) < 24 { - return - } - op := pkt[1] - pktSrcBytes := [16]byte{} - copy(pktSrcBytes[:], pkt[8:24]) - pktSrc := netip.AddrFrom16(pktSrcBytes).Unmap() - if pktSrc != src.Addr() { - // TODO this error isn't fatal but should be rejected by server. - // Since it's a test it's difficult to get them the same though. - d.logf("mismatch of packet source and source IP: got %v, expected %v", pktSrc, src.Addr()) - } - switch op { - case pcpOpAnnounce: - d.inc(&d.counters.numPCPDiscoRecv) - if !d.doPCP { - return - } - resp := buildPCPDiscoResponse(pkt) - if _, err := d.pxpConn.WriteTo(resp, net.UDPAddrFromAddrPort(src)); err != nil { - d.inc(&d.counters.numFailedWrites) - } - case pcpOpMap: - if len(pkt) < 60 { - d.logf("got too short packet for pcp op map: %v", pkt) - d.inc(&d.counters.invalidPCPMapPkt) - return - } - d.inc(&d.counters.numPCPMapRecv) - if !d.doPCP { - return - } - resp := buildPCPMapResponse(pkt) - d.pxpConn.WriteTo(resp, net.UDPAddrFromAddrPort(src)) - default: - // unknown op code, ignore it for now. - d.inc(&d.counters.numPCPOtherRecv) - return - } -} - -func newTestClient(t *testing.T, igd *TestIGD) *Client { - var c *Client - c = NewClient(t.Logf, netmon.NewStatic(), nil, new(controlknobs.Knobs), func() { - t.Logf("port map changed") - t.Logf("have mapping: %v", c.HaveMapping()) - }) - c.testPxPPort = igd.TestPxPPort() - c.testUPnPPort = igd.TestUPnPPort() - c.netMon = netmon.NewStatic() - c.SetGatewayLookupFunc(testIPAndGateway) - return c -} diff --git a/net/portmapper/pcp_test.go b/net/portmapper/pcp_test.go deleted file mode 100644 index 8f8eef3ef8399..0000000000000 --- a/net/portmapper/pcp_test.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portmapper - -import ( - "encoding/binary" - "net/netip" - "testing" - - "tailscale.com/net/netaddr" -) - -var examplePCPMapResponse = []byte{2, 129, 0, 0, 0, 0, 28, 32, 0, 2, 155, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 112, 9, 24, 241, 208, 251, 45, 157, 76, 10, 188, 17, 0, 0, 0, 4, 210, 4, 210, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 135, 180, 175, 246} - -func TestParsePCPMapResponse(t *testing.T) { - mapping, err := parsePCPMapResponse(examplePCPMapResponse) - if err != nil { - t.Fatalf("failed to parse PCP Map Response: %v", err) - } - if mapping == nil { - t.Fatalf("got nil mapping when expected non-nil") - } - expectedAddr := netip.MustParseAddrPort("135.180.175.246:1234") - if mapping.external != expectedAddr { - t.Errorf("mismatched external address, got: %v, want: %v", mapping.external, expectedAddr) - } -} - -const ( - serverResponseBit = 1 << 7 - fakeLifetimeSec = 1<<31 - 1 -) - -func buildPCPDiscoResponse(req []byte) []byte { - out := make([]byte, 24) - out[0] = pcpVersion - out[1] = req[1] | serverResponseBit - out[3] = 0 - // Do not put an epoch time in 8:12, when we start using it, tests that use it should fail. - return out -} - -func buildPCPMapResponse(req []byte) []byte { - out := make([]byte, 24+36) - out[0] = pcpVersion - out[1] = req[1] | serverResponseBit - out[3] = 0 - binary.BigEndian.PutUint32(out[4:8], 1<<30) - // Do not put an epoch time in 8:12, when we start using it, tests that use it should fail. - mapResp := out[24:] - mapReq := req[24:] - // copy nonce, protocol and internal port - copy(mapResp[:13], mapReq[:13]) - copy(mapResp[16:18], mapReq[16:18]) - // assign external port - binary.BigEndian.PutUint16(mapResp[18:20], 4242) - assignedIP := netaddr.IPv4(127, 0, 0, 1) - assignedIP16 := assignedIP.As16() - copy(mapResp[20:36], assignedIP16[:]) - return out -} diff --git a/net/portmapper/portmapper.go b/net/portmapper/portmapper.go index 71b55b8a7f240..6f130a99af693 100644 --- a/net/portmapper/portmapper.go +++ b/net/portmapper/portmapper.go @@ -19,18 +19,18 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/neterror" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/util/clientmetric" "go4.org/mem" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/net/netaddr" - "tailscale.com/net/neterror" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/sockstats" - "tailscale.com/syncs" - "tailscale.com/types/logger" - "tailscale.com/types/nettype" - "tailscale.com/util/clientmetric" ) var disablePortMapperEnv = envknob.RegisterBool("TS_DISABLE_PORTMAPPER") diff --git a/net/portmapper/portmapper_test.go b/net/portmapper/portmapper_test.go deleted file mode 100644 index d321b720a02b3..0000000000000 --- a/net/portmapper/portmapper_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portmapper - -import ( - "context" - "os" - "reflect" - "strconv" - "testing" - "time" - - "tailscale.com/control/controlknobs" -) - -func TestCreateOrGetMapping(t *testing.T) { - if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v { - t.Skip("skipping test without HIT_NETWORK=1") - } - c := NewClient(t.Logf, nil, nil, new(controlknobs.Knobs), nil) - defer c.Close() - c.SetLocalPort(1234) - for i := range 2 { - if i > 0 { - time.Sleep(100 * time.Millisecond) - } - ext, err := c.createOrGetMapping(context.Background()) - t.Logf("Got: %v, %v", ext, err) - } -} - -func TestClientProbe(t *testing.T) { - if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v { - t.Skip("skipping test without HIT_NETWORK=1") - } - c := NewClient(t.Logf, nil, nil, new(controlknobs.Knobs), nil) - defer c.Close() - for i := range 3 { - if i > 0 { - time.Sleep(100 * time.Millisecond) - } - res, err := c.Probe(context.Background()) - t.Logf("Got(t=%dms): %+v, %v", i*100, res, err) - } -} - -func TestClientProbeThenMap(t *testing.T) { - if v, _ := strconv.ParseBool(os.Getenv("HIT_NETWORK")); !v { - t.Skip("skipping test without HIT_NETWORK=1") - } - c := NewClient(t.Logf, nil, nil, new(controlknobs.Knobs), nil) - defer c.Close() - c.debug.VerboseLogs = true - c.SetLocalPort(1234) - res, err := c.Probe(context.Background()) - t.Logf("Probe: %+v, %v", res, err) - ext, err := c.createOrGetMapping(context.Background()) - t.Logf("createOrGetMapping: %v, %v", ext, err) -} - -func TestProbeIntegration(t *testing.T) { - igd, err := NewTestIGD(t.Logf, TestIGDOptions{PMP: true, PCP: true, UPnP: true}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - c := newTestClient(t, igd) - t.Logf("Listening on pxp=%v, upnp=%v", c.testPxPPort, c.testUPnPPort) - defer c.Close() - - res, err := c.Probe(context.Background()) - if err != nil { - t.Fatalf("Probe: %v", err) - } - if !res.UPnP { - t.Errorf("didn't detect UPnP") - } - st := igd.stats() - want := igdCounters{ - numUPnPDiscoRecv: 1, - numPMPRecv: 1, - numPCPRecv: 1, - numPCPDiscoRecv: 1, - numPMPPublicAddrRecv: 1, - } - if !reflect.DeepEqual(st, want) { - t.Errorf("unexpected stats:\n got: %+v\nwant: %+v", st, want) - } - - t.Logf("Probe: %+v", res) - t.Logf("IGD stats: %+v", st) - // TODO(bradfitz): finish -} - -func TestPCPIntegration(t *testing.T) { - igd, err := NewTestIGD(t.Logf, TestIGDOptions{PMP: false, PCP: true, UPnP: false}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - c := newTestClient(t, igd) - defer c.Close() - res, err := c.Probe(context.Background()) - if err != nil { - t.Fatalf("probe failed: %v", err) - } - if res.UPnP || res.PMP { - t.Errorf("probe unexpectedly saw upnp or pmp: %+v", res) - } - if !res.PCP { - t.Fatalf("probe did not see pcp: %+v", res) - } - - external, err := c.createOrGetMapping(context.Background()) - if err != nil { - t.Fatalf("failed to get mapping: %v", err) - } - if !external.IsValid() { - t.Errorf("got zero IP, expected non-zero") - } - if c.mapping == nil { - t.Errorf("got nil mapping after successful createOrGetMapping") - } -} - -// Test to ensure that metric names generated by this function do not contain -// invalid characters. -// -// See https://github.com/tailscale/tailscale/issues/9551 -func TestGetUPnPErrorsMetric(t *testing.T) { - // This will panic if the metric name is invalid. - getUPnPErrorsMetric(100) - getUPnPErrorsMetric(0) - getUPnPErrorsMetric(-100) -} diff --git a/net/portmapper/select_test.go b/net/portmapper/select_test.go deleted file mode 100644 index 9e99c9a9d3a90..0000000000000 --- a/net/portmapper/select_test.go +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portmapper - -import ( - "context" - "encoding/xml" - "net/http" - "net/url" - "strings" - "testing" - - "github.com/tailscale/goupnp" - "github.com/tailscale/goupnp/dcps/internetgateway2" -) - -// NOTE: this is in a distinct file because the various string constants are -// pretty verbose. - -func TestSelectBestService(t *testing.T) { - mustParseURL := func(ss string) *url.URL { - u, err := url.Parse(ss) - if err != nil { - t.Fatalf("error parsing URL %q: %v", ss, err) - } - return u - } - - // Run a fake IGD server to respond to UPnP requests. - igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - testCases := []struct { - name string - rootDesc string - control map[string]map[string]any - want string // controlURL field - }{ - { - name: "single_device", - rootDesc: testRootDesc, - control: map[string]map[string]any{ - // Service that's up and should be selected. - "/ctl/IPConn": { - "GetExternalIPAddress": testGetExternalIPAddressResponse, - "GetStatusInfo": testGetStatusInfoResponse, - }, - }, - want: "/ctl/IPConn", - }, - { - name: "first_device_disconnected", - rootDesc: testSelectRootDesc, - control: map[string]map[string]any{ - // Service that's down; it's important that this is the - // one that's down since it's ordered first in the XML - // and we want to verify that our code properly queries - // and then skips it. - "/upnp/control/yomkmsnooi/wanipconn-1": { - "GetStatusInfo": testGetStatusInfoResponseDisconnected, - // NOTE: nothing else should be called - // if GetStatusInfo returns a - // disconnected result - }, - // Service that's up and should be selected. - "/upnp/control/xstnsgeuyh/wanipconn-7": { - "GetExternalIPAddress": testGetExternalIPAddressResponse, - "GetStatusInfo": testGetStatusInfoResponse, - }, - }, - want: "/upnp/control/xstnsgeuyh/wanipconn-7", - }, - { - name: "prefer_public_external_IP", - rootDesc: testSelectRootDesc, - control: map[string]map[string]any{ - // Service with a private external IP; order matters as above. - "/upnp/control/yomkmsnooi/wanipconn-1": { - "GetStatusInfo": testGetStatusInfoResponse, - "GetExternalIPAddress": testGetExternalIPAddressResponsePrivate, - }, - // Service that's up and should be selected. - "/upnp/control/xstnsgeuyh/wanipconn-7": { - "GetExternalIPAddress": testGetExternalIPAddressResponse, - "GetStatusInfo": testGetStatusInfoResponse, - }, - }, - want: "/upnp/control/xstnsgeuyh/wanipconn-7", - }, - { - name: "all_private_external_IPs", - rootDesc: testSelectRootDesc, - control: map[string]map[string]any{ - "/upnp/control/yomkmsnooi/wanipconn-1": { - "GetStatusInfo": testGetStatusInfoResponse, - "GetExternalIPAddress": testGetExternalIPAddressResponsePrivate, - }, - "/upnp/control/xstnsgeuyh/wanipconn-7": { - "GetStatusInfo": testGetStatusInfoResponse, - "GetExternalIPAddress": testGetExternalIPAddressResponsePrivate, - }, - }, - want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML - }, - { - name: "nothing_connected", - rootDesc: testSelectRootDesc, - control: map[string]map[string]any{ - "/upnp/control/yomkmsnooi/wanipconn-1": { - "GetStatusInfo": testGetStatusInfoResponseDisconnected, - }, - "/upnp/control/xstnsgeuyh/wanipconn-7": { - "GetStatusInfo": testGetStatusInfoResponseDisconnected, - }, - }, - want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML - }, - { - name: "GetStatusInfo_errors", - rootDesc: testSelectRootDesc, - control: map[string]map[string]any{ - "/upnp/control/yomkmsnooi/wanipconn-1": { - "GetStatusInfo": func(_ string) (int, string) { - return http.StatusInternalServerError, "internal error" - }, - }, - "/upnp/control/xstnsgeuyh/wanipconn-7": { - "GetStatusInfo": func(_ string) (int, string) { - return http.StatusNotFound, "not found" - }, - }, - }, - want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML - }, - { - name: "GetExternalIPAddress_bad_ip", - rootDesc: testSelectRootDesc, - control: map[string]map[string]any{ - "/upnp/control/yomkmsnooi/wanipconn-1": { - "GetStatusInfo": testGetStatusInfoResponse, - "GetExternalIPAddress": testGetExternalIPAddressResponseInvalid, - }, - "/upnp/control/xstnsgeuyh/wanipconn-7": { - "GetStatusInfo": testGetStatusInfoResponse, - "GetExternalIPAddress": testGetExternalIPAddressResponse, - }, - }, - want: "/upnp/control/xstnsgeuyh/wanipconn-7", - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - // Ensure that we're using our test IGD server for all requests. - rootDesc := strings.ReplaceAll(tt.rootDesc, "@SERVERURL@", igd.ts.URL) - - igd.SetUPnPHandler(&upnpServer{ - t: t, - Desc: rootDesc, - Control: tt.control, - }) - c := newTestClient(t, igd) - t.Logf("Listening on upnp=%v", c.testUPnPPort) - defer c.Close() - - // Ensure that we're using the HTTP client that talks to our test IGD server - ctx := context.Background() - ctx = goupnp.WithHTTPClient(ctx, c.upnpHTTPClientLocked()) - - loc := mustParseURL(igd.ts.URL) - rootDev := mustParseRootDev(t, rootDesc, loc) - - svc, err := selectBestService(ctx, t.Logf, rootDev, loc) - if err != nil { - t.Fatal(err) - } - - var controlURL string - switch v := svc.(type) { - case *internetgateway2.WANIPConnection2: - controlURL = v.ServiceClient.Service.ControlURL.Str - case *internetgateway2.WANIPConnection1: - controlURL = v.ServiceClient.Service.ControlURL.Str - case *internetgateway2.WANPPPConnection1: - controlURL = v.ServiceClient.Service.ControlURL.Str - default: - t.Fatalf("unknown client type: %T", v) - } - - if controlURL != tt.want { - t.Errorf("mismatched controlURL: got=%q want=%q", controlURL, tt.want) - } - }) - } -} - -func mustParseRootDev(t *testing.T, devXML string, loc *url.URL) *goupnp.RootDevice { - decoder := xml.NewDecoder(strings.NewReader(devXML)) - decoder.DefaultSpace = goupnp.DeviceXMLNamespace - decoder.CharsetReader = goupnp.CharsetReaderDefault - - root := new(goupnp.RootDevice) - if err := decoder.Decode(root); err != nil { - t.Fatalf("error decoding device XML: %v", err) - } - - // Ensure the URLBase is set properly; this is how DeviceByURL does it. - var urlBaseStr string - if root.URLBaseStr != "" { - urlBaseStr = root.URLBaseStr - } else { - urlBaseStr = loc.String() - } - urlBase, err := url.Parse(urlBaseStr) - if err != nil { - t.Fatalf("error parsing URL %q: %v", urlBaseStr, err) - } - root.SetURLBase(urlBase) - - return root -} - -// Note: adapted from mikrotikRootDescXML with addresses replaced with -// localhost, and unnecessary fields removed. -const testSelectRootDesc = ` - - - 1 - 0 - - - urn:schemas-upnp-org:device:InternetGatewayDevice:1 - MikroTik Router - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-INTERNET-GATEWAY-DEVICE- - - - urn:schemas-microsoft-com:service:OSInfo:1 - urn:microsoft-com:serviceId:OSInfo1 - /osinfo.xml - /upnp/control/oqjsxqshhz/osinfo - /upnp/event/cwzcyndrjf/osinfo - - - - - urn:schemas-upnp-org:device:WANDevice:1 - WAN Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-DEVICE--1 - - - urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 - urn:upnp-org:serviceId:WANCommonIFC1 - /wancommonifc-1.xml - /upnp/control/ivvmxhunyq/wancommonifc-1 - /upnp/event/mkjzdqvryf/wancommonifc-1 - - - - - urn:schemas-upnp-org:device:WANConnectionDevice:1 - WAN Connection Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--1 - - - urn:schemas-upnp-org:service:WANIPConnection:1 - urn:upnp-org:serviceId:WANIPConn1 - /wanipconn-1.xml - /upnp/control/yomkmsnooi/wanipconn-1 - /upnp/event/veeabhzzva/wanipconn-1 - - - - - - - urn:schemas-upnp-org:device:WANDevice:1 - WAN Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-DEVICE--7 - - - urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 - urn:upnp-org:serviceId:WANCommonIFC1 - /wancommonifc-7.xml - /upnp/control/vzcyyzzttz/wancommonifc-7 - /upnp/event/womwbqtbkq/wancommonifc-7 - - - - - urn:schemas-upnp-org:device:WANConnectionDevice:1 - WAN Connection Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--7 - - - urn:schemas-upnp-org:service:WANIPConnection:1 - urn:upnp-org:serviceId:WANIPConn1 - /wanipconn-7.xml - /upnp/control/xstnsgeuyh/wanipconn-7 - /upnp/event/rscixkusbs/wanipconn-7 - - - - - - - @SERVERURL@ - - @SERVERURL@ -` - -const testGetStatusInfoResponseDisconnected = ` - - - - Disconnected - ERROR_NONE - 0 - - - -` - -const testGetExternalIPAddressResponsePrivate = ` - - - - 10.9.8.7 - - - -` - -const testGetExternalIPAddressResponseInvalid = ` - - - - not-an-ip-addr - - - -` diff --git a/net/portmapper/upnp.go b/net/portmapper/upnp.go index f1199f0a6c584..1e0c3fa585d22 100644 --- a/net/portmapper/upnp.go +++ b/net/portmapper/upnp.go @@ -25,13 +25,13 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" "github.com/tailscale/goupnp" "github.com/tailscale/goupnp/dcps/internetgateway2" "github.com/tailscale/goupnp/soap" - "tailscale.com/envknob" - "tailscale.com/net/netns" - "tailscale.com/types/logger" - "tailscale.com/util/mak" ) // References: diff --git a/net/portmapper/upnp_test.go b/net/portmapper/upnp_test.go deleted file mode 100644 index c41b535a54df2..0000000000000 --- a/net/portmapper/upnp_test.go +++ /dev/null @@ -1,1118 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portmapper - -import ( - "context" - "encoding/xml" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "reflect" - "regexp" - "slices" - "sync/atomic" - "testing" - - "tailscale.com/tstest" -) - -// Google Wifi -const ( - googleWifiUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nUSN: uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece::urn:schemas-upnp-org:device:InternetGatewayDevice:2\r\nEXT:\r\nSERVER: Linux/5.4.0-1034-gcp UPnP/1.1 MiniUPnPd/1.9\r\nLOCATION: http://192.168.86.1:5000/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1\r\nBOOTID.UPNP.ORG: 1\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n" - - googleWifiRootDescXML = ` -10urn:schemas-upnp-org:device:InternetGatewayDevice:2OnHubGooglehttp://google.com/Wireless RouterOnHub1https://on.google.com/hub/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30eceurn:schemas-upnp-org:service:Layer3Forwarding:1urn:upnp-org:serviceId:Layer3Forwarding1/ctl/L3F/evt/L3F/L3F.xmlurn:schemas-upnp-org:service:DeviceProtection:1urn:upnp-org:serviceId:DeviceProtection1/ctl/DP/evt/DP/DP.xmlurn:schemas-upnp-org:device:WANDevice:2WANDeviceMiniUPnPhttp://miniupnp.free.fr/WAN DeviceWAN Device20210414http://miniupnp.free.fr/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30ecf000000000000urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1urn:upnp-org:serviceId:WANCommonIFC1/ctl/CmnIfCfg/evt/CmnIfCfg/WANCfg.xmlurn:schemas-upnp-org:device:WANConnectionDevice:2WANConnectionDeviceMiniUPnPhttp://miniupnp.free.fr/MiniUPnP daemonMiniUPnPd20210414http://miniupnp.free.fr/00000000uuid:a9708184-a6c0-413a-bbac-11bcf7e30ec0000000000000urn:schemas-upnp-org:service:WANIPConnection:2urn:upnp-org:serviceId:WANIPConn1/ctl/IPConn/evt/IPConn/WANIPCn.xmlhttp://testwifi.here/` - - // pfSense 2.5.0-RELEASE / FreeBSD 12.2-STABLE - pfSenseUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=120\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nEXT:\r\nSERVER: FreeBSD/12.2-STABLE UPnP/1.1 MiniUPnPd/2.2.1\r\nLOCATION: http://192.168.1.1:2189/rootDesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: 1627958564\r\nBOOTID.UPNP.ORG: 1627958564\r\nCONFIGID.UPNP.ORG: 1337\r\n\r\n" - - pfSenseRootDescXML = ` -11urn:schemas-upnp-org:device:InternetGatewayDevice:1FreeBSD routerFreeBSDhttp://www.freebsd.org/FreeBSD routerFreeBSD router2.5.0-RELEASEhttp://www.freebsd.org/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac11urn:schemas-upnp-org:service:Layer3Forwarding:1urn:upnp-org:serviceId:L3Forwarding1/L3F.xml/ctl/L3F/evt/L3Furn:schemas-upnp-org:device:WANDevice:1WANDeviceMiniUPnPhttp://miniupnp.free.fr/WAN DeviceWAN Device20210205http://miniupnp.free.fr/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac12000000000000urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1urn:upnp-org:serviceId:WANCommonIFC1/WANCfg.xml/ctl/CmnIfCfg/evt/CmnIfCfgurn:schemas-upnp-org:device:WANConnectionDevice:1WANConnectionDeviceMiniUPnPhttp://miniupnp.free.fr/MiniUPnP daemonMiniUPnPd20210205http://miniupnp.free.fr/BEE7052Buuid:bee7052b-49e8-3597-b545-55a1e38ac13000000000000urn:schemas-upnp-org:service:WANIPConnection:1urn:upnp-org:serviceId:WANIPConn1/WANIPCn.xml/ctl/IPConn/evt/IPConnhttps://192.168.1.1/` - - // Sagemcom FAST3890V3, https://github.com/tailscale/tailscale/issues/3557 - sagemcomUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Tue, 14 Dec 2021 07:51:29 GMT\r\nEXT:\r\nLOCATION: http://192.168.0.1:49153/69692b70/gatedesc0b.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: cabd6488-1dd1-11b2-9e52-a7461e1f098e\r\nSERVER: \r\nUser-Agent: redsonic\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:75802409-bccb-40e7-8e6c-fa095ecce13e::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n" - - // Huawei, https://github.com/tailscale/tailscale/issues/6320 - huaweiUPnPDisco = "HTTP/1.1 200 OK\r\nCACHE-CONTROL: max-age=1800\r\nDATE: Fri, 25 Nov 2022 07:04:37 GMT\r\nEXT:\r\nLOCATION: http://192.168.1.1:49652/49652gatedesc.xml\r\nOPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n01-NLS: ce8dd8b0-732d-11be-a4a1-a2b26c8915fb\r\nSERVER: Linux/4.4.240, UPnP/1.0, Portable SDK for UPnP devices/1.12.1\r\nX-User-Agent: UPnP/1.0 DLNADOC/1.50\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\nUSN: uuid:00e0fc37-2525-2828-2500-0C31DCD93368::urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n" - - // Mikrotik CHR v7.10, https://github.com/tailscale/tailscale/issues/8364 - mikrotikRootDescXML = ` - - - 1 - 0 - - - urn:schemas-upnp-org:device:InternetGatewayDevice:1 - MikroTik Router - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-INTERNET-GATEWAY-DEVICE- - - - image/gif - 16 - 16 - 8 - /logo16.gif - - - image/gif - 32 - 32 - 8 - /logo32.gif - - - image/gif - 48 - 48 - 8 - /logo48.gif - - - - - urn:schemas-microsoft-com:service:OSInfo:1 - urn:microsoft-com:serviceId:OSInfo1 - /osinfo.xml - /upnp/control/oqjsxqshhz/osinfo - /upnp/event/cwzcyndrjf/osinfo - - - - - urn:schemas-upnp-org:device:WANDevice:1 - WAN Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-DEVICE--1 - - - urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 - urn:upnp-org:serviceId:WANCommonIFC1 - /wancommonifc-1.xml - /upnp/control/ivvmxhunyq/wancommonifc-1 - /upnp/event/mkjzdqvryf/wancommonifc-1 - - - - - urn:schemas-upnp-org:device:WANConnectionDevice:1 - WAN Connection Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--1 - - - urn:schemas-upnp-org:service:WANIPConnection:1 - urn:upnp-org:serviceId:WANIPConn1 - /wanipconn-1.xml - /upnp/control/yomkmsnooi/wanipconn-1 - /upnp/event/veeabhzzva/wanipconn-1 - - - - - - - urn:schemas-upnp-org:device:WANDevice:1 - WAN Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-DEVICE--7 - - - urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 - urn:upnp-org:serviceId:WANCommonIFC1 - /wancommonifc-7.xml - /upnp/control/vzcyyzzttz/wancommonifc-7 - /upnp/event/womwbqtbkq/wancommonifc-7 - - - - - urn:schemas-upnp-org:device:WANConnectionDevice:1 - WAN Connection Device - MikroTik - https://www.mikrotik.com/ - Router OS - uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--7 - - - urn:schemas-upnp-org:service:WANIPConnection:1 - urn:upnp-org:serviceId:WANIPConn1 - /wanipconn-7.xml - /upnp/control/xstnsgeuyh/wanipconn-7 - /upnp/event/rscixkusbs/wanipconn-7 - - - - - - - http://10.0.0.1/ - http://127.0.0.1/ - - http://10.0.0.1:2828 - -` - - // Huawei, https://github.com/tailscale/tailscale/issues/10911 - huaweiRootDescXML = ` - - - 1 - 0 - - - urn:dslforum-org:device:InternetGatewayDevice:1 - HG531 V1 - Huawei Technologies Co., Ltd. - http://www.huawei.com - Huawei Home Gateway - HG531 V1 - Huawei Model - http://www.huawei.com - G6J8W15326003974 - uuid:00e0fc37-2626-2828-2600-587f668bdd9a - 000000000001 - - - urn:www-huawei-com:service:DeviceConfig:1 - urn:www-huawei-com:serviceId:DeviceConfig1 - /desc/DevCfg.xml - /ctrlt/DeviceConfig_1 - /evt/DeviceConfig_1 - - - urn:dslforum-org:service:LANConfigSecurity:1 - urn:dslforum-org:serviceId:LANConfigSecurity1 - /desc/LANSec.xml - /ctrlt/LANConfigSecurity_1 - /evt/LANConfigSecurity_1 - - - urn:dslforum-org:service:Layer3Forwarding:1 - urn:dslforum-org:serviceId:Layer3Forwarding1 - /desc/L3Fwd.xml - /ctrlt/Layer3Forwarding_1 - /evt/Layer3Forwarding_1 - - - - - urn:dslforum-org:device:WANDevice:1 - WANDevice - Huawei Technologies Co., Ltd. - http://www.huawei.com - Huawei Home Gateway - HG531 V1 - Huawei Model - http://www.huawei.com - G6J8W15326003974 - uuid:00e0fc37-2626-2828-2601-587f668bdd9a - 000000000001 - - - urn:dslforum-org:service:WANDSLInterfaceConfig:1 - urn:dslforum-org:serviceId:WANDSLInterfaceConfig1 - /desc/WanDslIfCfg.xml - /ctrlt/WANDSLInterfaceConfig_1 - /evt/WANDSLInterfaceConfig_1 - - - urn:dslforum-org:service:WANCommonInterfaceConfig:1 - urn:dslforum-org:serviceId:WANCommonInterfaceConfig1 - /desc/WanCommonIfc1.xml - /ctrlt/WANCommonInterfaceConfig_1 - /evt/WANCommonInterfaceConfig_1 - - - - - urn:dslforum-org:device:WANConnectionDevice:1 - WANConnectionDevice - Huawei Technologies Co., Ltd. - http://www.huawei.com - Huawei Home Gateway - HG531 V1 - Huawei Model - http://www.huawei.com - G6J8W15326003974 - uuid:00e0fc37-2626-2828-2603-587f668bdd9a - 000000000001 - - - urn:dslforum-org:service:WANPPPConnection:1 - urn:dslforum-org:serviceId:WANPPPConnection1 - /desc/WanPppConn.xml - /ctrlt/WANPPPConnection_1 - /evt/WANPPPConnection_1 - - - urn:dslforum-org:service:WANEthernetConnectionManagement:1 - urn:dslforum-org:serviceId:WANEthernetConnectionManagement1 - /desc/WanEthConnMgt.xml - /ctrlt/WANEthernetConnectionManagement_1 - /evt/WANEthernetConnectionManagement_1 - - - urn:dslforum-org:service:WANDSLLinkConfig:1 - urn:dslforum-org:serviceId:WANDSLLinkConfig1 - /desc/WanDslLink.xml - /ctrlt/WANDSLLinkConfig_1 - /evt/WANDSLLinkConfig_1 - - - - - - - urn:dslforum-org:device:LANDevice:1 - LANDevice - Huawei Technologies Co., Ltd. - http://www.huawei.com - Huawei Home Gateway - HG531 V1 - Huawei Model - http://www.huawei.com - G6J8W15326003974 - uuid:00e0fc37-2626-2828-2602-587f668bdd9a - 000000000001 - - - urn:dslforum-org:service:WLANConfiguration:1 - urn:dslforum-org:serviceId:WLANConfiguration4 - /desc/WLANCfg.xml - /ctrlt/WLANConfiguration_4 - /evt/WLANConfiguration_4 - - - urn:dslforum-org:service:WLANConfiguration:1 - urn:dslforum-org:serviceId:WLANConfiguration3 - /desc/WLANCfg.xml - /ctrlt/WLANConfiguration_3 - /evt/WLANConfiguration_3 - - - urn:dslforum-org:service:WLANConfiguration:1 - urn:dslforum-org:serviceId:WLANConfiguration2 - /desc/WLANCfg.xml - /ctrlt/WLANConfiguration_2 - /evt/WLANConfiguration_2 - - - urn:dslforum-org:service:WLANConfiguration:1 - urn:dslforum-org:serviceId:WLANConfiguration1 - /desc/WLANCfg.xml - /ctrlt/WLANConfiguration_1 - /evt/WLANConfiguration_1 - - - urn:dslforum-org:service:LANHostConfigManagement:1 - urn:dslforum-org:serviceId:LANHostConfigManagement1 - /desc/LanHostCfgMgmt.xml - /ctrlt/LANHostConfigManagement_1 - /evt/LANHostConfigManagement_1 - - - - - http://127.0.0.1 - - -` - - noSupportedServicesRootDesc = ` - - - 1 - 0 - - - urn:dslforum-org:device:InternetGatewayDevice:1 - Fake Router - Tailscale, Inc - http://www.tailscale.com - Fake Router - Test Model - v1 - http://www.tailscale.com - 123456789 - uuid:11111111-2222-3333-4444-555555555555 - 000000000001 - - - urn:schemas-microsoft-com:service:OSInfo:1 - urn:microsoft-com:serviceId:OSInfo1 - /osinfo.xml - /upnp/control/aaaaaaaaaa/osinfo - /upnp/event/aaaaaaaaaa/osinfo - - - - - urn:schemas-upnp-org:device:WANDevice:1 - WANDevice - Tailscale, Inc - http://www.tailscale.com - Tailscale Test Router - Test Model - v1 - http://www.tailscale.com - 123456789 - uuid:11111111-2222-3333-4444-555555555555 - 000000000001 - - - urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 - urn:upnp-org:serviceId:WANCommonIFC1 - /ctl/bbbbbbbb - /evt/bbbbbbbb - /WANCfg.xml - - - - - urn:schemas-upnp-org:device:WANConnectionDevice:1 - WANConnectionDevice - Tailscale, Inc - http://www.tailscale.com - Tailscale Test Router - Test Model - v1 - http://www.tailscale.com - 123456789 - uuid:11111111-2222-3333-4444-555555555555 - 000000000001 - - - urn:tailscale:service:SomethingElse:1 - urn:upnp-org:serviceId:TailscaleSomethingElse - /desc/SomethingElse.xml - /ctrlt/SomethingElse_1 - /evt/SomethingElse_1 - - - - - - - http://127.0.0.1 - - -` -) - -func TestParseUPnPDiscoResponse(t *testing.T) { - tests := []struct { - name string - headers string - want uPnPDiscoResponse - }{ - {"google", googleWifiUPnPDisco, uPnPDiscoResponse{ - Location: "http://192.168.86.1:5000/rootDesc.xml", - Server: "Linux/5.4.0-1034-gcp UPnP/1.1 MiniUPnPd/1.9", - USN: "uuid:a9708184-a6c0-413a-bbac-11bcf7e30ece::urn:schemas-upnp-org:device:InternetGatewayDevice:2", - }}, - {"pfsense", pfSenseUPnPDisco, uPnPDiscoResponse{ - Location: "http://192.168.1.1:2189/rootDesc.xml", - Server: "FreeBSD/12.2-STABLE UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }}, - {"sagemcom", sagemcomUPnPDisco, uPnPDiscoResponse{ - Location: "http://192.168.0.1:49153/69692b70/gatedesc0b.xml", - Server: "", - USN: "uuid:75802409-bccb-40e7-8e6c-fa095ecce13e::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }}, - {"huawei", huaweiUPnPDisco, uPnPDiscoResponse{ - Location: "http://192.168.1.1:49652/49652gatedesc.xml", - Server: "Linux/4.4.240, UPnP/1.0, Portable SDK for UPnP devices/1.12.1", - USN: "uuid:00e0fc37-2525-2828-2500-0C31DCD93368::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseUPnPDiscoResponse([]byte(tt.headers)) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("unexpected result:\n got: %+v\nwant: %+v\n", got, tt.want) - } - }) - } -} - -func TestGetUPnPClient(t *testing.T) { - tests := []struct { - name string - xmlBody string - want string - wantLog string - }{ - { - "google", - googleWifiRootDescXML, - "*internetgateway2.WANIPConnection2", - "saw UPnP type WANIPConnection2 at http://127.0.0.1:NNN/rootDesc.xml; OnHub (Google), method=single\n", - }, - { - "pfsense", - pfSenseRootDescXML, - "*internetgateway2.WANIPConnection1", - "saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; FreeBSD router (FreeBSD), method=single\n", - }, - { - "mikrotik", - mikrotikRootDescXML, - "*internetgateway2.WANIPConnection1", - "saw UPnP type WANIPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; MikroTik Router (MikroTik), method=none\n", - }, - { - "huawei", - huaweiRootDescXML, - "*portmapper.legacyWANPPPConnection1", - "saw UPnP type *portmapper.legacyWANPPPConnection1 at http://127.0.0.1:NNN/rootDesc.xml; HG531 V1 (Huawei Technologies Co., Ltd.), method=single\n", - }, - { - "not_supported", - noSupportedServicesRootDesc, - "", - "", - }, - - // TODO(bradfitz): find a PPP one in the wild - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.RequestURI == "/rootDesc.xml" { - io.WriteString(w, tt.xmlBody) - return - } - http.NotFound(w, r) - })) - defer ts.Close() - gw, _ := netip.AddrFromSlice(ts.Listener.Addr().(*net.TCPAddr).IP) - gw = gw.Unmap() - - ctx := context.Background() - - var logBuf tstest.MemLogger - dev, loc, err := getUPnPRootDevice(ctx, logBuf.Logf, DebugKnobs{}, gw, uPnPDiscoResponse{ - Location: ts.URL + "/rootDesc.xml", - }) - if err != nil { - t.Fatal(err) - } - c, err := selectBestService(ctx, logBuf.Logf, dev, loc) - if err != nil { - t.Fatal(err) - } - got := fmt.Sprintf("%T", c) - if got != tt.want { - t.Errorf("got %v; want %v", got, tt.want) - } - gotLog := regexp.MustCompile(`127\.0\.0\.1:\d+`).ReplaceAllString(logBuf.String(), "127.0.0.1:NNN") - if gotLog != tt.wantLog { - t.Errorf("logged %q; want %q", gotLog, tt.wantLog) - } - }) - } -} - -func TestGetUPnPPortMapping(t *testing.T) { - igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - // This is a very basic fake UPnP server handler. - var sawRequestWithLease atomic.Bool - handlers := map[string]any{ - "AddPortMapping": func(body []byte) (int, string) { - // Decode a minimal body to determine whether we skip the request or not. - var req struct { - Protocol string `xml:"NewProtocol"` - InternalPort string `xml:"NewInternalPort"` - ExternalPort string `xml:"NewExternalPort"` - InternalClient string `xml:"NewInternalClient"` - LeaseDuration string `xml:"NewLeaseDuration"` - } - if err := xml.Unmarshal(body, &req); err != nil { - t.Errorf("bad request: %v", err) - return http.StatusBadRequest, "bad request" - } - - if req.Protocol != "UDP" { - t.Errorf(`got Protocol=%q, want "UDP"`, req.Protocol) - } - if req.LeaseDuration != "0" { - // Return a fake error to ensure that we fall back to a permanent lease. - sawRequestWithLease.Store(true) - return http.StatusOK, testAddPortMappingPermanentLease - } - - // Success! - return http.StatusOK, testAddPortMappingResponse - }, - "GetExternalIPAddress": testGetExternalIPAddressResponse, - "GetStatusInfo": testGetStatusInfoResponse, - "DeletePortMapping": "", // Do nothing for test - } - - ctx := context.Background() - - rootDescsToTest := []string{testRootDesc, mikrotikRootDescXML} - for _, rootDesc := range rootDescsToTest { - igd.SetUPnPHandler(&upnpServer{ - t: t, - Desc: rootDesc, - Control: map[string]map[string]any{ - "/ctl/IPConn": handlers, - "/upnp/control/yomkmsnooi/wanipconn-1": handlers, - }, - }) - - c := newTestClient(t, igd) - t.Logf("Listening on upnp=%v", c.testUPnPPort) - defer c.Close() - - c.debug.VerboseLogs = true - - // Try twice to test the "cache previous mapping" logic. - var ( - firstResponse netip.AddrPort - prevPort uint16 - ) - for i := range 2 { - sawRequestWithLease.Store(false) - mustProbeUPnP(t, ctx, c) - - gw, myIP, ok := c.gatewayAndSelfIP() - if !ok { - t.Fatalf("could not get gateway and self IP") - } - t.Logf("gw=%v myIP=%v", gw, myIP) - - ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), prevPort) - if !ok { - t.Fatal("could not get UPnP port mapping") - } - if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want { - t.Errorf("bad external address; got %v want %v", got, want) - } - if !sawRequestWithLease.Load() { - t.Errorf("wanted request with lease, but didn't see one") - } - if i == 0 { - firstResponse = ext - prevPort = ext.Port() - } else if firstResponse != ext { - t.Errorf("got different response on second attempt: (got) %v != %v (want)", ext, firstResponse) - } - t.Logf("external IP: %v", ext) - } - } -} - -// TestGetUPnPPortMapping_NoValidServices tests that getUPnPPortMapping doesn't -// crash when a valid UPnP response with no supported services is discovered -// and parsed. -// -// See https://github.com/tailscale/tailscale/issues/10911 -func TestGetUPnPPortMapping_NoValidServices(t *testing.T) { - igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - igd.SetUPnPHandler(&upnpServer{ - t: t, - Desc: noSupportedServicesRootDesc, - }) - - c := newTestClient(t, igd) - defer c.Close() - c.debug.VerboseLogs = true - - ctx := context.Background() - mustProbeUPnP(t, ctx, c) - - gw, myIP, ok := c.gatewayAndSelfIP() - if !ok { - t.Fatalf("could not get gateway and self IP") - } - - // This shouldn't panic - _, ok = c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0) - if ok { - t.Fatal("did not expect to get UPnP port mapping") - } -} - -// Tests the legacy behaviour with the pre-UPnP standard portmapping service. -func TestGetUPnPPortMapping_Legacy(t *testing.T) { - igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - // This is a very basic fake UPnP server handler. - handlers := map[string]any{ - "AddPortMapping": testLegacyAddPortMappingResponse, - "GetExternalIPAddress": testLegacyGetExternalIPAddressResponse, - "GetStatusInfo": testLegacyGetStatusInfoResponse, - "DeletePortMapping": "", // Do nothing for test - } - - igd.SetUPnPHandler(&upnpServer{ - t: t, - Desc: huaweiRootDescXML, - Control: map[string]map[string]any{ - "/ctrlt/WANPPPConnection_1": handlers, - }, - }) - - c := newTestClient(t, igd) - defer c.Close() - c.debug.VerboseLogs = true - - ctx := context.Background() - mustProbeUPnP(t, ctx, c) - - gw, myIP, ok := c.gatewayAndSelfIP() - if !ok { - t.Fatalf("could not get gateway and self IP") - } - - ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0) - if !ok { - t.Fatal("could not get UPnP port mapping") - } - if got, want := ext.Addr(), netip.MustParseAddr("123.123.123.123"); got != want { - t.Errorf("bad external address; got %v want %v", got, want) - } -} - -func TestGetUPnPPortMappingNoResponses(t *testing.T) { - igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - c := newTestClient(t, igd) - t.Logf("Listening on upnp=%v", c.testUPnPPort) - defer c.Close() - - c.debug.VerboseLogs = true - - // Do this before setting uPnPMetas since it invalidates those mappings - // if gw/myIP change. - gw, myIP, _ := c.gatewayAndSelfIP() - - t.Run("ErrorContactingUPnP", func(t *testing.T) { - c.mu.Lock() - c.uPnPMetas = []uPnPDiscoResponse{{ - Location: "http://127.0.0.1:1/does-not-exist.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", - }} - c.mu.Unlock() - - _, ok := c.getUPnPPortMapping(context.Background(), gw, netip.AddrPortFrom(myIP, 12345), 0) - if ok { - t.Errorf("expected no mapping when there are no responses") - } - }) -} - -func TestProcessUPnPResponses(t *testing.T) { - testCases := []struct { - name string - responses []uPnPDiscoResponse - want []uPnPDiscoResponse - }{ - { - name: "single", - responses: []uPnPDiscoResponse{{ - Location: "http://192.168.1.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }}, - want: []uPnPDiscoResponse{{ - Location: "http://192.168.1.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }}, - }, - { - name: "multiple_with_same_location", - responses: []uPnPDiscoResponse{ - { - Location: "http://192.168.1.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }, - { - Location: "http://192.168.1.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", - }, - }, - want: []uPnPDiscoResponse{{ - Location: "http://192.168.1.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", - }}, - }, - { - name: "multiple_with_different_location", - responses: []uPnPDiscoResponse{ - { - Location: "http://192.168.1.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }, - { - Location: "http://192.168.100.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", - }, - }, - want: []uPnPDiscoResponse{ - // note: this sorts first because we prefer "InternetGatewayDevice:2" - { - Location: "http://192.168.100.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:2", - }, - { - Location: "http://192.168.1.1:2828/control.xml", - Server: "Tailscale-Test/1.0 UPnP/1.1 MiniUPnPd/2.2.1", - USN: "uuid:bee7052b-49e8-3597-b545-55a1e38ac11::urn:schemas-upnp-org:device:InternetGatewayDevice:1", - }, - }, - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - got := processUPnPResponses(slices.Clone(tt.responses)) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("unexpected result:\n got: %+v\nwant: %+v\n", got, tt.want) - } - }) - } -} - -// See: https://github.com/tailscale/corp/issues/23538 -func TestGetUPnPPortMapping_Invalid(t *testing.T) { - for _, responseAddr := range []string{ - "0.0.0.0", - "127.0.0.1", - } { - t.Run(responseAddr, func(t *testing.T) { - igd, err := NewTestIGD(t.Logf, TestIGDOptions{UPnP: true}) - if err != nil { - t.Fatal(err) - } - defer igd.Close() - - // This is a very basic fake UPnP server handler. - handlers := map[string]any{ - "AddPortMapping": testAddPortMappingResponse, - "GetExternalIPAddress": makeGetExternalIPAddressResponse(responseAddr), - "GetStatusInfo": testGetStatusInfoResponse, - "DeletePortMapping": "", // Do nothing for test - } - - igd.SetUPnPHandler(&upnpServer{ - t: t, - Desc: huaweiRootDescXML, - Control: map[string]map[string]any{ - "/ctrlt/WANPPPConnection_1": handlers, - }, - }) - - c := newTestClient(t, igd) - defer c.Close() - c.debug.VerboseLogs = true - - ctx := context.Background() - mustProbeUPnP(t, ctx, c) - - gw, myIP, ok := c.gatewayAndSelfIP() - if !ok { - t.Fatalf("could not get gateway and self IP") - } - - ext, ok := c.getUPnPPortMapping(ctx, gw, netip.AddrPortFrom(myIP, 12345), 0) - if ok { - t.Fatal("did not expect to get UPnP port mapping") - } - if ext.IsValid() { - t.Fatalf("expected no external address; got %v", ext) - } - }) - } -} - -type upnpServer struct { - t *testing.T - Desc string // root device XML - Control map[string]map[string]any // map["/url"]map["UPnPService"]response -} - -func (u *upnpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - u.t.Logf("got UPnP request %s %s", r.Method, r.URL.Path) - if r.URL.Path == "/rootDesc.xml" { - io.WriteString(w, u.Desc) - return - } - if control, ok := u.Control[r.URL.Path]; ok { - u.handleControl(w, r, control) - return - } - - u.t.Logf("ignoring request") - http.NotFound(w, r) -} - -func (u *upnpServer) handleControl(w http.ResponseWriter, r *http.Request, handlers map[string]any) { - body, err := io.ReadAll(r.Body) - if err != nil { - u.t.Errorf("error reading request body: %v", err) - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - // Decode the request type. - var outerRequest struct { - Body struct { - Request struct { - XMLName xml.Name - } `xml:",any"` - Inner string `xml:",innerxml"` - } `xml:"Body"` - } - if err := xml.Unmarshal(body, &outerRequest); err != nil { - u.t.Errorf("bad request: %v", err) - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - requestType := outerRequest.Body.Request.XMLName.Local - upnpRequest := outerRequest.Body.Inner - u.t.Logf("UPnP request: %s", requestType) - - handler, ok := handlers[requestType] - if !ok { - u.t.Errorf("unhandled UPnP request type %q", requestType) - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - switch v := handler.(type) { - case string: - io.WriteString(w, v) - case []byte: - w.Write(v) - - // Function handlers - case func(string) string: - io.WriteString(w, v(upnpRequest)) - case func([]byte) string: - io.WriteString(w, v([]byte(upnpRequest))) - - case func(string) (int, string): - code, body := v(upnpRequest) - w.WriteHeader(code) - io.WriteString(w, body) - case func([]byte) (int, string): - code, body := v([]byte(upnpRequest)) - w.WriteHeader(code) - io.WriteString(w, body) - - default: - u.t.Fatalf("invalid handler type: %T", v) - http.Error(w, "invalid handler type", http.StatusInternalServerError) - return - } -} - -func mustProbeUPnP(tb testing.TB, ctx context.Context, c *Client) ProbeResult { - tb.Helper() - res, err := c.Probe(ctx) - if err != nil { - tb.Fatalf("Probe: %v", err) - } - if !res.UPnP { - tb.Fatalf("didn't detect UPnP") - } - return res -} - -const testRootDesc = ` - - - 1 - 1 - - - urn:schemas-upnp-org:device:InternetGatewayDevice:1 - Tailscale Test Router - Tailscale - https://tailscale.com - Tailscale Test Router - Tailscale Test Router - 2.5.0-RELEASE - https://tailscale.com - 1234 - uuid:1974e83b-6dc7-4635-92b3-6a85a4037294 - - - urn:schemas-upnp-org:device:WANDevice:1 - WANDevice - MiniUPnP - http://miniupnp.free.fr/ - WAN Device - WAN Device - 20990102 - http://miniupnp.free.fr/ - 1234 - uuid:1974e83b-6dc7-4635-92b3-6a85a4037294 - 000000000000 - - - urn:schemas-upnp-org:device:WANConnectionDevice:1 - WANConnectionDevice - MiniUPnP - http://miniupnp.free.fr/ - MiniUPnP daemon - MiniUPnPd - 20210205 - http://miniupnp.free.fr/ - 1234 - uuid:1974e83b-6dc7-4635-92b3-6a85a4037294 - 000000000000 - - - urn:schemas-upnp-org:service:WANIPConnection:1 - urn:upnp-org:serviceId:WANIPConn1 - /WANIPCn.xml - /ctl/IPConn - /evt/IPConn - - - - - - - https://127.0.0.1/ - - -` - -const testAddPortMappingPermanentLease = ` - - - - s:Client - UPnPError - - - 725 - OnlyPermanentLeasesSupported - - - - - -` - -const testAddPortMappingResponse = ` - - - - - -` - -const testGetExternalIPAddressResponse = ` - - - - 123.123.123.123 - - - -` - -const testGetStatusInfoResponse = ` - - - - Connected - ERROR_NONE - 9999 - - - -` - -const testLegacyAddPortMappingResponse = ` - - - - - -` - -const testLegacyGetExternalIPAddressResponse = ` - - - - 123.123.123.123 - - - -` - -const testLegacyGetStatusInfoResponse = ` - - - - Connected - ERROR_NONE - 9999 - - - -` - -func makeGetExternalIPAddressResponse(ip string) string { - return fmt.Sprintf(` - - - - %s - - - -`, ip) -} diff --git a/net/proxymux/mux_test.go b/net/proxymux/mux_test.go deleted file mode 100644 index 29166f9966bbc..0000000000000 --- a/net/proxymux/mux_test.go +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package proxymux - -import ( - "fmt" - "net" - "net/http" - "net/http/httputil" - "net/url" - "testing" - - "tailscale.com/net/socks5" -) - -func TestSplitSOCKSAndHTTP(t *testing.T) { - s := mkWorld(t) - defer s.Close() - - s.checkURL(s.httpClient, false) - s.checkURL(s.socksClient, false) -} - -func TestSplitSOCKSAndHTTPCloseSocks(t *testing.T) { - s := mkWorld(t) - defer s.Close() - - s.socksListener.Close() - s.checkURL(s.httpClient, false) - s.checkURL(s.socksClient, true) -} - -func TestSplitSOCKSAndHTTPCloseHTTP(t *testing.T) { - s := mkWorld(t) - defer s.Close() - - s.httpListener.Close() - s.checkURL(s.httpClient, true) - s.checkURL(s.socksClient, false) -} - -func TestSplitSOCKSAndHTTPCloseBoth(t *testing.T) { - s := mkWorld(t) - defer s.Close() - - s.httpListener.Close() - s.socksListener.Close() - s.checkURL(s.httpClient, true) - s.checkURL(s.socksClient, true) -} - -type world struct { - t *testing.T - - // targetListener/target is the HTTP server the client wants to - // reach. It unconditionally responds with HTTP 418 "I'm a - // teapot". - targetListener net.Listener - target http.Server - targetURL string - - // httpListener/httpProxy is an HTTP proxy that can proxy to - // target. - httpListener net.Listener - httpProxy http.Server - - // socksListener/socksProxy is a SOCKS5 proxy that can dial - // targetListener. - socksListener net.Listener - socksProxy *socks5.Server - - // jointListener is the mux that serves both HTTP and SOCKS5 - // proxying. - jointListener net.Listener - - // httpClient and socksClient are HTTP clients configured to proxy - // through httpProxy and socksProxy respectively. - httpClient *http.Client - socksClient *http.Client -} - -func (s *world) checkURL(c *http.Client, wantErr bool) { - s.t.Helper() - resp, err := c.Get(s.targetURL) - if wantErr { - if err == nil { - s.t.Errorf("HTTP request succeeded unexpectedly: got HTTP code %d, wanted failure", resp.StatusCode) - } - } else if err != nil { - s.t.Errorf("HTTP request failed: %v", err) - } else if c := resp.StatusCode; c != http.StatusTeapot { - s.t.Errorf("unexpected status code: got %d, want %d", c, http.StatusTeapot) - } -} - -func (s *world) Close() { - s.jointListener.Close() - s.socksListener.Close() - s.httpProxy.Close() - s.httpListener.Close() - s.target.Close() - s.targetListener.Close() -} - -func mkWorld(t *testing.T) (ret *world) { - t.Helper() - - ret = &world{ - t: t, - } - var err error - - ret.targetListener, err = net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - ret.target = http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusTeapot) - }), - } - go ret.target.Serve(ret.targetListener) - ret.targetURL = fmt.Sprintf("http://%s/", ret.targetListener.Addr().String()) - - ret.jointListener, err = net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - ret.socksListener, ret.httpListener = SplitSOCKSAndHTTP(ret.jointListener) - - httpProxy := http.Server{ - Handler: httputil.NewSingleHostReverseProxy(&url.URL{ - Scheme: "http", - Host: ret.targetListener.Addr().String(), - Path: "/", - }), - } - go httpProxy.Serve(ret.httpListener) - - ret.socksProxy = &socks5.Server{} - go ret.socksProxy.Serve(ret.socksListener) - - ret.httpClient = &http.Client{ - Transport: &http.Transport{ - Proxy: func(*http.Request) (*url.URL, error) { - return &url.URL{ - Scheme: "http", - Host: ret.jointListener.Addr().String(), - Path: "/", - }, nil - }, - DisableKeepAlives: true, // one connection per request - }, - } - - ret.socksClient = &http.Client{ - Transport: &http.Transport{ - Proxy: func(*http.Request) (*url.URL, error) { - return &url.URL{ - Scheme: "socks5", - Host: ret.jointListener.Addr().String(), - Path: "/", - }, nil - }, - DisableKeepAlives: true, // one connection per request - }, - } - - return ret -} diff --git a/net/routetable/routetable.go b/net/routetable/routetable.go index 2884706f109a1..a8e0f69dac8a8 100644 --- a/net/routetable/routetable.go +++ b/net/routetable/routetable.go @@ -11,7 +11,7 @@ import ( "net/netip" "strconv" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) var ( diff --git a/net/routetable/routetable_bsd.go b/net/routetable/routetable_bsd.go index 1de1a2734ce6c..43fe4b69d82f0 100644 --- a/net/routetable/routetable_bsd.go +++ b/net/routetable/routetable_bsd.go @@ -15,10 +15,10 @@ import ( "strings" "syscall" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/net/route" "golang.org/x/sys/unix" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" ) type RouteEntryBSD struct { diff --git a/net/routetable/routetable_bsd_test.go b/net/routetable/routetable_bsd_test.go deleted file mode 100644 index 29493d59bdc36..0000000000000 --- a/net/routetable/routetable_bsd_test.go +++ /dev/null @@ -1,433 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build darwin || freebsd - -package routetable - -import ( - "fmt" - "net" - "net/netip" - "reflect" - "runtime" - "testing" - - "golang.org/x/net/route" - "golang.org/x/sys/unix" - "tailscale.com/net/netmon" -) - -func TestRouteEntryFromMsg(t *testing.T) { - ifs := map[int]netmon.Interface{ - 1: { - Interface: &net.Interface{ - Name: "iface0", - }, - }, - 2: { - Interface: &net.Interface{ - Name: "tailscale0", - }, - }, - } - - ip4 := func(s string) *route.Inet4Addr { - ip := netip.MustParseAddr(s) - return &route.Inet4Addr{IP: ip.As4()} - } - ip6 := func(s string) *route.Inet6Addr { - ip := netip.MustParseAddr(s) - return &route.Inet6Addr{IP: ip.As16()} - } - ip6zone := func(s string, idx int) *route.Inet6Addr { - ip := netip.MustParseAddr(s) - return &route.Inet6Addr{IP: ip.As16(), ZoneID: idx} - } - link := func(idx int, addr string) *route.LinkAddr { - if _, found := ifs[idx]; !found { - panic("index not found") - } - - ret := &route.LinkAddr{ - Index: idx, - } - if addr != "" { - ret.Addr = make([]byte, 6) - fmt.Sscanf(addr, "%02x:%02x:%02x:%02x:%02x:%02x", - &ret.Addr[0], - &ret.Addr[1], - &ret.Addr[2], - &ret.Addr[3], - &ret.Addr[4], - &ret.Addr[5], - ) - } - return ret - } - - type testCase struct { - name string - msg *route.RouteMessage - want RouteEntry - fail bool - } - - testCases := []testCase{ - { - name: "BasicIPv4", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip4("1.2.3.4"), // dst - ip4("1.2.3.1"), // gateway - ip4("255.255.255.0"), // netmask - }, - }, - want: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/24")}, - Gateway: netip.MustParseAddr("1.2.3.1"), - Sys: RouteEntryBSD{}, - }, - }, - { - name: "BasicIPv6", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip6("fd7a:115c:a1e0::"), // dst - ip6("1234::"), // gateway - ip6("ffff:ffff:ffff::"), // netmask - }, - }, - want: RouteEntry{ - Family: 6, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("fd7a:115c:a1e0::/48")}, - Gateway: netip.MustParseAddr("1234::"), - Sys: RouteEntryBSD{}, - }, - }, - { - name: "IPv6WithZone", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip6zone("fe80::", 2), // dst - ip6("1234::"), // gateway - ip6("ffff:ffff:ffff:ffff::"), // netmask - }, - }, - want: RouteEntry{ - Family: 6, - Type: RouteTypeUnicast, // TODO - Dst: RouteDestination{Prefix: netip.MustParsePrefix("fe80::/64"), Zone: "tailscale0"}, - Gateway: netip.MustParseAddr("1234::"), - Sys: RouteEntryBSD{}, - }, - }, - { - name: "IPv6WithUnknownZone", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip6zone("fe80::", 4), // dst - ip6("1234::"), // gateway - ip6("ffff:ffff:ffff:ffff::"), // netmask - }, - }, - want: RouteEntry{ - Family: 6, - Type: RouteTypeUnicast, // TODO - Dst: RouteDestination{Prefix: netip.MustParsePrefix("fe80::/64"), Zone: "4"}, - Gateway: netip.MustParseAddr("1234::"), - Sys: RouteEntryBSD{}, - }, - }, - { - name: "DefaultIPv4", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip4("0.0.0.0"), // dst - ip4("1.2.3.4"), // gateway - ip4("0.0.0.0"), // netmask - }, - }, - want: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: defaultRouteIPv4, - Gateway: netip.MustParseAddr("1.2.3.4"), - Sys: RouteEntryBSD{}, - }, - }, - { - name: "DefaultIPv6", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip6("0::"), // dst - ip6("1234::"), // gateway - ip6("0::"), // netmask - }, - }, - want: RouteEntry{ - Family: 6, - Type: RouteTypeUnicast, - Dst: defaultRouteIPv6, - Gateway: netip.MustParseAddr("1234::"), - Sys: RouteEntryBSD{}, - }, - }, - { - name: "ShortAddrs", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip4("1.2.3.4"), // dst - }, - }, - want: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/32")}, - Sys: RouteEntryBSD{}, - }, - }, - { - name: "TailscaleIPv4", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip4("100.64.0.0"), // dst - link(2, ""), - ip4("255.192.0.0"), // netmask - }, - }, - want: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("100.64.0.0/10")}, - Sys: RouteEntryBSD{ - GatewayInterface: "tailscale0", - GatewayIdx: 2, - }, - }, - }, - { - name: "Flags", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip4("1.2.3.4"), // dst - ip4("1.2.3.1"), // gateway - ip4("255.255.255.0"), // netmask - }, - Flags: unix.RTF_STATIC | unix.RTF_GATEWAY | unix.RTF_UP, - }, - want: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/24")}, - Gateway: netip.MustParseAddr("1.2.3.1"), - Sys: RouteEntryBSD{ - Flags: []string{"gateway", "static", "up"}, - RawFlags: unix.RTF_STATIC | unix.RTF_GATEWAY | unix.RTF_UP, - }, - }, - }, - { - name: "SkipNoAddrs", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{}, - }, - fail: true, - }, - { - name: "SkipBadVersion", - msg: &route.RouteMessage{ - Version: 1, - }, - fail: true, - }, - { - name: "SkipBadType", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType + 1, - }, - fail: true, - }, - { - name: "OutputIface", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Index: 1, - Addrs: []route.Addr{ - ip4("1.2.3.4"), // dst - }, - }, - want: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/32")}, - Interface: "iface0", - Sys: RouteEntryBSD{}, - }, - }, - { - name: "GatewayMAC", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip4("100.64.0.0"), // dst - link(1, "01:02:03:04:05:06"), - ip4("255.192.0.0"), // netmask - }, - }, - want: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("100.64.0.0/10")}, - Sys: RouteEntryBSD{ - GatewayAddr: "01:02:03:04:05:06", - GatewayInterface: "iface0", - GatewayIdx: 1, - }, - }, - }, - } - - if runtime.GOOS == "darwin" { - testCases = append(testCases, - testCase{ - name: "SkipFlags", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Addrs: []route.Addr{ - ip4("1.2.3.4"), // dst - ip4("1.2.3.1"), // gateway - ip4("255.255.255.0"), // netmask - }, - Flags: unix.RTF_UP | skipFlags, - }, - fail: true, - }, - testCase{ - name: "NetmaskAdjust", - msg: &route.RouteMessage{ - Version: 3, - Type: rmExpectedType, - Flags: unix.RTF_MULTICAST, - Addrs: []route.Addr{ - ip6("ff00::"), // dst - ip6("1234::"), // gateway - ip6("ffff:ffff:ff00::"), // netmask - }, - }, - want: RouteEntry{ - Family: 6, - Type: RouteTypeMulticast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("ff00::/8")}, - Gateway: netip.MustParseAddr("1234::"), - Sys: RouteEntryBSD{ - Flags: []string{"multicast"}, - RawFlags: unix.RTF_MULTICAST, - }, - }, - }, - ) - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - re, ok := routeEntryFromMsg(ifs, tc.msg) - if wantOk := !tc.fail; ok != wantOk { - t.Fatalf("ok = %v; want %v", ok, wantOk) - } - - if !reflect.DeepEqual(re, tc.want) { - t.Fatalf("RouteEntry mismatch:\n got: %+v\nwant: %+v", re, tc.want) - } - }) - } -} - -func TestRouteEntryFormatting(t *testing.T) { - testCases := []struct { - re RouteEntry - want string - }{ - { - re: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.0/24")}, - Interface: "en0", - Sys: RouteEntryBSD{ - GatewayInterface: "en0", - Flags: []string{"static", "up"}, - }, - }, - want: `{Family: IPv4, Dst: 1.2.3.0/24, Interface: en0, Sys: {GatewayInterface: en0, Flags: [static up]}}`, - }, - { - re: RouteEntry{ - Family: 6, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("fd7a:115c:a1e0::/24")}, - Interface: "en0", - Sys: RouteEntryBSD{ - GatewayIdx: 3, - Flags: []string{"static", "up"}, - }, - }, - want: `{Family: IPv6, Dst: fd7a:115c:a1e0::/24, Interface: en0, Sys: {GatewayIdx: 3, Flags: [static up]}}`, - }, - } - for _, tc := range testCases { - t.Run("", func(t *testing.T) { - got := fmt.Sprint(tc.re) - if got != tc.want { - t.Fatalf("RouteEntry.String() mismatch\n got: %q\nwant: %q", got, tc.want) - } - }) - } -} - -func TestGetRouteTable(t *testing.T) { - routes, err := Get(1000) - if err != nil { - t.Fatal(err) - } - - // Basic assertion: we have at least one 'default' route - var ( - hasDefault bool - ) - for _, route := range routes { - if route.Dst == defaultRouteIPv4 || route.Dst == defaultRouteIPv6 { - hasDefault = true - } - } - if !hasDefault { - t.Errorf("expected at least one default route; routes=%v", routes) - } -} diff --git a/net/routetable/routetable_linux.go b/net/routetable/routetable_linux.go index 88dc8535a99e4..7926c8a21bea8 100644 --- a/net/routetable/routetable_linux.go +++ b/net/routetable/routetable_linux.go @@ -11,11 +11,11 @@ import ( "net/netip" "strconv" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" "github.com/tailscale/netlink" "golang.org/x/sys/unix" - "tailscale.com/net/netaddr" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" ) // RouteEntryLinux is the structure that makes up the Sys field of the diff --git a/net/routetable/routetable_linux_test.go b/net/routetable/routetable_linux_test.go deleted file mode 100644 index bbf7790e787ca..0000000000000 --- a/net/routetable/routetable_linux_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package routetable - -import ( - "fmt" - "net/netip" - "testing" - - "golang.org/x/sys/unix" -) - -func TestGetRouteTable(t *testing.T) { - routes, err := Get(512000) // arbitrarily large - if err != nil { - t.Fatal(err) - } - - // Basic assertion: we have at least one 'default' route in the main table - var ( - hasDefault bool - ) - for _, route := range routes { - if route.Dst == defaultRouteIPv4 && route.Sys.(RouteEntryLinux).Table == unix.RT_TABLE_MAIN { - hasDefault = true - } - } - if !hasDefault { - t.Errorf("expected at least one default route; routes=%v", routes) - } -} - -func TestRouteEntryFormatting(t *testing.T) { - testCases := []struct { - re RouteEntry - want string - }{ - { - re: RouteEntry{ - Family: 4, - Type: RouteTypeMulticast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("100.64.0.0/10")}, - Gateway: netip.MustParseAddr("1.2.3.1"), - Interface: "tailscale0", - Sys: RouteEntryLinux{ - Type: unix.RTN_UNICAST, - Table: 52, - Proto: unix.RTPROT_STATIC, - Src: netip.MustParseAddr("1.2.3.4"), - Priority: 555, - }, - }, - want: `{Family: IPv4, Type: multicast, Dst: 100.64.0.0/10, Gateway: 1.2.3.1, Interface: tailscale0, Sys: {Type: unicast, Table: 52, Proto: static, Src: 1.2.3.4, Priority: 555}}`, - }, - { - re: RouteEntry{ - Family: 4, - Type: RouteTypeUnicast, - Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.0/24")}, - Gateway: netip.MustParseAddr("1.2.3.1"), - Sys: RouteEntryLinux{ - Type: unix.RTN_UNICAST, - Table: unix.RT_TABLE_MAIN, - Proto: unix.RTPROT_BOOT, - }, - }, - want: `{Family: IPv4, Dst: 1.2.3.0/24, Gateway: 1.2.3.1, Sys: {Type: unicast}}`, - }, - } - for _, tc := range testCases { - t.Run("", func(t *testing.T) { - got := fmt.Sprint(tc.re) - if got != tc.want { - t.Fatalf("RouteEntry.String() = %q; want %q", got, tc.want) - } - }) - } -} diff --git a/net/socks5/socks5.go b/net/socks5/socks5.go index 4a5befa1d2fef..c38a88673a521 100644 --- a/net/socks5/socks5.go +++ b/net/socks5/socks5.go @@ -24,7 +24,7 @@ import ( "strconv" "time" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // Authentication METHODs described in RFC 1928, section 3. diff --git a/net/socks5/socks5_test.go b/net/socks5/socks5_test.go deleted file mode 100644 index bc6fac79fdcf9..0000000000000 --- a/net/socks5/socks5_test.go +++ /dev/null @@ -1,289 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package socks5 - -import ( - "bytes" - "errors" - "fmt" - "io" - "net" - "testing" - - "golang.org/x/net/proxy" -) - -func socks5Server(listener net.Listener) { - var server Server - err := server.Serve(listener) - if err != nil { - panic(err) - } - listener.Close() -} - -func backendServer(listener net.Listener) { - conn, err := listener.Accept() - if err != nil { - panic(err) - } - conn.Write([]byte("Test")) - conn.Close() - listener.Close() -} - -func udpEchoServer(conn net.PacketConn) { - var buf [1024]byte - n, addr, err := conn.ReadFrom(buf[:]) - if err != nil { - panic(err) - } - _, err = conn.WriteTo(buf[:n], addr) - if err != nil { - panic(err) - } - conn.Close() -} - -func TestRead(t *testing.T) { - // backend server which we'll use SOCKS5 to connect to - listener, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatal(err) - } - backendServerPort := listener.Addr().(*net.TCPAddr).Port - go backendServer(listener) - - // SOCKS5 server - socks5, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatal(err) - } - socks5Port := socks5.Addr().(*net.TCPAddr).Port - go socks5Server(socks5) - - addr := fmt.Sprintf("localhost:%d", socks5Port) - socksDialer, err := proxy.SOCKS5("tcp", addr, nil, proxy.Direct) - if err != nil { - t.Fatal(err) - } - - addr = fmt.Sprintf("localhost:%d", backendServerPort) - conn, err := socksDialer.Dial("tcp", addr) - if err != nil { - t.Fatal(err) - } - - buf := make([]byte, 4) - _, err = io.ReadFull(conn, buf) - if err != nil { - t.Fatal(err) - } - if string(buf) != "Test" { - t.Fatalf("got: %q want: Test", buf) - } - - err = conn.Close() - if err != nil { - t.Fatal(err) - } -} - -func TestReadPassword(t *testing.T) { - // backend server which we'll use SOCKS5 to connect to - ln, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatal(err) - } - backendServerPort := ln.Addr().(*net.TCPAddr).Port - go backendServer(ln) - - socks5ln, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - socks5ln.Close() - }) - auth := &proxy.Auth{User: "foo", Password: "bar"} - go func() { - s := Server{Username: auth.User, Password: auth.Password} - err := s.Serve(socks5ln) - if err != nil && !errors.Is(err, net.ErrClosed) { - panic(err) - } - }() - - addr := fmt.Sprintf("localhost:%d", socks5ln.Addr().(*net.TCPAddr).Port) - - if d, err := proxy.SOCKS5("tcp", addr, nil, proxy.Direct); err != nil { - t.Fatal(err) - } else { - if _, err := d.Dial("tcp", addr); err == nil { - t.Fatal("expected no-auth dial error") - } - } - - badPwd := &proxy.Auth{User: "foo", Password: "not right"} - if d, err := proxy.SOCKS5("tcp", addr, badPwd, proxy.Direct); err != nil { - t.Fatal(err) - } else { - if _, err := d.Dial("tcp", addr); err == nil { - t.Fatal("expected bad password dial error") - } - } - - badUsr := &proxy.Auth{User: "not right", Password: "bar"} - if d, err := proxy.SOCKS5("tcp", addr, badUsr, proxy.Direct); err != nil { - t.Fatal(err) - } else { - if _, err := d.Dial("tcp", addr); err == nil { - t.Fatal("expected bad username dial error") - } - } - - socksDialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct) - if err != nil { - t.Fatal(err) - } - - addr = fmt.Sprintf("localhost:%d", backendServerPort) - conn, err := socksDialer.Dial("tcp", addr) - if err != nil { - t.Fatal(err) - } - - buf := make([]byte, 4) - if _, err := io.ReadFull(conn, buf); err != nil { - t.Fatal(err) - } - if string(buf) != "Test" { - t.Fatalf("got: %q want: Test", buf) - } - - if err := conn.Close(); err != nil { - t.Fatal(err) - } -} - -func TestUDP(t *testing.T) { - // backend UDP server which we'll use SOCKS5 to connect to - newUDPEchoServer := func() net.PacketConn { - listener, err := net.ListenPacket("udp", ":0") - if err != nil { - t.Fatal(err) - } - go udpEchoServer(listener) - return listener - } - - const echoServerNumber = 3 - echoServerListener := make([]net.PacketConn, echoServerNumber) - for i := 0; i < echoServerNumber; i++ { - echoServerListener[i] = newUDPEchoServer() - } - defer func() { - for i := 0; i < echoServerNumber; i++ { - _ = echoServerListener[i].Close() - } - }() - - // SOCKS5 server - socks5, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatal(err) - } - socks5Port := socks5.Addr().(*net.TCPAddr).Port - go socks5Server(socks5) - - // make a socks5 udpAssociate conn - newUdpAssociateConn := func() (socks5Conn net.Conn, socks5UDPAddr socksAddr) { - // net/proxy don't support UDP, so we need to manually send the SOCKS5 UDP request - conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", socks5Port)) - if err != nil { - t.Fatal(err) - } - _, err = conn.Write([]byte{socks5Version, 0x01, noAuthRequired}) // client hello with no auth - if err != nil { - t.Fatal(err) - } - buf := make([]byte, 1024) - n, err := conn.Read(buf) // server hello - if err != nil { - t.Fatal(err) - } - if n != 2 || buf[0] != socks5Version || buf[1] != noAuthRequired { - t.Fatalf("got: %q want: 0x05 0x00", buf[:n]) - } - - targetAddr := socksAddr{addrType: ipv4, addr: "0.0.0.0", port: 0} - targetAddrPkt, err := targetAddr.marshal() - if err != nil { - t.Fatal(err) - } - _, err = conn.Write(append([]byte{socks5Version, byte(udpAssociate), 0x00}, targetAddrPkt...)) // client reqeust - if err != nil { - t.Fatal(err) - } - - n, err = conn.Read(buf) // server response - if err != nil { - t.Fatal(err) - } - if n < 3 || !bytes.Equal(buf[:3], []byte{socks5Version, 0x00, 0x00}) { - t.Fatalf("got: %q want: 0x05 0x00 0x00", buf[:n]) - } - udpProxySocksAddr, err := parseSocksAddr(bytes.NewReader(buf[3:n])) - if err != nil { - t.Fatal(err) - } - - return conn, udpProxySocksAddr - } - - conn, udpProxySocksAddr := newUdpAssociateConn() - defer conn.Close() - - sendUDPAndWaitResponse := func(socks5UDPConn net.Conn, addr socksAddr, body []byte) (responseBody []byte) { - udpPayload, err := (&udpRequest{addr: addr}).marshal() - if err != nil { - t.Fatal(err) - } - udpPayload = append(udpPayload, body...) - _, err = socks5UDPConn.Write(udpPayload) - if err != nil { - t.Fatal(err) - } - buf := make([]byte, 1024) - n, err := socks5UDPConn.Read(buf) - if err != nil { - t.Fatal(err) - } - _, responseBody, err = parseUDPRequest(buf[:n]) - if err != nil { - t.Fatal(err) - } - return responseBody - } - - udpProxyAddr, err := net.ResolveUDPAddr("udp", udpProxySocksAddr.hostPort()) - if err != nil { - t.Fatal(err) - } - socks5UDPConn, err := net.DialUDP("udp", nil, udpProxyAddr) - if err != nil { - t.Fatal(err) - } - defer socks5UDPConn.Close() - - for i := 0; i < echoServerNumber; i++ { - port := echoServerListener[i].LocalAddr().(*net.UDPAddr).Port - addr := socksAddr{addrType: ipv4, addr: "127.0.0.1", port: uint16(port)} - requestBody := []byte(fmt.Sprintf("Test %d", i)) - responseBody := sendUDPAndWaitResponse(socks5UDPConn, addr, requestBody) - if !bytes.Equal(requestBody, responseBody) { - t.Fatalf("got: %q want: %q", responseBody, requestBody) - } - } -} diff --git a/net/sockstats/sockstats.go b/net/sockstats/sockstats.go index 715c1ee06e9a9..df75bb74ec410 100644 --- a/net/sockstats/sockstats.go +++ b/net/sockstats/sockstats.go @@ -11,8 +11,8 @@ package sockstats import ( "context" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" ) // SockStats contains statistics for sockets instrumented with the diff --git a/net/sockstats/sockstats_noop.go b/net/sockstats/sockstats_noop.go index 96723111ade7a..ade81cb59c336 100644 --- a/net/sockstats/sockstats_noop.go +++ b/net/sockstats/sockstats_noop.go @@ -8,8 +8,8 @@ package sockstats import ( "context" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" ) const IsAvailable = false diff --git a/net/sockstats/sockstats_tsgo.go b/net/sockstats/sockstats_tsgo.go index fec9ec3b0dad2..373ae384c0c06 100644 --- a/net/sockstats/sockstats_tsgo.go +++ b/net/sockstats/sockstats_tsgo.go @@ -15,10 +15,10 @@ import ( "syscall" "time" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/version" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/version" ) const IsAvailable = true diff --git a/net/sockstats/sockstats_tsgo_test.go b/net/sockstats/sockstats_tsgo_test.go deleted file mode 100644 index c467c8a70ff79..0000000000000 --- a/net/sockstats/sockstats_tsgo_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build tailscale_go && (darwin || ios || android || ts_enable_sockstats) - -package sockstats - -import ( - "testing" - "time" -) - -type testTime struct { - time.Time -} - -func (t *testTime) now() time.Time { - return t.Time -} - -func (t *testTime) Add(d time.Duration) { - t.Time = t.Time.Add(d) -} - -func TestRadioMonitor(t *testing.T) { - tests := []struct { - name string - activity func(*testTime, *radioMonitor) - want int64 - }{ - { - "no activity", - func(_ *testTime, _ *radioMonitor) {}, - 0, - }, - { - "active less than init stall period", - func(tt *testTime, rm *radioMonitor) { - rm.active() - tt.Add(1 * time.Second) - }, - 0, // radio on, but not long enough to report data - }, - { - "active, 10 sec idle", - func(tt *testTime, rm *radioMonitor) { - rm.active() - tt.Add(9 * time.Second) - }, - 50, // radio on 5 seconds of 10 seconds - }, - { - "active, spanning three seconds", - func(tt *testTime, rm *radioMonitor) { - rm.active() - tt.Add(2100 * time.Millisecond) - rm.active() - }, - 100, // radio on for 3 seconds - }, - { - "400 iterations: 2 sec active, 1 min idle", - func(tt *testTime, rm *radioMonitor) { - // 400 iterations to ensure values loop back around rm.usage array - for range 400 { - rm.active() - tt.Add(1 * time.Second) - rm.active() - tt.Add(59 * time.Second) - } - }, - 10, // radio on 6 seconds of every minute - }, - { - "activity at end of time window", - func(tt *testTime, rm *radioMonitor) { - tt.Add(3 * time.Second) - rm.active() - }, - 25, - }, - } - - oldStallPeriod := initStallPeriod - initStallPeriod = 3 - t.Cleanup(func() { initStallPeriod = oldStallPeriod }) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tm := &testTime{time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)} - rm := &radioMonitor{ - startTime: tm.Time.Unix(), - now: tm.now, - } - tt.activity(tm, rm) - got := rm.radioHighPercent() - if got != tt.want { - t.Errorf("got radioOnPercent %d, want %d", got, tt.want) - } - }) - } -} diff --git a/net/speedtest/speedtest_test.go b/net/speedtest/speedtest_test.go deleted file mode 100644 index 55dcbeea1abdf..0000000000000 --- a/net/speedtest/speedtest_test.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package speedtest - -import ( - "net" - "testing" - "time" -) - -func TestDownload(t *testing.T) { - // start a listener and find the port where the server will be listening. - l, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { l.Close() }) - - serverIP := l.Addr().String() - t.Log("server IP found:", serverIP) - - type state struct { - err error - } - displayResult := func(t *testing.T, r Result, start time.Time) { - t.Helper() - t.Logf("{ Megabytes: %.2f, Start: %.1f, End: %.1f, Total: %t }", r.MegaBytes(), r.IntervalStart.Sub(start).Seconds(), r.IntervalEnd.Sub(start).Seconds(), r.Total) - } - stateChan := make(chan state, 1) - - go func() { - err := Serve(l) - stateChan <- state{err: err} - }() - - // ensure that the test returns an appropriate number of Result structs - expectedLen := int(DefaultDuration.Seconds()) + 1 - - t.Run("download test", func(t *testing.T) { - // conduct a download test - results, err := RunClient(Download, DefaultDuration, serverIP) - - if err != nil { - t.Fatal("download test failed:", err) - } - - if len(results) < expectedLen { - t.Fatalf("download results: expected length: %d, actual length: %d", expectedLen, len(results)) - } - - start := results[0].IntervalStart - for _, result := range results { - displayResult(t, result, start) - } - }) - - t.Run("upload test", func(t *testing.T) { - // conduct an upload test - results, err := RunClient(Upload, DefaultDuration, serverIP) - - if err != nil { - t.Fatal("upload test failed:", err) - } - - if len(results) < expectedLen { - t.Fatalf("upload results: expected length: %d, actual length: %d", expectedLen, len(results)) - } - - start := results[0].IntervalStart - for _, result := range results { - displayResult(t, result, start) - } - }) - - // causes the server goroutine to finish - l.Close() - - testState := <-stateChan - if testState.err != nil { - t.Error("server error:", err) - } -} diff --git a/net/stun/stun_test.go b/net/stun/stun_test.go deleted file mode 100644 index 05fc4d2ba727f..0000000000000 --- a/net/stun/stun_test.go +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package stun_test - -import ( - "bytes" - "encoding/hex" - "fmt" - "net/netip" - "testing" - - "tailscale.com/net/stun" - "tailscale.com/util/must" -) - -// TODO(bradfitz): fuzz this. - -func ExampleRequest() { - txID := stun.NewTxID() - req := stun.Request(txID) - fmt.Printf("%x\n", req) -} - -var responseTests = []struct { - name string - data []byte - wantTID []byte - wantAddr netip.Addr - wantPort uint16 -}{ - { - name: "google-1", - data: []byte{ - 0x01, 0x01, 0x00, 0x0c, 0x21, 0x12, 0xa4, 0x42, - 0x23, 0x60, 0xb1, 0x1e, 0x3e, 0xc6, 0x8f, 0xfa, - 0x93, 0xe0, 0x80, 0x07, 0x00, 0x20, 0x00, 0x08, - 0x00, 0x01, 0xc7, 0x86, 0x69, 0x57, 0x85, 0x6f, - }, - wantTID: []byte{ - 0x23, 0x60, 0xb1, 0x1e, 0x3e, 0xc6, 0x8f, 0xfa, - 0x93, 0xe0, 0x80, 0x07, - }, - wantAddr: netip.AddrFrom4([4]byte{72, 69, 33, 45}), - wantPort: 59028, - }, - { - name: "google-2", - data: []byte{ - 0x01, 0x01, 0x00, 0x0c, 0x21, 0x12, 0xa4, 0x42, - 0xf9, 0xf1, 0x21, 0xcb, 0xde, 0x7d, 0x7c, 0x75, - 0x92, 0x3c, 0xe2, 0x71, 0x00, 0x20, 0x00, 0x08, - 0x00, 0x01, 0xc7, 0x87, 0x69, 0x57, 0x85, 0x6f, - }, - wantTID: []byte{ - 0xf9, 0xf1, 0x21, 0xcb, 0xde, 0x7d, 0x7c, 0x75, - 0x92, 0x3c, 0xe2, 0x71, - }, - wantAddr: netip.AddrFrom4([4]byte{72, 69, 33, 45}), - wantPort: 59029, - }, - { - name: "stun.sipgate.net:10000", - data: []byte{ - 0x01, 0x01, 0x00, 0x44, 0x21, 0x12, 0xa4, 0x42, - 0x48, 0x2e, 0xb6, 0x47, 0x15, 0xe8, 0xb2, 0x8e, - 0xae, 0xad, 0x64, 0x44, 0x00, 0x01, 0x00, 0x08, - 0x00, 0x01, 0xe4, 0xab, 0x48, 0x45, 0x21, 0x2d, - 0x00, 0x04, 0x00, 0x08, 0x00, 0x01, 0x27, 0x10, - 0xd9, 0x0a, 0x44, 0x98, 0x00, 0x05, 0x00, 0x08, - 0x00, 0x01, 0x27, 0x11, 0xd9, 0x74, 0x7a, 0x8a, - 0x80, 0x20, 0x00, 0x08, 0x00, 0x01, 0xc5, 0xb9, - 0x69, 0x57, 0x85, 0x6f, 0x80, 0x22, 0x00, 0x10, - 0x56, 0x6f, 0x76, 0x69, 0x64, 0x61, 0x2e, 0x6f, - 0x72, 0x67, 0x20, 0x30, 0x2e, 0x39, 0x36, 0x00, - }, - wantTID: []byte{ - 0x48, 0x2e, 0xb6, 0x47, 0x15, 0xe8, 0xb2, 0x8e, - 0xae, 0xad, 0x64, 0x44, - }, - wantAddr: netip.AddrFrom4([4]byte{72, 69, 33, 45}), - wantPort: 58539, - }, - { - name: "stun.powervoip.com:3478", - data: []byte{ - 0x01, 0x01, 0x00, 0x24, 0x21, 0x12, 0xa4, 0x42, - 0x7e, 0x57, 0x96, 0x68, 0x29, 0xf4, 0x44, 0x60, - 0x9d, 0x1d, 0xea, 0xa6, 0x00, 0x01, 0x00, 0x08, - 0x00, 0x01, 0xe9, 0xd3, 0x48, 0x45, 0x21, 0x2d, - 0x00, 0x04, 0x00, 0x08, 0x00, 0x01, 0x0d, 0x96, - 0x4d, 0x48, 0xa9, 0xd4, 0x00, 0x05, 0x00, 0x08, - 0x00, 0x01, 0x0d, 0x97, 0x4d, 0x48, 0xa9, 0xd5, - }, - wantTID: []byte{ - 0x7e, 0x57, 0x96, 0x68, 0x29, 0xf4, 0x44, 0x60, - 0x9d, 0x1d, 0xea, 0xa6, - }, - wantAddr: netip.AddrFrom4([4]byte{72, 69, 33, 45}), - wantPort: 59859, - }, - { - name: "in-process pion server", - data: []byte{ - 0x01, 0x01, 0x00, 0x24, 0x21, 0x12, 0xa4, 0x42, - 0xeb, 0xc2, 0xd3, 0x6e, 0xf4, 0x71, 0x21, 0x7c, - 0x4f, 0x3e, 0x30, 0x8e, 0x80, 0x22, 0x00, 0x0a, - 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x65, 0x72, 0x00, 0x00, 0x00, 0x20, 0x00, 0x08, - 0x00, 0x01, 0xce, 0x66, 0x5e, 0x12, 0xa4, 0x43, - 0x80, 0x28, 0x00, 0x04, 0xb6, 0x99, 0xbb, 0x02, - 0x01, 0x01, 0x00, 0x24, 0x21, 0x12, 0xa4, 0x42, - }, - wantTID: []byte{ - 0xeb, 0xc2, 0xd3, 0x6e, 0xf4, 0x71, 0x21, 0x7c, - 0x4f, 0x3e, 0x30, 0x8e, - }, - wantAddr: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - wantPort: 61300, - }, - { - name: "stuntman-server ipv6", - data: []byte{ - 0x01, 0x01, 0x00, 0x48, 0x21, 0x12, 0xa4, 0x42, - 0x06, 0xf5, 0x66, 0x85, 0xd2, 0x8a, 0xf3, 0xe6, - 0x9c, 0xe3, 0x41, 0xe2, 0x00, 0x01, 0x00, 0x14, - 0x00, 0x02, 0x90, 0xce, 0x26, 0x02, 0x00, 0xd1, - 0xb4, 0xcf, 0xc1, 0x00, 0x38, 0xb2, 0x31, 0xff, - 0xfe, 0xef, 0x96, 0xf6, 0x80, 0x2b, 0x00, 0x14, - 0x00, 0x02, 0x0d, 0x96, 0x26, 0x04, 0xa8, 0x80, - 0x00, 0x02, 0x00, 0xd1, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xc5, 0x70, 0x01, 0x00, 0x20, 0x00, 0x14, - 0x00, 0x02, 0xb1, 0xdc, 0x07, 0x10, 0xa4, 0x93, - 0xb2, 0x3a, 0xa7, 0x85, 0xea, 0x38, 0xc2, 0x19, - 0x62, 0x0c, 0xd7, 0x14, - }, - wantTID: []byte{ - 6, 245, 102, 133, 210, 138, 243, 230, 156, 227, - 65, 226, - }, - wantAddr: netip.MustParseAddr("2602:d1:b4cf:c100:38b2:31ff:feef:96f6"), - wantPort: 37070, - }, - - // Testing STUN attribute padding rules using STUN software attribute - // with values of 1 & 3 length respectively before the XorMappedAddress attribute - { - name: "software-a", - data: []byte{ - 0x01, 0x01, 0x00, 0x14, 0x21, 0x12, 0xa4, 0x42, - 0xeb, 0xc2, 0xd3, 0x6e, 0xf4, 0x71, 0x21, 0x7c, - 0x4f, 0x3e, 0x30, 0x8e, 0x80, 0x22, 0x00, 0x01, - 0x61, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x08, - 0x00, 0x01, 0xce, 0x66, 0x5e, 0x12, 0xa4, 0x43, - }, - wantTID: []byte{ - 0xeb, 0xc2, 0xd3, 0x6e, 0xf4, 0x71, 0x21, 0x7c, - 0x4f, 0x3e, 0x30, 0x8e, - }, - wantAddr: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - wantPort: 61300, - }, - { - name: "software-abc", - data: []byte{ - 0x01, 0x01, 0x00, 0x14, 0x21, 0x12, 0xa4, 0x42, - 0xeb, 0xc2, 0xd3, 0x6e, 0xf4, 0x71, 0x21, 0x7c, - 0x4f, 0x3e, 0x30, 0x8e, 0x80, 0x22, 0x00, 0x03, - 0x61, 0x62, 0x63, 0x00, 0x00, 0x20, 0x00, 0x08, - 0x00, 0x01, 0xce, 0x66, 0x5e, 0x12, 0xa4, 0x43, - }, - wantTID: []byte{ - 0xeb, 0xc2, 0xd3, 0x6e, 0xf4, 0x71, 0x21, 0x7c, - 0x4f, 0x3e, 0x30, 0x8e, - }, - wantAddr: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - wantPort: 61300, - }, - { - name: "no-4in6", - data: must.Get(hex.DecodeString("010100182112a4424fd5d202dcb37d31fc773306002000140002cd3d2112a4424fd5d202dcb382ce2dc3fcc7")), - wantTID: []byte{79, 213, 210, 2, 220, 179, 125, 49, 252, 119, 51, 6}, - wantAddr: netip.AddrFrom4([4]byte{209, 180, 207, 193}), - wantPort: 60463, - }, -} - -func TestParseResponse(t *testing.T) { - subtest := func(t *testing.T, i int) { - test := responseTests[i] - tID, addrPort, err := stun.ParseResponse(test.data) - if err != nil { - t.Fatal(err) - } - - if !bytes.Equal(tID[:], test.wantTID) { - t.Errorf("tid=%v, want %v", tID[:], test.wantTID) - } - if addrPort.Addr().Compare(test.wantAddr) != 0 { - t.Errorf("addr=%v, want %v", addrPort.Addr(), test.wantAddr) - } - if addrPort.Port() != test.wantPort { - t.Errorf("port=%d, want %d", addrPort.Port(), test.wantPort) - } - } - for i, test := range responseTests { - t.Run(test.name, func(t *testing.T) { - subtest(t, i) - }) - } -} - -func TestIs(t *testing.T) { - const magicCookie = "\x21\x12\xa4\x42" - tests := []struct { - in string - want bool - }{ - {"", false}, - {"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", false}, - {"\x00\x00\x00\x00" + magicCookie + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", false}, - {"\x00\x00\x00\x00" + magicCookie + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", true}, - {"\x00\x00\x00\x00" + magicCookie + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00foo", true}, - // high bits set: - {"\xf0\x00\x00\x00" + magicCookie + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", false}, - {"\x40\x00\x00\x00" + magicCookie + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", false}, - // first byte non-zero, but not high bits: - {"\x20\x00\x00\x00" + magicCookie + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", true}, - } - for i, tt := range tests { - pkt := []byte(tt.in) - got := stun.Is(pkt) - if got != tt.want { - t.Errorf("%d. In(%q (%v)) = %v; want %v", i, pkt, pkt, got, tt.want) - } - } -} - -func TestParseBindingRequest(t *testing.T) { - tx := stun.NewTxID() - req := stun.Request(tx) - gotTx, err := stun.ParseBindingRequest(req) - if err != nil { - t.Fatal(err) - } - if gotTx != tx { - t.Errorf("original txID %q != got txID %q", tx, gotTx) - } -} - -func TestResponse(t *testing.T) { - txN := func(n int) (x stun.TxID) { - for i := range x { - x[i] = byte(n) - } - return - } - tests := []struct { - tx stun.TxID - addr netip.Addr - port uint16 - }{ - {tx: txN(1), addr: netip.MustParseAddr("1.2.3.4"), port: 254}, - {tx: txN(2), addr: netip.MustParseAddr("1.2.3.4"), port: 257}, - {tx: txN(3), addr: netip.MustParseAddr("1::4"), port: 254}, - {tx: txN(4), addr: netip.MustParseAddr("1::4"), port: 257}, - } - for _, tt := range tests { - res := stun.Response(tt.tx, netip.AddrPortFrom(tt.addr, tt.port)) - tx2, addr2, err := stun.ParseResponse(res) - if err != nil { - t.Errorf("TX %x: error: %v", tt.tx, err) - continue - } - if tt.tx != tx2 { - t.Errorf("TX %x: got TxID = %v", tt.tx, tx2) - } - if tt.addr.Compare(addr2.Addr()) != 0 { - t.Errorf("TX %x: addr = %v; want %v", tt.tx, addr2.Addr(), tt.addr) - } - if tt.port != addr2.Port() { - t.Errorf("TX %x: port = %v; want %v", tt.tx, addr2.Port(), tt.port) - } - } -} - -func TestAttrOrderForXdpDERP(t *testing.T) { - // package derp/xdp assumes attribute order. This test ensures we don't - // drift and break that assumption. - txID := stun.NewTxID() - req := stun.Request(txID) - if len(req) < 20+12 { - t.Fatal("too short") - } - if !bytes.Equal(req[20:22], []byte{0x80, 0x22}) { - t.Fatal("the first attr is not of type software") - } - if !bytes.Equal(req[24:32], []byte("tailnode")) { - t.Fatal("unexpected software attr value") - } -} diff --git a/net/stun/stuntest/stuntest.go b/net/stun/stuntest/stuntest.go index 09684160055fb..839a683e0efd4 100644 --- a/net/stun/stuntest/stuntest.go +++ b/net/stun/stuntest/stuntest.go @@ -14,10 +14,10 @@ import ( "sync" "testing" - "tailscale.com/net/netaddr" - "tailscale.com/net/stun" - "tailscale.com/tailcfg" - "tailscale.com/types/nettype" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/nettype" ) type stunStats struct { diff --git a/net/stunserver/stunserver.go b/net/stunserver/stunserver.go index b45bb633129fe..3655fd8df88eb 100644 --- a/net/stunserver/stunserver.go +++ b/net/stunserver/stunserver.go @@ -15,8 +15,8 @@ import ( "net/netip" "time" - "tailscale.com/metrics" - "tailscale.com/net/stun" + "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/net/stun" ) var ( diff --git a/net/stunserver/stunserver_test.go b/net/stunserver/stunserver_test.go deleted file mode 100644 index 24a7bb570b6bd..0000000000000 --- a/net/stunserver/stunserver_test.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package stunserver - -import ( - "context" - "net" - "sync" - "testing" - "time" - - "tailscale.com/net/stun" - "tailscale.com/util/must" -) - -func TestSTUNServer(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s := New(ctx) - must.Do(s.Listen("localhost:0")) - var w sync.WaitGroup - w.Add(1) - var serveErr error - go func() { - defer w.Done() - serveErr = s.Serve() - }() - - c := must.Get(net.DialUDP("udp", nil, s.LocalAddr().(*net.UDPAddr))) - defer c.Close() - c.SetDeadline(time.Now().Add(5 * time.Second)) - txid := stun.NewTxID() - _, err := c.Write(stun.Request(txid)) - if err != nil { - t.Fatalf("failed to write STUN request: %v", err) - } - var buf [64 << 10]byte - n, err := c.Read(buf[:]) - if err != nil { - t.Fatalf("failed to read STUN response: %v", err) - } - if !stun.Is(buf[:n]) { - t.Fatalf("response is not STUN") - } - tid, _, err := stun.ParseResponse(buf[:n]) - if err != nil { - t.Fatalf("failed to parse STUN response: %v", err) - } - if tid != txid { - t.Fatalf("STUN response has wrong transaction ID; got %d, want %d", tid, txid) - } - - cancel() - w.Wait() - if serveErr != nil { - t.Fatalf("failed to listen and serve: %v", serveErr) - } -} - -func BenchmarkServerSTUN(b *testing.B) { - b.ReportAllocs() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - s := New(ctx) - s.Listen("localhost:0") - go s.Serve() - addr := s.LocalAddr().(*net.UDPAddr) - - var resBuf [1500]byte - cc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) - if err != nil { - b.Fatal(err) - } - - tx := stun.NewTxID() - req := stun.Request(tx) - for range b.N { - if _, err := cc.WriteToUDP(req, addr); err != nil { - b.Fatal(err) - } - _, _, err := cc.ReadFromUDP(resBuf[:]) - if err != nil { - b.Fatal(err) - } - } -} diff --git a/net/tcpinfo/tcpinfo_test.go b/net/tcpinfo/tcpinfo_test.go deleted file mode 100644 index bb3d224ec1beb..0000000000000 --- a/net/tcpinfo/tcpinfo_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tcpinfo - -import ( - "bytes" - "io" - "net" - "runtime" - "testing" -) - -func TestRTT(t *testing.T) { - switch runtime.GOOS { - case "linux", "darwin": - default: - t.Skipf("not currently supported on %s", runtime.GOOS) - } - - ln, err := net.Listen("tcp4", "localhost:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - - go func() { - for { - c, err := ln.Accept() - if err != nil { - return - } - t.Cleanup(func() { c.Close() }) - - // Copy from the client to nowhere - go io.Copy(io.Discard, c) - } - }() - - conn, err := net.Dial("tcp4", ln.Addr().String()) - if err != nil { - t.Fatal(err) - } - - // Write a bunch of data to the conn to force TCP session establishment - // and a few packets. - junkData := bytes.Repeat([]byte("hello world\n"), 1024*1024) - for i := range 10 { - if _, err := conn.Write(junkData); err != nil { - t.Fatalf("error writing junk data [%d]: %v", i, err) - } - } - - // Get the RTT now - rtt, err := RTT(conn) - if err != nil { - t.Fatalf("error getting RTT: %v", err) - } - if rtt == 0 { - t.Errorf("expected RTT > 0") - } - - t.Logf("TCP rtt: %v", rtt) -} diff --git a/net/tlsdial/blockblame/blockblame_test.go b/net/tlsdial/blockblame/blockblame_test.go deleted file mode 100644 index 6d3592c60a3de..0000000000000 --- a/net/tlsdial/blockblame/blockblame_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package blockblame - -import ( - "crypto/x509" - "encoding/pem" - "testing" -) - -const controlplaneDotTailscaleDotComPEM = ` ------BEGIN CERTIFICATE----- -MIIDkzCCAxqgAwIBAgISA2GOahsftpp59yuHClbDuoduMAoGCCqGSM49BAMDMDIx -CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF -NjAeFw0yNDEwMTIxNjE2NDVaFw0yNTAxMTAxNjE2NDRaMCUxIzAhBgNVBAMTGmNv -bnRyb2xwbGFuZS50YWlsc2NhbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD -QgAExfraDUc1t185zuGtZlnPDtEJJSDBqvHN4vQcXSzSTPSAdDYHcA8fL5woU2Kg -jK/2C0wm/rYy2Rre/ulhkS4wB6OCAhswggIXMA4GA1UdDwEB/wQEAwIHgDAdBgNV -HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4E -FgQUpArnpDj8Yh6NTgMOZjDPx0TuLmcwHwYDVR0jBBgwFoAUkydGmAOpUWiOmNbE -QkjbI79YlNIwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vZTYu -by5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNi5pLmxlbmNyLm9yZy8w -JQYDVR0RBB4wHIIaY29udHJvbHBsYW5lLnRhaWxzY2FsZS5jb20wEwYDVR0gBAww -CjAIBgZngQwBAgEwggEDBgorBgEEAdZ5AgQCBIH0BIHxAO8AdgDgkrP8DB3I52g2 -H95huZZNClJ4GYpy1nLEsE2lbW9UBAAAAZKBujCyAAAEAwBHMEUCIQDHMgUaL4H9 -ZJa090ZOpBeEVu3+t+EF4HlHI1NqAai6uQIgeY/lLfjAXfcVgxBHHR4zjd0SzhaP -TREHXzwxzN/8blkAdQDPEVbu1S58r/OHW9lpLpvpGnFnSrAX7KwB0lt3zsw7CAAA -AZKBujh8AAAEAwBGMEQCICQwhMk45t9aiFjfwOC/y6+hDbszqSCpIv63kFElweUy -AiAqTdkqmbqUVpnav5JdWkNERVAIlY4jqrThLsCLZYbNszAKBggqhkjOPQQDAwNn -ADBkAjALyfgAt1XQp1uSfxy4GapR5OsmjEMBRVq6IgsPBlCRBfmf0Q3/a6mF0pjb -Sj4oa+cCMEhZk4DmBTIdZY9zjuh8s7bXNfKxUQS0pEhALtXqyFr+D5dF7JcQo9+s -Z98JY7/PCA== ------END CERTIFICATE-----` - -func TestVerifyCertificateOurControlPlane(t *testing.T) { - p, _ := pem.Decode([]byte(controlplaneDotTailscaleDotComPEM)) - if p == nil { - t.Fatalf("failed to extract certificate bytes for controlplane.tailscale.com") - return - } - cert, err := x509.ParseCertificate(p.Bytes) - if err != nil { - t.Fatalf("failed to parse certificate: %v", err) - return - } - m, found := VerifyCertificate(cert) - if found { - t.Fatalf("expected to not get a result for the controlplane.tailscale.com certificate") - } - if m != nil { - t.Fatalf("expected nil manufacturer for controlplane.tailscale.com certificate") - } -} diff --git a/net/tlsdial/deps_test.go b/net/tlsdial/deps_test.go deleted file mode 100644 index 7a93899c2f126..0000000000000 --- a/net/tlsdial/deps_test.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build for_go_mod_tidy_only - -package tlsdial - -import _ "filippo.io/mkcert" diff --git a/net/tlsdial/tlsdial.go b/net/tlsdial/tlsdial.go index 7e847a8b6a656..bfc1283c5a69d 100644 --- a/net/tlsdial/tlsdial.go +++ b/net/tlsdial/tlsdial.go @@ -24,10 +24,10 @@ import ( "sync/atomic" "time" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/net/tlsdial/blockblame" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/net/tlsdial/blockblame" ) var counterFallbackOK int32 // atomic diff --git a/net/tlsdial/tlsdial_test.go b/net/tlsdial/tlsdial_test.go deleted file mode 100644 index 26814ebbd8dc0..0000000000000 --- a/net/tlsdial/tlsdial_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tlsdial - -import ( - "crypto/x509" - "io" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "reflect" - "runtime" - "sync/atomic" - "testing" - - "tailscale.com/health" -) - -func resetOnce() { - rv := reflect.ValueOf(&bakedInRootsOnce).Elem() - rv.Set(reflect.Zero(rv.Type())) -} - -func TestBakedInRoots(t *testing.T) { - resetOnce() - p := bakedInRoots() - got := p.Subjects() - if len(got) != 1 { - t.Errorf("subjects = %v; want 1", len(got)) - } -} - -func TestFallbackRootWorks(t *testing.T) { - defer resetOnce() - - const debug = false - if runtime.GOOS != "linux" { - t.Skip("test assumes Linux") - } - d := t.TempDir() - crtFile := filepath.Join(d, "tlsdial.test.crt") - keyFile := filepath.Join(d, "tlsdial.test.key") - caFile := filepath.Join(d, "rootCA.pem") - cmd := exec.Command("go", - "run", "filippo.io/mkcert", - "--cert-file="+crtFile, - "--key-file="+keyFile, - "tlsdial.test") - cmd.Env = append(os.Environ(), "CAROOT="+d) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("mkcert: %v, %s", err, out) - } - if debug { - t.Logf("Ran: %s", out) - dents, err := os.ReadDir(d) - if err != nil { - t.Fatal(err) - } - for _, de := range dents { - t.Logf(" - %v", de) - } - } - - caPEM, err := os.ReadFile(caFile) - if err != nil { - t.Fatal(err) - } - resetOnce() - bakedInRootsOnce.Do(func() { - p := x509.NewCertPool() - if !p.AppendCertsFromPEM(caPEM) { - t.Fatal("failed to add") - } - bakedInRootsOnce.p = p - }) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - if debug { - t.Logf("listener running at %v", ln.Addr()) - } - done := make(chan struct{}) - defer close(done) - - errc := make(chan error, 1) - go func() { - err := http.ServeTLS(ln, http.HandlerFunc(sayHi), crtFile, keyFile) - select { - case <-done: - return - default: - t.Logf("ServeTLS: %v", err) - errc <- err - } - }() - - tr := &http.Transport{ - Dial: func(network, addr string) (net.Conn, error) { - return net.Dial("tcp", ln.Addr().String()) - }, - DisableKeepAlives: true, // for test cleanup ease - } - ht := new(health.Tracker) - tr.TLSClientConfig = Config("tlsdial.test", ht, tr.TLSClientConfig) - c := &http.Client{Transport: tr} - - ctr0 := atomic.LoadInt32(&counterFallbackOK) - - res, err := c.Get("https://tlsdial.test/") - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - if res.StatusCode != 200 { - t.Fatal(res.Status) - } - - ctrDelta := atomic.LoadInt32(&counterFallbackOK) - ctr0 - if ctrDelta != 1 { - t.Errorf("fallback root success count = %d; want 1", ctrDelta) - } -} - -func sayHi(w http.ResponseWriter, r *http.Request) { - io.WriteString(w, "hi") -} diff --git a/net/tsaddr/tsaddr.go b/net/tsaddr/tsaddr.go index 06e6a26ddb721..af8d6f6317099 100644 --- a/net/tsaddr/tsaddr.go +++ b/net/tsaddr/tsaddr.go @@ -11,9 +11,9 @@ import ( "slices" "sync" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/types/views" "go4.org/netipx" - "tailscale.com/net/netaddr" - "tailscale.com/types/views" ) // ChromeOSVMRange returns the subset of the CGNAT IPv4 range used by diff --git a/net/tsaddr/tsaddr_test.go b/net/tsaddr/tsaddr_test.go deleted file mode 100644 index 9ac1ce3036299..0000000000000 --- a/net/tsaddr/tsaddr_test.go +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsaddr - -import ( - "net/netip" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/net/netaddr" - "tailscale.com/types/views" -) - -func TestInCrostiniRange(t *testing.T) { - tests := []struct { - ip netip.Addr - want bool - }{ - {netaddr.IPv4(192, 168, 0, 1), false}, - {netaddr.IPv4(100, 101, 102, 103), false}, - {netaddr.IPv4(100, 115, 92, 0), true}, - {netaddr.IPv4(100, 115, 92, 5), true}, - {netaddr.IPv4(100, 115, 92, 255), true}, - {netaddr.IPv4(100, 115, 93, 40), true}, - {netaddr.IPv4(100, 115, 94, 1), false}, - } - - for _, test := range tests { - if got := ChromeOSVMRange().Contains(test.ip); got != test.want { - t.Errorf("inCrostiniRange(%q) = %v, want %v", test.ip, got, test.want) - } - } -} - -func TestTailscaleServiceIP(t *testing.T) { - got := TailscaleServiceIP().String() - want := "100.100.100.100" - if got != want { - t.Errorf("got %q; want %q", got, want) - } - if TailscaleServiceIPString != want { - t.Error("TailscaleServiceIPString is not consistent") - } -} - -func TestTailscaleServiceIPv6(t *testing.T) { - got := TailscaleServiceIPv6().String() - want := "fd7a:115c:a1e0::53" - if got != want { - t.Errorf("got %q; want %q", got, want) - } - if TailscaleServiceIPv6String != want { - t.Error("TailscaleServiceIPv6String is not consistent") - } -} - -func TestChromeOSVMRange(t *testing.T) { - if got, want := ChromeOSVMRange().String(), "100.115.92.0/23"; got != want { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestCGNATRange(t *testing.T) { - if got, want := CGNATRange().String(), "100.64.0.0/10"; got != want { - t.Errorf("got %q; want %q", got, want) - } -} - -var sinkIP netip.Addr - -func BenchmarkTailscaleServiceAddr(b *testing.B) { - b.ReportAllocs() - for range b.N { - sinkIP = TailscaleServiceIP() - } -} - -func TestUnmapVia(t *testing.T) { - tests := []struct { - ip string - want string - }{ - {"1.2.3.4", "1.2.3.4"}, // unchanged v4 - {"fd7a:115c:a1e0:b1a::bb:10.2.1.3", "10.2.1.3"}, - {"fd7a:115c:a1e0:b1b::bb:10.2.1.4", "fd7a:115c:a1e0:b1b:0:bb:a02:104"}, // "b1b",not "bia" - } - for _, tt := range tests { - if got := UnmapVia(netip.MustParseAddr(tt.ip)).String(); got != tt.want { - t.Errorf("for %q: got %q, want %q", tt.ip, got, tt.want) - } - } -} - -func TestIsExitNodeRoute(t *testing.T) { - tests := []struct { - pref netip.Prefix - want bool - }{ - { - pref: AllIPv4(), - want: true, - }, - { - pref: AllIPv6(), - want: true, - }, - { - pref: netip.MustParsePrefix("1.1.1.1/0"), - want: false, - }, - { - pref: netip.MustParsePrefix("1.1.1.1/1"), - want: false, - }, - { - pref: netip.MustParsePrefix("192.168.0.0/24"), - want: false, - }, - } - - for _, tt := range tests { - if got := IsExitRoute(tt.pref); got != tt.want { - t.Errorf("for %q: got %v, want %v", tt.pref, got, tt.want) - } - } -} - -func TestWithoutExitRoutes(t *testing.T) { - tests := []struct { - prefs []netip.Prefix - want []netip.Prefix - }{ - { - prefs: []netip.Prefix{AllIPv4(), AllIPv6()}, - want: []netip.Prefix{}, - }, - { - prefs: []netip.Prefix{AllIPv4()}, - want: []netip.Prefix{AllIPv4()}, - }, - { - prefs: []netip.Prefix{AllIPv4(), AllIPv6(), netip.MustParsePrefix("10.0.0.0/10")}, - want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/10")}, - }, - { - prefs: []netip.Prefix{AllIPv6(), netip.MustParsePrefix("10.0.0.0/10")}, - want: []netip.Prefix{AllIPv6(), netip.MustParsePrefix("10.0.0.0/10")}, - }, - } - - for _, tt := range tests { - got := WithoutExitRoutes(views.SliceOf(tt.prefs)) - if diff := cmp.Diff(tt.want, got.AsSlice(), cmpopts.EquateEmpty(), cmp.Comparer(func(a, b netip.Prefix) bool { return a == b })); diff != "" { - t.Errorf("unexpected route difference (-want +got):\n%s", diff) - } - } -} - -func TestWithoutExitRoute(t *testing.T) { - tests := []struct { - prefs []netip.Prefix - want []netip.Prefix - }{ - { - prefs: []netip.Prefix{AllIPv4(), AllIPv6()}, - want: []netip.Prefix{}, - }, - { - prefs: []netip.Prefix{AllIPv4()}, - want: []netip.Prefix{}, - }, - { - prefs: []netip.Prefix{AllIPv4(), AllIPv6(), netip.MustParsePrefix("10.0.0.0/10")}, - want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/10")}, - }, - { - prefs: []netip.Prefix{AllIPv6(), netip.MustParsePrefix("10.0.0.0/10")}, - want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/10")}, - }, - } - - for _, tt := range tests { - got := WithoutExitRoute(views.SliceOf(tt.prefs)) - if diff := cmp.Diff(tt.want, got.AsSlice(), cmpopts.EquateEmpty(), cmp.Comparer(func(a, b netip.Prefix) bool { return a == b })); diff != "" { - t.Errorf("unexpected route difference (-want +got):\n%s", diff) - } - } -} - -func TestContainsExitRoute(t *testing.T) { - tests := []struct { - prefs []netip.Prefix - want bool - }{ - { - prefs: []netip.Prefix{AllIPv4(), AllIPv6()}, - want: true, - }, - { - prefs: []netip.Prefix{AllIPv4()}, - want: true, - }, - { - prefs: []netip.Prefix{AllIPv4(), AllIPv6(), netip.MustParsePrefix("10.0.0.0/10")}, - want: true, - }, - { - prefs: []netip.Prefix{AllIPv6(), netip.MustParsePrefix("10.0.0.0/10")}, - want: true, - }, - { - prefs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/10")}, - want: false, - }, - } - - for _, tt := range tests { - if got := ContainsExitRoute(views.SliceOf(tt.prefs)); got != tt.want { - t.Errorf("for %q: got %v, want %v", tt.prefs, got, tt.want) - } - } -} - -func TestIsTailscaleIPv4(t *testing.T) { - tests := []struct { - in netip.Addr - want bool - }{ - { - in: netip.MustParseAddr("100.67.19.57"), - want: true, - }, - { - in: netip.MustParseAddr("10.10.10.10"), - want: false, - }, - { - - in: netip.MustParseAddr("fd7a:115c:a1e0:3f2b:7a1d:4e88:9c2b:7f01"), - want: false, - }, - { - in: netip.MustParseAddr("bc9d:0aa0:1f0a:69ab:eb5c:28e0:5456:a518"), - want: false, - }, - { - in: netip.MustParseAddr("100.115.92.157"), - want: false, - }, - } - for _, tt := range tests { - if got := IsTailscaleIPv4(tt.in); got != tt.want { - t.Errorf("IsTailscaleIPv4(%v) = %v, want %v", tt.in, got, tt.want) - } - } -} - -func TestIsTailscaleIP(t *testing.T) { - tests := []struct { - in netip.Addr - want bool - }{ - { - in: netip.MustParseAddr("100.67.19.57"), - want: true, - }, - { - in: netip.MustParseAddr("10.10.10.10"), - want: false, - }, - { - - in: netip.MustParseAddr("fd7a:115c:a1e0:3f2b:7a1d:4e88:9c2b:7f01"), - want: true, - }, - { - in: netip.MustParseAddr("bc9d:0aa0:1f0a:69ab:eb5c:28e0:5456:a518"), - want: false, - }, - { - in: netip.MustParseAddr("100.115.92.157"), - want: false, - }, - } - for _, tt := range tests { - if got := IsTailscaleIP(tt.in); got != tt.want { - t.Errorf("IsTailscaleIP(%v) = %v, want %v", tt.in, got, tt.want) - } - } -} diff --git a/net/tsdial/dnsmap.go b/net/tsdial/dnsmap.go index 2ef1cb1f171c0..1c8758115b6a7 100644 --- a/net/tsdial/dnsmap.go +++ b/net/tsdial/dnsmap.go @@ -12,8 +12,8 @@ import ( "strconv" "strings" - "tailscale.com/types/netmap" - "tailscale.com/util/dnsname" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/util/dnsname" ) // dnsMap maps MagicDNS names (both base + FQDN) to their first IP. diff --git a/net/tsdial/dnsmap_test.go b/net/tsdial/dnsmap_test.go deleted file mode 100644 index 43461a135e1c5..0000000000000 --- a/net/tsdial/dnsmap_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsdial - -import ( - "net/netip" - "reflect" - "testing" - - "tailscale.com/tailcfg" - "tailscale.com/types/netmap" -) - -func nodeViews(v []*tailcfg.Node) []tailcfg.NodeView { - nv := make([]tailcfg.NodeView, len(v)) - for i, n := range v { - nv[i] = n.View() - } - return nv -} - -func TestDNSMapFromNetworkMap(t *testing.T) { - pfx := netip.MustParsePrefix - ip := netip.MustParseAddr - tests := []struct { - name string - nm *netmap.NetworkMap - want dnsMap - }{ - { - name: "self", - nm: &netmap.NetworkMap{ - Name: "foo.tailnet", - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - }, - want: dnsMap{ - "foo": ip("100.102.103.104"), - "foo.tailnet": ip("100.102.103.104"), - }, - }, - { - name: "self_and_peers", - nm: &netmap.NetworkMap{ - Name: "foo.tailnet", - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - pfx("100.102.103.104/32"), - pfx("100::123/128"), - }, - }).View(), - Peers: []tailcfg.NodeView{ - (&tailcfg.Node{ - Name: "a.tailnet", - Addresses: []netip.Prefix{ - pfx("100.0.0.201/32"), - pfx("100::201/128"), - }, - }).View(), - (&tailcfg.Node{ - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }).View(), - }, - }, - want: dnsMap{ - "foo": ip("100.102.103.104"), - "foo.tailnet": ip("100.102.103.104"), - "a": ip("100.0.0.201"), - "a.tailnet": ip("100.0.0.201"), - "b": ip("100::202"), - "b.tailnet": ip("100::202"), - }, - }, - { - name: "self_has_v6_only", - nm: &netmap.NetworkMap{ - Name: "foo.tailnet", - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{ - pfx("100::123/128"), - }, - }).View(), - Peers: nodeViews([]*tailcfg.Node{ - { - Name: "a.tailnet", - Addresses: []netip.Prefix{ - pfx("100.0.0.201/32"), - pfx("100::201/128"), - }, - }, - { - Name: "b.tailnet", - Addresses: []netip.Prefix{ - pfx("100::202/128"), - }, - }, - }), - }, - want: dnsMap{ - "foo": ip("100::123"), - "foo.tailnet": ip("100::123"), - "a": ip("100::201"), - "a.tailnet": ip("100::201"), - "b": ip("100::202"), - "b.tailnet": ip("100::202"), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := dnsMapFromNetworkMap(tt.nm) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("mismatch:\n got %v\nwant %v\n", got, tt.want) - } - }) - } -} diff --git a/net/tsdial/dohclient.go b/net/tsdial/dohclient.go index d830398cdfb9c..7c422aa466aa9 100644 --- a/net/tsdial/dohclient.go +++ b/net/tsdial/dohclient.go @@ -13,7 +13,7 @@ import ( "net/http" "time" - "tailscale.com/net/dnscache" + "github.com/sagernet/tailscale/net/dnscache" ) // dohConn is a net.PacketConn suitable for returning from diff --git a/net/tsdial/dohclient_test.go b/net/tsdial/dohclient_test.go deleted file mode 100644 index 23255769f4847..0000000000000 --- a/net/tsdial/dohclient_test.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsdial - -import ( - "context" - "flag" - "net" - "testing" - "time" -) - -var dohBase = flag.String("doh-base", "", "DoH base URL for manual DoH tests; e.g. \"http://100.68.82.120:47830/dns-query\"") - -func TestDoHResolve(t *testing.T) { - if *dohBase == "" { - t.Skip("skipping manual test without --doh-base= set") - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var r net.Resolver - r.Dial = func(ctx context.Context, network, address string) (net.Conn, error) { - return &dohConn{ctx: ctx, baseURL: *dohBase}, nil - } - addrs, err := r.LookupIP(ctx, "ip4", "google.com.") - if err != nil { - t.Fatal(err) - } - t.Logf("Got: %q", addrs) -} diff --git a/net/tsdial/peerapi_macios_ext.go b/net/tsdial/peerapi_macios_ext.go index 3ebead3db439f..fda24101e95a4 100644 --- a/net/tsdial/peerapi_macios_ext.go +++ b/net/tsdial/peerapi_macios_ext.go @@ -14,7 +14,7 @@ import ( "net" "syscall" - "tailscale.com/net/netns" + "github.com/sagernet/tailscale/net/netns" ) func init() { diff --git a/net/tsdial/tsdial.go b/net/tsdial/tsdial.go index 3606dd67f7ea2..17dd21ceb5290 100644 --- a/net/tsdial/tsdial.go +++ b/net/tsdial/tsdial.go @@ -19,17 +19,17 @@ import ( "time" "github.com/gaissmai/bart" - "tailscale.com/net/dnscache" - "tailscale.com/net/netknob" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/testenv" - "tailscale.com/version" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/netknob" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/version" ) // NewDialer returns a new Dialer that can dial out of tailscaled. diff --git a/net/tshttpproxy/tshttpproxy.go b/net/tshttpproxy/tshttpproxy.go index 2ca440b57be74..e6cc8f8e67ffe 100644 --- a/net/tshttpproxy/tshttpproxy.go +++ b/net/tshttpproxy/tshttpproxy.go @@ -18,8 +18,8 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/util/mak" "golang.org/x/net/http/httpproxy" - "tailscale.com/util/mak" ) // InvalidateCache invalidates the package-level cache for ProxyFromEnvironment. diff --git a/net/tshttpproxy/tshttpproxy_linux.go b/net/tshttpproxy/tshttpproxy_linux.go index b241c256d4798..2d87c5d278294 100644 --- a/net/tshttpproxy/tshttpproxy_linux.go +++ b/net/tshttpproxy/tshttpproxy_linux.go @@ -9,7 +9,7 @@ import ( "net/http" "net/url" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/version/distro" ) func init() { diff --git a/net/tshttpproxy/tshttpproxy_synology.go b/net/tshttpproxy/tshttpproxy_synology.go index 2e50d26d3a655..45ea122c067a7 100644 --- a/net/tshttpproxy/tshttpproxy_synology.go +++ b/net/tshttpproxy/tshttpproxy_synology.go @@ -17,7 +17,7 @@ import ( "sync" "time" - "tailscale.com/util/lineiter" + "github.com/sagernet/tailscale/util/lineiter" ) // These vars are overridden for tests. diff --git a/net/tshttpproxy/tshttpproxy_synology_test.go b/net/tshttpproxy/tshttpproxy_synology_test.go deleted file mode 100644 index 3061740f3beff..0000000000000 --- a/net/tshttpproxy/tshttpproxy_synology_test.go +++ /dev/null @@ -1,376 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package tshttpproxy - -import ( - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "tailscale.com/tstest" -) - -func TestSynologyProxyFromConfigCached(t *testing.T) { - req, err := http.NewRequest("GET", "http://example.org/", nil) - if err != nil { - t.Fatal(err) - } - - tstest.Replace(t, &synologyProxyConfigPath, filepath.Join(t.TempDir(), "proxy.conf")) - - t.Run("no config file", func(t *testing.T) { - if _, err := os.Stat(synologyProxyConfigPath); err == nil { - t.Fatalf("%s must not exist for this test", synologyProxyConfigPath) - } - - cache.updated = time.Time{} - cache.httpProxy = nil - cache.httpsProxy = nil - - if val, err := synologyProxyFromConfigCached(req); val != nil || err != nil { - t.Fatalf("got %s, %v; want nil, nil", val, err) - } - - if got, want := cache.updated, time.Unix(0, 0); got != want { - t.Fatalf("got %s, want %s", got, want) - } - if cache.httpProxy != nil { - t.Fatalf("got %s, want nil", cache.httpProxy) - } - if cache.httpsProxy != nil { - t.Fatalf("got %s, want nil", cache.httpsProxy) - } - }) - - t.Run("config file updated", func(t *testing.T) { - cache.updated = time.Now() - cache.httpProxy = nil - cache.httpsProxy = nil - - if err := os.WriteFile(synologyProxyConfigPath, []byte(` -proxy_enabled=yes -http_host=10.0.0.55 -http_port=80 -https_host=10.0.0.66 -https_port=443 - `), 0600); err != nil { - t.Fatal(err) - } - - val, err := synologyProxyFromConfigCached(req) - if err != nil { - t.Fatal(err) - } - - if cache.httpProxy == nil { - t.Fatal("http proxy was not cached") - } - if cache.httpsProxy == nil { - t.Fatal("https proxy was not cached") - } - - if want := urlMustParse("http://10.0.0.55:80"); val.String() != want.String() { - t.Fatalf("got %s; want %s", val, want) - } - }) - - t.Run("config file removed", func(t *testing.T) { - cache.updated = time.Now() - cache.httpProxy = urlMustParse("http://127.0.0.1/") - cache.httpsProxy = urlMustParse("http://127.0.0.1/") - - if err := os.Remove(synologyProxyConfigPath); err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - - val, err := synologyProxyFromConfigCached(req) - if err != nil { - t.Fatal(err) - } - if val != nil { - t.Fatalf("got %s; want nil", val) - } - if cache.httpProxy != nil { - t.Fatalf("got %s, want nil", cache.httpProxy) - } - if cache.httpsProxy != nil { - t.Fatalf("got %s, want nil", cache.httpsProxy) - } - }) - - t.Run("picks proxy from request scheme", func(t *testing.T) { - cache.updated = time.Now() - cache.httpProxy = nil - cache.httpsProxy = nil - - if err := os.WriteFile(synologyProxyConfigPath, []byte(` -proxy_enabled=yes -http_host=10.0.0.55 -http_port=80 -https_host=10.0.0.66 -https_port=443 - `), 0600); err != nil { - t.Fatal(err) - } - - httpReq, err := http.NewRequest("GET", "http://example.com", nil) - if err != nil { - t.Fatal(err) - } - val, err := synologyProxyFromConfigCached(httpReq) - if err != nil { - t.Fatal(err) - } - if val == nil { - t.Fatalf("got nil, want an http URL") - } - if got, want := val.String(), "http://10.0.0.55:80"; got != want { - t.Fatalf("got %q, want %q", got, want) - } - - httpsReq, err := http.NewRequest("GET", "https://example.com", nil) - if err != nil { - t.Fatal(err) - } - val, err = synologyProxyFromConfigCached(httpsReq) - if err != nil { - t.Fatal(err) - } - if val == nil { - t.Fatalf("got nil, want an http URL") - } - if got, want := val.String(), "http://10.0.0.66:443"; got != want { - t.Fatalf("got %q, want %q", got, want) - } - }) -} - -func TestSynologyProxiesFromConfig(t *testing.T) { - var ( - openReader io.ReadCloser - openErr error - ) - tstest.Replace(t, &openSynologyProxyConf, func() (io.ReadCloser, error) { - return openReader, openErr - }) - - t.Run("with config", func(t *testing.T) { - mc := &mustCloser{Reader: strings.NewReader(` -proxy_user=foo -proxy_pwd=bar -proxy_enabled=yes -adv_enabled=yes -bypass_enabled=yes -auth_enabled=yes -https_host=10.0.0.66 -https_port=8443 -http_host=10.0.0.55 -http_port=80 - `)} - defer mc.check(t) - openReader = mc - - httpProxy, httpsProxy, err := synologyProxiesFromConfig() - - if got, want := err, openErr; got != want { - t.Fatalf("got %s, want %s", got, want) - } - - if got, want := httpsProxy, urlMustParse("http://foo:bar@10.0.0.66:8443"); got.String() != want.String() { - t.Fatalf("got %s, want %s", got, want) - } - - if got, want := err, openErr; got != want { - t.Fatalf("got %s, want %s", got, want) - } - - if got, want := httpProxy, urlMustParse("http://foo:bar@10.0.0.55:80"); got.String() != want.String() { - t.Fatalf("got %s, want %s", got, want) - } - - }) - - t.Run("nonexistent config", func(t *testing.T) { - openReader = nil - openErr = os.ErrNotExist - - httpProxy, httpsProxy, err := synologyProxiesFromConfig() - if err != nil { - t.Fatalf("expected no error, got %s", err) - } - if httpProxy != nil { - t.Fatalf("expected no url, got %s", httpProxy) - } - if httpsProxy != nil { - t.Fatalf("expected no url, got %s", httpsProxy) - } - }) - - t.Run("error opening config", func(t *testing.T) { - openReader = nil - openErr = errors.New("example error") - - httpProxy, httpsProxy, err := synologyProxiesFromConfig() - if err != openErr { - t.Fatalf("expected %s, got %s", openErr, err) - } - if httpProxy != nil { - t.Fatalf("expected no url, got %s", httpProxy) - } - if httpsProxy != nil { - t.Fatalf("expected no url, got %s", httpsProxy) - } - }) - -} - -func TestParseSynologyConfig(t *testing.T) { - cases := map[string]struct { - input string - httpProxy *url.URL - httpsProxy *url.URL - err error - }{ - "populated": { - input: ` -proxy_user=foo -proxy_pwd=bar -proxy_enabled=yes -adv_enabled=yes -bypass_enabled=yes -auth_enabled=yes -https_host=10.0.0.66 -https_port=8443 -http_host=10.0.0.55 -http_port=80 -`, - httpProxy: urlMustParse("http://foo:bar@10.0.0.55:80"), - httpsProxy: urlMustParse("http://foo:bar@10.0.0.66:8443"), - err: nil, - }, - "no-auth": { - input: ` -proxy_user=foo -proxy_pwd=bar -proxy_enabled=yes -adv_enabled=yes -bypass_enabled=yes -auth_enabled=no -https_host=10.0.0.66 -https_port=8443 -http_host=10.0.0.55 -http_port=80 -`, - httpProxy: urlMustParse("http://10.0.0.55:80"), - httpsProxy: urlMustParse("http://10.0.0.66:8443"), - err: nil, - }, - "http-only": { - input: ` -proxy_user=foo -proxy_pwd=bar -proxy_enabled=yes -adv_enabled=yes -bypass_enabled=yes -auth_enabled=yes -https_host= -https_port=8443 -http_host=10.0.0.55 -http_port=80 -`, - httpProxy: urlMustParse("http://foo:bar@10.0.0.55:80"), - httpsProxy: nil, - err: nil, - }, - "empty": { - input: ` -proxy_user= -proxy_pwd= -proxy_enabled= -adv_enabled= -bypass_enabled= -auth_enabled= -https_host= -https_port= -http_host= -http_port= -`, - httpProxy: nil, - httpsProxy: nil, - err: nil, - }, - } - - for name, example := range cases { - t.Run(name, func(t *testing.T) { - httpProxy, httpsProxy, err := parseSynologyConfig(strings.NewReader(example.input)) - if err != example.err { - t.Fatal(err) - } - if example.err != nil { - return - } - - if example.httpProxy == nil && httpProxy != nil { - t.Fatalf("got %s, want nil", httpProxy) - } - - if example.httpProxy != nil { - if httpProxy == nil { - t.Fatalf("got nil, want %s", example.httpProxy) - } - - if got, want := example.httpProxy.String(), httpProxy.String(); got != want { - t.Fatalf("got %s, want %s", got, want) - } - } - - if example.httpsProxy == nil && httpsProxy != nil { - t.Fatalf("got %s, want nil", httpProxy) - } - - if example.httpsProxy != nil { - if httpsProxy == nil { - t.Fatalf("got nil, want %s", example.httpsProxy) - } - - if got, want := example.httpsProxy.String(), httpsProxy.String(); got != want { - t.Fatalf("got %s, want %s", got, want) - } - } - }) - } -} -func urlMustParse(u string) *url.URL { - r, err := url.Parse(u) - if err != nil { - panic(fmt.Sprintf("urlMustParse: %s", err)) - } - return r -} - -type mustCloser struct { - io.Reader - closed bool -} - -func (m *mustCloser) Close() error { - m.closed = true - return nil -} - -func (m *mustCloser) check(t *testing.T) { - if !m.closed { - t.Errorf("mustCloser wrapping %#v was not closed at time of check", m.Reader) - } -} diff --git a/net/tshttpproxy/tshttpproxy_test.go b/net/tshttpproxy/tshttpproxy_test.go deleted file mode 100644 index 97f8c1f8b049a..0000000000000 --- a/net/tshttpproxy/tshttpproxy_test.go +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tshttpproxy - -import ( - "net/http" - "net/url" - "os" - "runtime" - "strings" - "testing" - "time" - - "tailscale.com/util/must" -) - -func TestGetAuthHeaderNoResult(t *testing.T) { - const proxyURL = "http://127.0.0.1:38274" - - u, err := url.Parse(proxyURL) - if err != nil { - t.Fatalf("can't parse %q: %v", proxyURL, err) - } - - got, err := GetAuthHeader(u) - if err != nil { - t.Fatalf("can't get auth header value: %v", err) - } - - if runtime.GOOS == "windows" && strings.HasPrefix(got, "Negotiate") { - t.Logf("didn't get empty result, but got acceptable Windows Negotiate header") - return - } - if got != "" { - t.Fatalf("GetAuthHeader(%q) = %q; want empty string", proxyURL, got) - } -} - -func TestGetAuthHeaderBasicAuth(t *testing.T) { - const proxyURL = "http://user:password@127.0.0.1:38274" - const want = "Basic dXNlcjpwYXNzd29yZA==" - - u, err := url.Parse(proxyURL) - if err != nil { - t.Fatalf("can't parse %q: %v", proxyURL, err) - } - - got, err := GetAuthHeader(u) - if err != nil { - t.Fatalf("can't get auth header value: %v", err) - } - - if got != want { - t.Fatalf("GetAuthHeader(%q) = %q; want %q", proxyURL, got, want) - } -} - -func TestProxyFromEnvironment_setNoProxyUntil(t *testing.T) { - const fakeProxyEnv = "10.1.2.3:456" - const fakeProxyFull = "http://" + fakeProxyEnv - - defer os.Setenv("HTTPS_PROXY", os.Getenv("HTTPS_PROXY")) - os.Setenv("HTTPS_PROXY", fakeProxyEnv) - - req := &http.Request{URL: must.Get(url.Parse("https://example.com/"))} - for i := range 3 { - switch i { - case 1: - setNoProxyUntil(time.Minute) - case 2: - setNoProxyUntil(0) - } - got, err := ProxyFromEnvironment(req) - if err != nil { - t.Fatalf("[%d] ProxyFromEnvironment: %v", i, err) - } - if got == nil || got.String() != fakeProxyFull { - t.Errorf("[%d] Got proxy %v; want %v", i, got, fakeProxyFull) - } - } - -} - -func TestSetSelfProxy(t *testing.T) { - // Ensure we clean everything up at the end of our test - t.Cleanup(func() { - config = nil - proxyFunc = nil - }) - - testCases := []struct { - name string - env map[string]string - self []string - wantHTTP string - wantHTTPS string - }{ - { - name: "no self proxy", - env: map[string]string{ - "HTTP_PROXY": "127.0.0.1:1234", - "HTTPS_PROXY": "127.0.0.1:1234", - }, - self: nil, - wantHTTP: "127.0.0.1:1234", - wantHTTPS: "127.0.0.1:1234", - }, - { - name: "skip proxies", - env: map[string]string{ - "HTTP_PROXY": "127.0.0.1:1234", - "HTTPS_PROXY": "127.0.0.1:5678", - }, - self: []string{"127.0.0.1:1234", "127.0.0.1:5678"}, - wantHTTP: "", // skipped - wantHTTPS: "", // skipped - }, - { - name: "localhost normalization of env var", - env: map[string]string{ - "HTTP_PROXY": "localhost:1234", - "HTTPS_PROXY": "[::1]:5678", - }, - self: []string{"127.0.0.1:1234", "127.0.0.1:5678"}, - wantHTTP: "", // skipped - wantHTTPS: "", // skipped - }, - { - name: "localhost normalization of addr", - env: map[string]string{ - "HTTP_PROXY": "127.0.0.1:1234", - "HTTPS_PROXY": "127.0.0.1:1234", - }, - self: []string{"[::1]:1234"}, - wantHTTP: "", // skipped - wantHTTPS: "", // skipped - }, - { - name: "no ports", - env: map[string]string{ - "HTTP_PROXY": "myproxy", - "HTTPS_PROXY": "myproxy", - }, - self: []string{"127.0.0.1:1234"}, - wantHTTP: "myproxy", - wantHTTPS: "myproxy", - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - oldEnv, found := os.LookupEnv(k) - if found { - t.Cleanup(func() { - os.Setenv(k, oldEnv) - }) - } - os.Setenv(k, v) - } - - // Reset computed variables - config = nil - proxyFunc = func(*url.URL) (*url.URL, error) { - panic("should not be called") - } - - SetSelfProxy(tt.self...) - - if got := config.HTTPProxy; got != tt.wantHTTP { - t.Errorf("got HTTPProxy=%q; want %q", got, tt.wantHTTP) - } - if got := config.HTTPSProxy; got != tt.wantHTTPS { - t.Errorf("got HTTPSProxy=%q; want %q", got, tt.wantHTTPS) - } - if proxyFunc != nil { - t.Errorf("wanted nil proxyFunc") - } - - // Verify that we do actually proxy through the - // expected proxy, if we have one configured. - pf := getProxyFunc() - if tt.wantHTTP != "" { - want := "http://" + tt.wantHTTP - - uu, _ := url.Parse("http://tailscale.com") - dest, err := pf(uu) - if err != nil { - t.Error(err) - } else if dest.String() != want { - t.Errorf("got dest=%q; want %q", dest, want) - } - } - if tt.wantHTTPS != "" { - want := "http://" + tt.wantHTTPS - - uu, _ := url.Parse("https://tailscale.com") - dest, err := pf(uu) - if err != nil { - t.Error(err) - } else if dest.String() != want { - t.Errorf("got dest=%q; want %q", dest, want) - } - } - }) - } -} diff --git a/net/tshttpproxy/tshttpproxy_windows.go b/net/tshttpproxy/tshttpproxy_windows.go index 06a1f5ae445d0..edd34422b770c 100644 --- a/net/tshttpproxy/tshttpproxy_windows.go +++ b/net/tshttpproxy/tshttpproxy_windows.go @@ -18,12 +18,12 @@ import ( "unsafe" "github.com/alexbrainman/sspi/negotiate" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/cmpver" "golang.org/x/sys/windows" - "tailscale.com/hostinfo" - "tailscale.com/syncs" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/cmpver" ) func init() { diff --git a/net/tstun/fake.go b/net/tstun/fake.go index 3d86bb3df4ca9..f3e315317f38f 100644 --- a/net/tstun/fake.go +++ b/net/tstun/fake.go @@ -7,7 +7,7 @@ import ( "io" "os" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/wireguard-go/tun" ) type fakeTUN struct { diff --git a/net/tstun/ifstatus_noop.go b/net/tstun/ifstatus_noop.go index 8cf569f982010..548aeb3d88184 100644 --- a/net/tstun/ifstatus_noop.go +++ b/net/tstun/ifstatus_noop.go @@ -8,8 +8,8 @@ package tstun import ( "time" - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" ) // Dummy implementation that does nothing. diff --git a/net/tstun/ifstatus_windows.go b/net/tstun/ifstatus_windows.go index fd9fc2112524c..42f229ef03429 100644 --- a/net/tstun/ifstatus_windows.go +++ b/net/tstun/ifstatus_windows.go @@ -8,9 +8,9 @@ import ( "sync" "time" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/types/logger" ) // ifaceWatcher waits for an interface to be up. diff --git a/net/tstun/linkattrs_linux.go b/net/tstun/linkattrs_linux.go index 681e79269f75f..d6f7fca1d9c12 100644 --- a/net/tstun/linkattrs_linux.go +++ b/net/tstun/linkattrs_linux.go @@ -6,7 +6,7 @@ package tstun import ( "github.com/mdlayher/genetlink" "github.com/mdlayher/netlink" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/wireguard-go/tun" "golang.org/x/sys/unix" ) diff --git a/net/tstun/linkattrs_notlinux.go b/net/tstun/linkattrs_notlinux.go index 7a7b40fc2652b..5552bf1a944e9 100644 --- a/net/tstun/linkattrs_notlinux.go +++ b/net/tstun/linkattrs_notlinux.go @@ -5,7 +5,7 @@ package tstun -import "github.com/tailscale/wireguard-go/tun" +import "github.com/sagernet/wireguard-go/tun" func setLinkAttrs(iface tun.Device) error { return nil diff --git a/net/tstun/mtu.go b/net/tstun/mtu.go index 004529c205f9e..0502469f389db 100644 --- a/net/tstun/mtu.go +++ b/net/tstun/mtu.go @@ -4,7 +4,7 @@ package tstun import ( - "tailscale.com/envknob" + "github.com/sagernet/tailscale/envknob" ) // The MTU (Maximum Transmission Unit) of a network interface is the largest diff --git a/net/tstun/mtu_test.go b/net/tstun/mtu_test.go deleted file mode 100644 index 8d165bfd341a9..0000000000000 --- a/net/tstun/mtu_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause -package tstun - -import ( - "os" - "strconv" - "testing" -) - -// Test the default MTU in the presence of various envknobs. -func TestDefaultTunMTU(t *testing.T) { - // Save and restore the envknobs we will be changing. - - // TS_DEBUG_MTU sets the MTU to a specific value. - defer os.Setenv("TS_DEBUG_MTU", os.Getenv("TS_DEBUG_MTU")) - os.Setenv("TS_DEBUG_MTU", "") - - // TS_DEBUG_ENABLE_PMTUD enables path MTU discovery. - defer os.Setenv("TS_DEBUG_ENABLE_PMTUD", os.Getenv("TS_DEBUG_ENABLE_PMTUD")) - os.Setenv("TS_DEBUG_ENABLE_PMTUD", "") - - // With no MTU envknobs set, we should get the conservative MTU. - if DefaultTUNMTU() != safeTUNMTU { - t.Errorf("default TUN MTU = %d, want %d", DefaultTUNMTU(), safeTUNMTU) - } - - // If set, TS_DEBUG_MTU should set the MTU. - mtu := maxTUNMTU - 1 - os.Setenv("TS_DEBUG_MTU", strconv.Itoa(int(mtu))) - if DefaultTUNMTU() != mtu { - t.Errorf("default TUN MTU = %d, want %d, TS_DEBUG_MTU ignored", DefaultTUNMTU(), mtu) - } - - // MTU should be clamped to maxTunMTU. - mtu = maxTUNMTU + 1 - os.Setenv("TS_DEBUG_MTU", strconv.Itoa(int(mtu))) - if DefaultTUNMTU() != maxTUNMTU { - t.Errorf("default TUN MTU = %d, want %d, clamping failed", DefaultTUNMTU(), maxTUNMTU) - } - - // If PMTUD is enabled, the MTU should default to the safe MTU, but only - // if the user hasn't requested a specific MTU. - // - // TODO: When PMTUD is generating PTB responses, this will become the - // largest MTU we probe. - os.Setenv("TS_DEBUG_MTU", "") - os.Setenv("TS_DEBUG_ENABLE_PMTUD", "true") - if DefaultTUNMTU() != safeTUNMTU { - t.Errorf("default TUN MTU = %d, want %d", DefaultTUNMTU(), safeTUNMTU) - } - // TS_DEBUG_MTU should take precedence over TS_DEBUG_ENABLE_PMTUD. - mtu = WireToTUNMTU(MaxPacketSize - 1) - os.Setenv("TS_DEBUG_MTU", strconv.Itoa(int(mtu))) - if DefaultTUNMTU() != mtu { - t.Errorf("default TUN MTU = %d, want %d", DefaultTUNMTU(), mtu) - } -} - -// Test the conversion of wire MTU to/from Tailscale TUN MTU corner cases. -func TestMTUConversion(t *testing.T) { - tests := []struct { - w WireMTU - t TUNMTU - }{ - {w: 0, t: 0}, - {w: wgHeaderLen - 1, t: 0}, - {w: wgHeaderLen, t: 0}, - {w: wgHeaderLen + 1, t: 1}, - {w: 1360, t: 1280}, - {w: 1500, t: 1420}, - {w: 9000, t: 8920}, - } - - for _, tt := range tests { - m := WireToTUNMTU(tt.w) - if m != tt.t { - t.Errorf("conversion of wire MTU %v to TUN MTU = %v, want %v", tt.w, m, tt.t) - } - } - - tests2 := []struct { - t TUNMTU - w WireMTU - }{ - {t: 0, w: wgHeaderLen}, - {t: 1, w: wgHeaderLen + 1}, - {t: 1280, w: 1360}, - {t: 1420, w: 1500}, - {t: 8920, w: 9000}, - } - - for _, tt := range tests2 { - m := TUNToWireMTU(tt.t) - if m != tt.w { - t.Errorf("conversion of TUN MTU %v to wire MTU = %v, want %v", tt.t, m, tt.w) - } - } -} diff --git a/net/tstun/tap_linux.go b/net/tstun/tap_linux.go index 8a00a96927c4d..eb60ae62026d2 100644 --- a/net/tstun/tap_linux.go +++ b/net/tstun/tap_linux.go @@ -15,21 +15,21 @@ import ( "sync" "github.com/insomniacslk/dhcp/dhcpv4" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/checksum" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/wireguard-go/tun" "golang.org/x/sys/unix" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/checksum" - "gvisor.dev/gvisor/pkg/tcpip/header" - "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" - "gvisor.dev/gvisor/pkg/tcpip/network/ipv6" - "gvisor.dev/gvisor/pkg/tcpip/transport/udp" - "tailscale.com/net/netaddr" - "tailscale.com/net/packet" - "tailscale.com/net/tsaddr" - "tailscale.com/syncs" - "tailscale.com/types/ipproto" - "tailscale.com/types/logger" - "tailscale.com/util/multierr" ) // TODO: this was randomly generated once. Maybe do it per process start? But diff --git a/net/tstun/tstun_stub.go b/net/tstun/tstun_stub.go index 7a4f71a099fd5..5ca2e73ed1319 100644 --- a/net/tstun/tstun_stub.go +++ b/net/tstun/tstun_stub.go @@ -6,8 +6,8 @@ package tstun import ( - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" ) func New(logf logger.Logf, tunName string) (tun.Device, string, error) { diff --git a/net/tstun/tun.go b/net/tstun/tun.go index 9f5d42ecc3269..1f0d64178de03 100644 --- a/net/tstun/tun.go +++ b/net/tstun/tun.go @@ -13,8 +13,8 @@ import ( "strings" "time" - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" ) // createTAP is non-nil on Linux. diff --git a/net/tstun/tun_linux.go b/net/tstun/tun_linux.go index 9600ceb77328f..536ed506523b6 100644 --- a/net/tstun/tun_linux.go +++ b/net/tstun/tun_linux.go @@ -11,8 +11,8 @@ import ( "strings" "syscall" - "tailscale.com/types/logger" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/version/distro" ) func init() { diff --git a/net/tstun/tun_macos.go b/net/tstun/tun_macos.go index 3506f05b1e4c9..64f11c3001f1d 100644 --- a/net/tstun/tun_macos.go +++ b/net/tstun/tun_macos.go @@ -8,7 +8,7 @@ package tstun import ( "os" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) func init() { diff --git a/net/tstun/tun_notwindows.go b/net/tstun/tun_notwindows.go index 087fcd4eec784..a504436237b8b 100644 --- a/net/tstun/tun_notwindows.go +++ b/net/tstun/tun_notwindows.go @@ -5,7 +5,7 @@ package tstun -import "github.com/tailscale/wireguard-go/tun" +import "github.com/sagernet/wireguard-go/tun" func interfaceName(dev tun.Device) (string, error) { return dev.Name() diff --git a/net/tstun/tun_windows.go b/net/tstun/tun_windows.go index 2b1d3054e5ecb..e759edb813dda 100644 --- a/net/tstun/tun_windows.go +++ b/net/tstun/tun_windows.go @@ -4,7 +4,7 @@ package tstun import ( - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/wireguard-go/tun" "golang.org/x/sys/windows" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" ) diff --git a/net/tstun/wrap.go b/net/tstun/wrap.go index c384abf9d4bbe..a8c15fc832680 100644 --- a/net/tstun/wrap.go +++ b/net/tstun/wrap.go @@ -18,28 +18,28 @@ import ( "time" "github.com/gaissmai/bart" - "github.com/tailscale/wireguard-go/conn" - "github.com/tailscale/wireguard-go/device" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/tailscale/disco" + tsmetrics "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/net/connstats" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/net/packet/checksum" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tstime/mono" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/usermetric" + "github.com/sagernet/tailscale/wgengine/capture" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/netstack/gro" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/wireguard-go/conn" + "github.com/sagernet/wireguard-go/device" + "github.com/sagernet/wireguard-go/tun" "go4.org/mem" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "tailscale.com/disco" - tsmetrics "tailscale.com/metrics" - "tailscale.com/net/connstats" - "tailscale.com/net/packet" - "tailscale.com/net/packet/checksum" - "tailscale.com/net/tsaddr" - "tailscale.com/syncs" - "tailscale.com/tstime/mono" - "tailscale.com/types/ipproto" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/usermetric" - "tailscale.com/wgengine/capture" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/netstack/gro" - "tailscale.com/wgengine/wgcfg" ) const maxBufferSize = device.MaxMessageSize diff --git a/net/tstun/wrap_linux.go b/net/tstun/wrap_linux.go index 136ddfe1efb2d..169b93c87744c 100644 --- a/net/tstun/wrap_linux.go +++ b/net/tstun/wrap_linux.go @@ -8,13 +8,13 @@ import ( "net/netip" "runtime" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/checksum" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/wireguard-go/tun" "golang.org/x/sys/unix" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/checksum" - "gvisor.dev/gvisor/pkg/tcpip/header" - "tailscale.com/envknob" - "tailscale.com/net/tsaddr" ) // SetLinkFeaturesPostUp configures link features on t based on select TS_TUN_ diff --git a/net/tstun/wrap_test.go b/net/tstun/wrap_test.go deleted file mode 100644 index 9ebedda837b0a..0000000000000 --- a/net/tstun/wrap_test.go +++ /dev/null @@ -1,961 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstun - -import ( - "bytes" - "context" - "encoding/binary" - "encoding/hex" - "expvar" - "fmt" - "net/netip" - "reflect" - "strconv" - "strings" - "testing" - "time" - "unicode" - "unsafe" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/tailscale/wireguard-go/tun/tuntest" - "go4.org/mem" - "go4.org/netipx" - "gvisor.dev/gvisor/pkg/buffer" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "tailscale.com/disco" - "tailscale.com/net/connstats" - "tailscale.com/net/netaddr" - "tailscale.com/net/packet" - "tailscale.com/tstest" - "tailscale.com/tstime/mono" - "tailscale.com/types/ipproto" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netlogtype" - "tailscale.com/types/ptr" - "tailscale.com/types/views" - "tailscale.com/util/must" - "tailscale.com/util/usermetric" - "tailscale.com/wgengine/capture" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/wgcfg" -) - -func udp4(src, dst string, sport, dport uint16) []byte { - sip, err := netip.ParseAddr(src) - if err != nil { - panic(err) - } - dip, err := netip.ParseAddr(dst) - if err != nil { - panic(err) - } - header := &packet.UDP4Header{ - IP4Header: packet.IP4Header{ - Src: sip, - Dst: dip, - IPID: 0, - }, - SrcPort: sport, - DstPort: dport, - } - return packet.Generate(header, []byte("udp_payload")) -} - -func tcp4syn(src, dst string, sport, dport uint16) []byte { - sip, err := netip.ParseAddr(src) - if err != nil { - panic(err) - } - dip, err := netip.ParseAddr(dst) - if err != nil { - panic(err) - } - ipHeader := packet.IP4Header{ - IPProto: ipproto.TCP, - Src: sip, - Dst: dip, - IPID: 0, - } - tcpHeader := make([]byte, 20) - binary.BigEndian.PutUint16(tcpHeader[0:], sport) - binary.BigEndian.PutUint16(tcpHeader[2:], dport) - tcpHeader[13] |= 2 // SYN - - both := packet.Generate(ipHeader, tcpHeader) - - // 20 byte IP4 + 20 byte TCP - binary.BigEndian.PutUint16(both[2:4], 40) - - return both -} - -func nets(nets ...string) (ret []netip.Prefix) { - for _, s := range nets { - if i := strings.IndexByte(s, '/'); i == -1 { - ip, err := netip.ParseAddr(s) - if err != nil { - panic(err) - } - bits := uint8(32) - if ip.Is6() { - bits = 128 - } - ret = append(ret, netip.PrefixFrom(ip, int(bits))) - } else { - pfx, err := netip.ParsePrefix(s) - if err != nil { - panic(err) - } - ret = append(ret, pfx) - } - } - return ret -} - -func ports(s string) filter.PortRange { - if s == "*" { - return filter.PortRange{First: 0, Last: 65535} - } - - var fs, ls string - i := strings.IndexByte(s, '-') - if i == -1 { - fs = s - ls = fs - } else { - fs = s[:i] - ls = s[i+1:] - } - first, err := strconv.ParseInt(fs, 10, 16) - if err != nil { - panic(fmt.Sprintf("invalid NetPortRange %q", s)) - } - last, err := strconv.ParseInt(ls, 10, 16) - if err != nil { - panic(fmt.Sprintf("invalid NetPortRange %q", s)) - } - return filter.PortRange{First: uint16(first), Last: uint16(last)} -} - -func netports(netPorts ...string) (ret []filter.NetPortRange) { - for _, s := range netPorts { - i := strings.LastIndexByte(s, ':') - if i == -1 { - panic(fmt.Sprintf("invalid NetPortRange %q", s)) - } - - npr := filter.NetPortRange{ - Net: nets(s[:i])[0], - Ports: ports(s[i+1:]), - } - ret = append(ret, npr) - } - return ret -} - -func setfilter(logf logger.Logf, tun *Wrapper) { - protos := views.SliceOf([]ipproto.Proto{ - ipproto.TCP, - ipproto.UDP, - }) - matches := []filter.Match{ - {IPProto: protos, Srcs: nets("5.6.7.8"), Dsts: netports("1.2.3.4:89-90")}, - {IPProto: protos, Srcs: nets("1.2.3.4"), Dsts: netports("5.6.7.8:98")}, - } - var sb netipx.IPSetBuilder - sb.AddPrefix(netip.MustParsePrefix("1.2.0.0/16")) - ipSet, _ := sb.IPSet() - tun.SetFilter(filter.New(matches, nil, ipSet, ipSet, nil, logf)) -} - -func newChannelTUN(logf logger.Logf, secure bool) (*tuntest.ChannelTUN, *Wrapper) { - chtun := tuntest.NewChannelTUN() - reg := new(usermetric.Registry) - tun := Wrap(logf, chtun.TUN(), reg) - if secure { - setfilter(logf, tun) - } else { - tun.disableFilter = true - } - tun.Start() - return chtun, tun -} - -func newFakeTUN(logf logger.Logf, secure bool) (*fakeTUN, *Wrapper) { - ftun := NewFake() - reg := new(usermetric.Registry) - tun := Wrap(logf, ftun, reg) - if secure { - setfilter(logf, tun) - } else { - tun.disableFilter = true - } - return ftun.(*fakeTUN), tun -} - -func TestReadAndInject(t *testing.T) { - chtun, tun := newChannelTUN(t.Logf, false) - defer tun.Close() - - const size = 2 // all payloads have this size - written := []string{"w0", "w1"} - injected := []string{"i0", "i1"} - - go func() { - for _, packet := range written { - payload := []byte(packet) - chtun.Outbound <- payload - } - }() - - for _, packet := range injected { - go func(packet string) { - payload := []byte(packet) - err := tun.InjectOutbound(payload) - if err != nil { - t.Errorf("%s: error: %v", packet, err) - } - }(packet) - } - - var buf [MaxPacketSize]byte - var seen = make(map[string]bool) - sizes := make([]int, 1) - // We expect the same packets back, in no particular order. - for i := range len(written) + len(injected) { - packet := buf[:] - buffs := [][]byte{packet} - numPackets, err := tun.Read(buffs, sizes, 0) - if err != nil { - t.Errorf("read %d: error: %v", i, err) - } - if numPackets != 1 { - t.Fatalf("read %d packets, expected %d", numPackets, 1) - } - packet = packet[:sizes[0]] - packetLen := len(packet) - if packetLen != size { - t.Errorf("read %d: got size %d; want %d", i, packetLen, size) - } - got := string(packet) - t.Logf("read %d: got %s", i, got) - seen[got] = true - } - - for _, packet := range written { - if !seen[packet] { - t.Errorf("%s not received", packet) - } - } - for _, packet := range injected { - if !seen[packet] { - t.Errorf("%s not received", packet) - } - } -} - -func TestWriteAndInject(t *testing.T) { - chtun, tun := newChannelTUN(t.Logf, false) - defer tun.Close() - - written := []string{"w0", "w1"} - injected := []string{"i0", "i1"} - - go func() { - for _, packet := range written { - payload := []byte(packet) - _, err := tun.Write([][]byte{payload}, 0) - if err != nil { - t.Errorf("%s: error: %v", packet, err) - } - } - }() - - for _, packet := range injected { - go func(packet string) { - payload := []byte(packet) - err := tun.InjectInboundCopy(payload) - if err != nil { - t.Errorf("%s: error: %v", packet, err) - } - }(packet) - } - - seen := make(map[string]bool) - // We expect the same packets back, in no particular order. - for i := range len(written) + len(injected) { - packet := <-chtun.Inbound - got := string(packet) - t.Logf("read %d: got %s", i, got) - seen[got] = true - } - - for _, packet := range written { - if !seen[packet] { - t.Errorf("%s not received", packet) - } - } - for _, packet := range injected { - if !seen[packet] { - t.Errorf("%s not received", packet) - } - } -} - -// mustHexDecode is like hex.DecodeString, but panics on error -// and ignores whitespace in s. -func mustHexDecode(s string) []byte { - return must.Get(hex.DecodeString(strings.Map(func(r rune) rune { - if unicode.IsSpace(r) { - return -1 - } - return r - }, s))) -} - -func TestFilter(t *testing.T) { - - chtun, tun := newChannelTUN(t.Logf, true) - defer tun.Close() - - // Reset the metrics before test. These are global - // so the different tests might have affected them. - tun.metrics.inboundDroppedPacketsTotal.ResetAllForTest() - tun.metrics.outboundDroppedPacketsTotal.ResetAllForTest() - - type direction int - - const ( - in direction = iota - out - ) - - tests := []struct { - name string - dir direction - drop bool - data []byte - }{ - {"short_in", in, true, []byte("\x45xxx")}, - {"short_out", out, true, []byte("\x45xxx")}, - {"ip97_out", out, false, mustHexDecode("4500 0019 d186 4000 4061 751d 644a 4603 6449 e549 6865 6c6c 6f")}, - {"bad_port_in", in, true, udp4("5.6.7.8", "1.2.3.4", 22, 22)}, - {"bad_port_out", out, false, udp4("1.2.3.4", "5.6.7.8", 22, 22)}, - {"bad_ip_in", in, true, udp4("8.1.1.1", "1.2.3.4", 89, 89)}, - {"bad_ip_out", out, false, udp4("1.2.3.4", "8.1.1.1", 98, 98)}, - {"good_packet_in", in, false, udp4("5.6.7.8", "1.2.3.4", 89, 89)}, - {"good_packet_out", out, false, udp4("1.2.3.4", "5.6.7.8", 98, 98)}, - } - - // A reader on the other end of the tun. - go func() { - var recvbuf []byte - for { - select { - case <-tun.closed: - return - case recvbuf = <-chtun.Inbound: - // continue - } - for _, tt := range tests { - if tt.drop && bytes.Equal(recvbuf, tt.data) { - t.Errorf("did not drop %s", tt.name) - } - } - } - }() - - var buf [MaxPacketSize]byte - stats := connstats.NewStatistics(0, 0, nil) - defer stats.Shutdown(context.Background()) - tun.SetStatistics(stats) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var n int - var err error - var filtered bool - sizes := make([]int, 1) - - tunStats, _ := stats.TestExtract() - if len(tunStats) > 0 { - t.Errorf("connstats.Statistics.Extract = %v, want {}", stats) - } - - if tt.dir == in { - // Use the side effect of updating the last - // activity atomic to determine whether the - // data was actually filtered. - // If it stays zero, nothing made it through - // to the wrapped TUN. - tun.lastActivityAtomic.StoreAtomic(0) - _, err = tun.Write([][]byte{tt.data}, 0) - filtered = tun.lastActivityAtomic.LoadAtomic() == 0 - } else { - chtun.Outbound <- tt.data - n, err = tun.Read([][]byte{buf[:]}, sizes, 0) - // In the read direction, errors are fatal, so we return n = 0 instead. - filtered = (n == 0) - } - - if err != nil { - t.Errorf("got err %v; want nil", err) - } - - if filtered { - if !tt.drop { - t.Errorf("got drop; want accept") - } - } else { - if tt.drop { - t.Errorf("got accept; want drop") - } - } - - got, _ := stats.TestExtract() - want := map[netlogtype.Connection]netlogtype.Counts{} - var wasUDP bool - if !tt.drop { - var p packet.Parsed - p.Decode(tt.data) - wasUDP = p.IPProto == ipproto.UDP - switch tt.dir { - case in: - conn := netlogtype.Connection{Proto: ipproto.UDP, Src: p.Dst, Dst: p.Src} - want[conn] = netlogtype.Counts{RxPackets: 1, RxBytes: uint64(len(tt.data))} - case out: - conn := netlogtype.Connection{Proto: ipproto.UDP, Src: p.Src, Dst: p.Dst} - want[conn] = netlogtype.Counts{TxPackets: 1, TxBytes: uint64(len(tt.data))} - } - } - if wasUDP { - if diff := cmp.Diff(got, want, cmpopts.EquateEmpty()); diff != "" { - t.Errorf("stats.TestExtract (-got +want):\n%s", diff) - } - } - }) - } - - var metricInboundDroppedPacketsACL, metricInboundDroppedPacketsErr, metricOutboundDroppedPacketsACL int64 - if m, ok := tun.metrics.inboundDroppedPacketsTotal.Get(usermetric.DropLabels{Reason: usermetric.ReasonACL}).(*expvar.Int); ok { - metricInboundDroppedPacketsACL = m.Value() - } - if m, ok := tun.metrics.inboundDroppedPacketsTotal.Get(usermetric.DropLabels{Reason: usermetric.ReasonError}).(*expvar.Int); ok { - metricInboundDroppedPacketsErr = m.Value() - } - if m, ok := tun.metrics.outboundDroppedPacketsTotal.Get(usermetric.DropLabels{Reason: usermetric.ReasonACL}).(*expvar.Int); ok { - metricOutboundDroppedPacketsACL = m.Value() - } - - assertMetricPackets(t, "inACL", 3, metricInboundDroppedPacketsACL) - assertMetricPackets(t, "inError", 0, metricInboundDroppedPacketsErr) - assertMetricPackets(t, "outACL", 1, metricOutboundDroppedPacketsACL) -} - -func assertMetricPackets(t *testing.T, metricName string, want, got int64) { - t.Helper() - if want != got { - t.Errorf("%s got unexpected value, got %d, want %d", metricName, got, want) - } -} - -func TestAllocs(t *testing.T) { - ftun, tun := newFakeTUN(t.Logf, false) - defer tun.Close() - - buf := [][]byte{{0x00}} - err := tstest.MinAllocsPerRun(t, 0, func() { - _, err := ftun.Write(buf, 0) - if err != nil { - t.Errorf("write: error: %v", err) - return - } - }) - - if err != nil { - t.Error(err) - } -} - -func TestClose(t *testing.T) { - ftun, tun := newFakeTUN(t.Logf, false) - - data := [][]byte{udp4("1.2.3.4", "5.6.7.8", 98, 98)} - _, err := ftun.Write(data, 0) - if err != nil { - t.Error(err) - } - - tun.Close() - _, err = ftun.Write(data, 0) - if err == nil { - t.Error("Expected error from ftun.Write() after Close()") - } -} - -func BenchmarkWrite(b *testing.B) { - b.ReportAllocs() - ftun, tun := newFakeTUN(b.Logf, true) - defer tun.Close() - - packet := [][]byte{udp4("5.6.7.8", "1.2.3.4", 89, 89)} - for range b.N { - _, err := ftun.Write(packet, 0) - if err != nil { - b.Errorf("err = %v; want nil", err) - } - } -} - -func TestAtomic64Alignment(t *testing.T) { - off := unsafe.Offsetof(Wrapper{}.lastActivityAtomic) - if off%8 != 0 { - t.Errorf("offset %v not 8-byte aligned", off) - } - - c := new(Wrapper) - c.lastActivityAtomic.StoreAtomic(mono.Now()) -} - -func TestPeerAPIBypass(t *testing.T) { - reg := new(usermetric.Registry) - wrapperWithPeerAPI := &Wrapper{ - PeerAPIPort: func(ip netip.Addr) (port uint16, ok bool) { - if ip == netip.MustParseAddr("100.64.1.2") { - return 60000, true - } - return - }, - metrics: registerMetrics(reg), - } - - tests := []struct { - name string - w *Wrapper - filter *filter.Filter - pkt []byte - want filter.Response - }{ - { - name: "reject_nil_filter", - w: &Wrapper{ - PeerAPIPort: func(netip.Addr) (port uint16, ok bool) { - return 60000, true - }, - metrics: registerMetrics(reg), - }, - pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60000), - want: filter.Drop, - }, - { - name: "reject_with_filter", - w: &Wrapper{ - metrics: registerMetrics(reg), - }, - filter: filter.NewAllowNone(logger.Discard, new(netipx.IPSet)), - pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60000), - want: filter.Drop, - }, - { - name: "peerapi_bypass_filter", - w: wrapperWithPeerAPI, - filter: filter.NewAllowNone(logger.Discard, new(netipx.IPSet)), - pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60000), - want: filter.Accept, - }, - { - name: "peerapi_dont_bypass_filter_wrong_port", - w: wrapperWithPeerAPI, - filter: filter.NewAllowNone(logger.Discard, new(netipx.IPSet)), - pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60001), - want: filter.Drop, - }, - { - name: "peerapi_dont_bypass_filter_wrong_dst_ip", - w: wrapperWithPeerAPI, - filter: filter.NewAllowNone(logger.Discard, new(netipx.IPSet)), - pkt: tcp4syn("1.2.3.4", "100.64.1.3", 1234, 60000), - want: filter.Drop, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := new(packet.Parsed) - p.Decode(tt.pkt) - tt.w.SetFilter(tt.filter) - tt.w.disableTSMPRejected = true - tt.w.logf = t.Logf - if got, _ := tt.w.filterPacketInboundFromWireGuard(p, nil, nil, nil); got != tt.want { - t.Errorf("got = %v; want %v", got, tt.want) - } - }) - } -} - -// Issue 1526: drop disco frames from ourselves. -func TestFilterDiscoLoop(t *testing.T) { - var memLog tstest.MemLogger - discoPub := key.DiscoPublicFromRaw32(mem.B([]byte{1: 1, 2: 2, 31: 0})) - tw := &Wrapper{logf: memLog.Logf, limitedLogf: memLog.Logf} - tw.SetDiscoKey(discoPub) - uh := packet.UDP4Header{ - IP4Header: packet.IP4Header{ - IPProto: ipproto.UDP, - Src: netaddr.IPv4(1, 2, 3, 4), - Dst: netaddr.IPv4(5, 6, 7, 8), - }, - SrcPort: 9, - DstPort: 10, - } - discobs := discoPub.Raw32() - discoPayload := fmt.Sprintf("%s%s%s", disco.Magic, discobs[:], [disco.NonceLen]byte{}) - pkt := make([]byte, uh.Len()+len(discoPayload)) - uh.Marshal(pkt) - copy(pkt[uh.Len():], discoPayload) - - p := new(packet.Parsed) - p.Decode(pkt) - got, _ := tw.filterPacketInboundFromWireGuard(p, nil, nil, nil) - if got != filter.DropSilently { - t.Errorf("got %v; want DropSilently", got) - } - if got, want := memLog.String(), "[unexpected] received self disco in packet over tstun; dropping\n"; got != want { - t.Errorf("log output mismatch\n got: %q\nwant: %q\n", got, want) - } - - memLog.Reset() - pp := new(packet.Parsed) - pp.Decode(pkt) - got, _ = tw.filterPacketOutboundToWireGuard(pp, nil, nil) - if got != filter.DropSilently { - t.Errorf("got %v; want DropSilently", got) - } - if got, want := memLog.String(), "[unexpected] received self disco out packet over tstun; dropping\n"; got != want { - t.Errorf("log output mismatch\n got: %q\nwant: %q\n", got, want) - } -} - -// TODO(andrew-d): refactor this test to no longer use addrFam, after #11945 -// removed it in peerConfigFromWGConfig -func TestPeerCfg_NAT(t *testing.T) { - node := func(ip, masqIP netip.Addr, otherAllowedIPs ...netip.Prefix) wgcfg.Peer { - p := wgcfg.Peer{ - PublicKey: key.NewNode().Public(), - AllowedIPs: []netip.Prefix{ - netip.PrefixFrom(ip, ip.BitLen()), - }, - } - if masqIP.Is4() { - p.V4MasqAddr = ptr.To(masqIP) - } else { - p.V6MasqAddr = ptr.To(masqIP) - } - p.AllowedIPs = append(p.AllowedIPs, otherAllowedIPs...) - return p - } - test := func(addrFam ipproto.Version) { - var ( - noIP netip.Addr - - selfNativeIP = netip.MustParseAddr("100.64.0.1") - selfEIP1 = netip.MustParseAddr("100.64.1.1") - selfEIP2 = netip.MustParseAddr("100.64.1.2") - selfAddrs = []netip.Prefix{netip.PrefixFrom(selfNativeIP, selfNativeIP.BitLen())} - - peer1IP = netip.MustParseAddr("100.64.0.2") - peer2IP = netip.MustParseAddr("100.64.0.3") - - subnet = netip.MustParsePrefix("192.168.0.0/24") - subnetIP = netip.MustParseAddr("192.168.0.1") - - exitRoute = netip.MustParsePrefix("0.0.0.0/0") - publicIP = netip.MustParseAddr("8.8.8.8") - ) - if addrFam == ipproto.Version6 { - selfNativeIP = netip.MustParseAddr("fd7a:115c:a1e0::a") - selfEIP1 = netip.MustParseAddr("fd7a:115c:a1e0::1a") - selfEIP2 = netip.MustParseAddr("fd7a:115c:a1e0::1b") - selfAddrs = []netip.Prefix{netip.PrefixFrom(selfNativeIP, selfNativeIP.BitLen())} - - peer1IP = netip.MustParseAddr("fd7a:115c:a1e0::b") - peer2IP = netip.MustParseAddr("fd7a:115c:a1e0::c") - - subnet = netip.MustParsePrefix("2001:db8::/32") - subnetIP = netip.MustParseAddr("2001:db8::FFFF") - - exitRoute = netip.MustParsePrefix("::/0") - publicIP = netip.MustParseAddr("2001:4860:4860::8888") - } - - type dnatTest struct { - src netip.Addr - dst netip.Addr - want netip.Addr // new destination after DNAT - } - - tests := []struct { - name string - wcfg *wgcfg.Config - snatMap map[netip.Addr]netip.Addr // dst -> src - dnat []dnatTest - }{ - { - name: "no-cfg", - wcfg: nil, - snatMap: map[netip.Addr]netip.Addr{ - peer1IP: selfNativeIP, - peer2IP: selfNativeIP, - subnetIP: selfNativeIP, - }, - dnat: []dnatTest{ - {selfNativeIP, selfNativeIP, selfNativeIP}, - {peer1IP, selfEIP1, selfEIP1}, - {peer2IP, selfEIP2, selfEIP2}, - }, - }, - { - name: "single-peer-requires-nat", - wcfg: &wgcfg.Config{ - Addresses: selfAddrs, - Peers: []wgcfg.Peer{ - node(peer1IP, noIP), - node(peer2IP, selfEIP2), - }, - }, - snatMap: map[netip.Addr]netip.Addr{ - peer1IP: selfNativeIP, - peer2IP: selfEIP2, - subnetIP: selfNativeIP, - }, - dnat: []dnatTest{ - {selfNativeIP, selfNativeIP, selfNativeIP}, - {peer1IP, selfEIP1, selfEIP1}, - {peer2IP, selfEIP2, selfNativeIP}, // NATed - {peer2IP, subnetIP, subnetIP}, - }, - }, - { - name: "multiple-peers-require-nat", - wcfg: &wgcfg.Config{ - Addresses: selfAddrs, - Peers: []wgcfg.Peer{ - node(peer1IP, selfEIP1), - node(peer2IP, selfEIP2), - }, - }, - snatMap: map[netip.Addr]netip.Addr{ - peer1IP: selfEIP1, - peer2IP: selfEIP2, - subnetIP: selfNativeIP, - }, - dnat: []dnatTest{ - {selfNativeIP, selfNativeIP, selfNativeIP}, - {peer1IP, selfEIP1, selfNativeIP}, - {peer2IP, selfEIP2, selfNativeIP}, - {peer2IP, subnetIP, subnetIP}, - }, - }, - { - name: "multiple-peers-require-nat-with-subnet", - wcfg: &wgcfg.Config{ - Addresses: selfAddrs, - Peers: []wgcfg.Peer{ - node(peer1IP, selfEIP1), - node(peer2IP, selfEIP2, subnet), - }, - }, - snatMap: map[netip.Addr]netip.Addr{ - peer1IP: selfEIP1, - peer2IP: selfEIP2, - subnetIP: selfEIP2, - }, - dnat: []dnatTest{ - {selfNativeIP, selfNativeIP, selfNativeIP}, - {peer1IP, selfEIP1, selfNativeIP}, - {peer2IP, selfEIP2, selfNativeIP}, - {peer2IP, subnetIP, subnetIP}, - }, - }, - { - name: "multiple-peers-require-nat-with-default-route", - wcfg: &wgcfg.Config{ - Addresses: selfAddrs, - Peers: []wgcfg.Peer{ - node(peer1IP, selfEIP1), - node(peer2IP, selfEIP2, exitRoute), - }, - }, - snatMap: map[netip.Addr]netip.Addr{ - peer1IP: selfEIP1, - peer2IP: selfEIP2, - publicIP: selfEIP2, - }, - dnat: []dnatTest{ - {selfNativeIP, selfNativeIP, selfNativeIP}, - {peer1IP, selfEIP1, selfNativeIP}, - {peer2IP, selfEIP2, selfNativeIP}, - {peer2IP, subnetIP, subnetIP}, - }, - }, - { - name: "no-nat", - wcfg: &wgcfg.Config{ - Addresses: selfAddrs, - Peers: []wgcfg.Peer{ - node(peer1IP, noIP), - node(peer2IP, noIP), - }, - }, - snatMap: map[netip.Addr]netip.Addr{ - peer1IP: selfNativeIP, - peer2IP: selfNativeIP, - subnetIP: selfNativeIP, - }, - dnat: []dnatTest{ - {selfNativeIP, selfNativeIP, selfNativeIP}, - {peer1IP, selfEIP1, selfEIP1}, - {peer2IP, selfEIP2, selfEIP2}, - {peer2IP, subnetIP, subnetIP}, - }, - }, - { - name: "exit-node-require-nat-peer-doesnt", - wcfg: &wgcfg.Config{ - Addresses: selfAddrs, - Peers: []wgcfg.Peer{ - node(peer1IP, noIP), - node(peer2IP, selfEIP2, exitRoute), - }, - }, - snatMap: map[netip.Addr]netip.Addr{ - peer1IP: selfNativeIP, - peer2IP: selfEIP2, - publicIP: selfEIP2, - }, - dnat: []dnatTest{ - {selfNativeIP, selfNativeIP, selfNativeIP}, - {peer2IP, selfEIP2, selfNativeIP}, - {peer2IP, subnetIP, subnetIP}, - }, - }, - } - - for _, tc := range tests { - t.Run(fmt.Sprintf("%v/%v", addrFam, tc.name), func(t *testing.T) { - pcfg := peerConfigTableFromWGConfig(tc.wcfg) - for peer, want := range tc.snatMap { - if got := pcfg.selectSrcIP(selfNativeIP, peer); got != want { - t.Errorf("selectSrcIP[%v]: got %v; want %v", peer, got, want) - } - } - for i, dt := range tc.dnat { - if got := pcfg.mapDstIP(dt.src, dt.dst); got != dt.want { - t.Errorf("dnat[%d]: mapDstIP[%v, %v]: got %v; want %v", i, dt.src, dt.dst, got, dt.want) - } - } - if t.Failed() { - t.Logf("%v", pcfg) - } - }) - } - } - test(ipproto.Version4) - test(ipproto.Version6) -} - -// TestCaptureHook verifies that the Wrapper.captureHook callback is called -// with the correct parameters when various packet operations are performed. -func TestCaptureHook(t *testing.T) { - type captureRecord struct { - path capture.Path - now time.Time - pkt []byte - meta packet.CaptureMeta - } - - var captured []captureRecord - hook := func(path capture.Path, now time.Time, pkt []byte, meta packet.CaptureMeta) { - captured = append(captured, captureRecord{ - path: path, - now: now, - pkt: pkt, - meta: meta, - }) - } - - now := time.Unix(1682085856, 0) - - _, w := newFakeTUN(t.Logf, true) - w.timeNow = func() time.Time { - return now - } - w.InstallCaptureHook(hook) - defer w.Close() - - // Loop reading and discarding packets; this ensures that we don't have - // packets stuck in vectorOutbound - go func() { - var ( - buf [MaxPacketSize]byte - sizes = make([]int, 1) - ) - for { - _, err := w.Read([][]byte{buf[:]}, sizes, 0) - if err != nil { - return - } - } - }() - - // Do operations that should result in a packet being captured. - w.Write([][]byte{ - []byte("Write1"), - []byte("Write2"), - }, 0) - packetBuf := stack.NewPacketBuffer(stack.PacketBufferOptions{ - Payload: buffer.MakeWithData([]byte("InjectInboundPacketBuffer")), - }) - buffs := make([][]byte, 1) - buffs[0] = make([]byte, PacketStartOffset+packetBuf.Size()) - sizes := make([]int, 1) - w.InjectInboundPacketBuffer(packetBuf, buffs, sizes) - - packetBuf = stack.NewPacketBuffer(stack.PacketBufferOptions{ - Payload: buffer.MakeWithData([]byte("InjectOutboundPacketBuffer")), - }) - w.InjectOutboundPacketBuffer(packetBuf) - - // TODO: test Read - // TODO: determine if we want InjectOutbound to log - - // Assert that the right packets are captured. - want := []captureRecord{ - { - path: capture.FromPeer, - pkt: []byte("Write1"), - }, - { - path: capture.FromPeer, - pkt: []byte("Write2"), - }, - { - path: capture.SynthesizedToLocal, - pkt: []byte("InjectInboundPacketBuffer"), - }, - { - path: capture.SynthesizedToPeer, - pkt: []byte("InjectOutboundPacketBuffer"), - }, - } - for i := range len(want) { - want[i].now = now - } - if !reflect.DeepEqual(captured, want) { - t.Errorf("mismatch between captured and expected packets\ngot: %+v\nwant: %+v", - captured, want) - } -} diff --git a/packages/deb/deb_test.go b/packages/deb/deb_test.go deleted file mode 100644 index 1a25f67ad4875..0000000000000 --- a/packages/deb/deb_test.go +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package deb - -import ( - "bytes" - "crypto/md5" - "crypto/sha1" - "crypto/sha256" - "encoding/hex" - "fmt" - "hash" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/goreleaser/nfpm/v2" - _ "github.com/goreleaser/nfpm/v2/deb" -) - -func TestDebInfo(t *testing.T) { - tests := []struct { - name string - in []byte - want *Info - wantErr bool - }{ - { - name: "simple", - in: mkTestDeb("1.2.3", "amd64"), - want: &Info{ - Version: "1.2.3", - Arch: "amd64", - Control: mkControl( - "Package", "tailscale", - "Version", "1.2.3", - "Section", "net", - "Priority", "extra", - "Architecture", "amd64", - "Maintainer", "Tail Scalar", - "Installed-Size", "0", - "Description", "test package"), - }, - }, - { - name: "arm64", - in: mkTestDeb("1.2.3", "arm64"), - want: &Info{ - Version: "1.2.3", - Arch: "arm64", - Control: mkControl( - "Package", "tailscale", - "Version", "1.2.3", - "Section", "net", - "Priority", "extra", - "Architecture", "arm64", - "Maintainer", "Tail Scalar", - "Installed-Size", "0", - "Description", "test package"), - }, - }, - { - name: "unstable", - in: mkTestDeb("1.7.25", "amd64"), - want: &Info{ - Version: "1.7.25", - Arch: "amd64", - Control: mkControl( - "Package", "tailscale", - "Version", "1.7.25", - "Section", "net", - "Priority", "extra", - "Architecture", "amd64", - "Maintainer", "Tail Scalar", - "Installed-Size", "0", - "Description", "test package"), - }, - }, - - // These truncation tests assume the structure of a .deb - // package, which is as follows: - // magic: 8 bytes - // file header: 60 bytes, before each file blob - // - // The first file in a .deb ar is "debian-binary", which is 4 - // bytes long and consists of "2.0\n". - // The second file is control.tar.gz, which is what we care - // about introspecting for metadata. - // The final file is data.tar.gz, which we don't care about. - // - // The first file in control.tar.gz is the "control" file we - // want to read for metadata. - { - name: "truncated_ar_magic", - in: mkTestDeb("1.7.25", "amd64")[:4], - wantErr: true, - }, - { - name: "truncated_ar_header", - in: mkTestDeb("1.7.25", "amd64")[:30], - wantErr: true, - }, - { - name: "missing_control_tgz", - // Truncate right after the "debian-binary" file, which - // makes the file a valid 1-file archive that's missing - // control.tar.gz. - in: mkTestDeb("1.7.25", "amd64")[:72], - wantErr: true, - }, - { - name: "truncated_tgz", - in: mkTestDeb("1.7.25", "amd64")[:172], - wantErr: true, - }, - } - - for _, test := range tests { - // mkTestDeb returns non-deterministic output due to - // timestamps embedded in the package file, so compute the - // wanted hashes on the fly here. - if test.want != nil { - test.want.MD5 = mkHash(test.in, md5.New) - test.want.SHA1 = mkHash(test.in, sha1.New) - test.want.SHA256 = mkHash(test.in, sha256.New) - } - - t.Run(test.name, func(t *testing.T) { - b := bytes.NewBuffer(test.in) - got, err := Read(b) - if err != nil { - if test.wantErr { - t.Logf("got expected error: %v", err) - return - } - t.Fatalf("reading deb info: %v", err) - } - if diff := diff(got, test.want); diff != "" { - t.Fatalf("parsed info diff (-got+want):\n%s", diff) - } - }) - } -} - -func diff(got, want any) string { - matchField := func(name string) func(p cmp.Path) bool { - return func(p cmp.Path) bool { - if len(p) != 3 { - return false - } - return p[2].String() == "."+name - } - } - toLines := cmp.Transformer("lines", func(b []byte) []string { return strings.Split(string(b), "\n") }) - toHex := cmp.Transformer("hex", func(b []byte) string { return hex.EncodeToString(b) }) - return cmp.Diff(got, want, - cmp.FilterPath(matchField("Control"), toLines), - cmp.FilterPath(matchField("MD5"), toHex), - cmp.FilterPath(matchField("SHA1"), toHex), - cmp.FilterPath(matchField("SHA256"), toHex)) -} - -func mkTestDeb(version, arch string) []byte { - info := nfpm.WithDefaults(&nfpm.Info{ - Name: "tailscale", - Description: "test package", - Arch: arch, - Platform: "linux", - Version: version, - Section: "net", - Priority: "extra", - Maintainer: "Tail Scalar", - }) - - pkg, err := nfpm.Get("deb") - if err != nil { - panic(fmt.Sprintf("getting deb packager: %v", err)) - } - - var b bytes.Buffer - if err := pkg.Package(info, &b); err != nil { - panic(fmt.Sprintf("creating deb package: %v", err)) - } - - return b.Bytes() -} - -func mkControl(fs ...string) []byte { - if len(fs)%2 != 0 { - panic("odd number of control file fields") - } - var b bytes.Buffer - for i := 0; i < len(fs); i = i + 2 { - k, v := fs[i], fs[i+1] - fmt.Fprintf(&b, "%s: %s\n", k, v) - } - return bytes.TrimSpace(b.Bytes()) -} - -func mkHash(b []byte, hasher func() hash.Hash) []byte { - h := hasher() - h.Write(b) - return h.Sum(nil) -} diff --git a/paths/migrate.go b/paths/migrate.go index 3a23ecca34fdc..2335cd6d6dc0f 100644 --- a/paths/migrate.go +++ b/paths/migrate.go @@ -7,7 +7,7 @@ import ( "os" "path/filepath" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // TryConfigFileMigration carefully copies the contents of oldFile to diff --git a/paths/paths.go b/paths/paths.go index 28c3be02a9c86..dcd29cfb57333 100644 --- a/paths/paths.go +++ b/paths/paths.go @@ -10,8 +10,8 @@ import ( "path/filepath" "runtime" - "tailscale.com/syncs" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/version/distro" ) // AppSharedDir is a string set by the iOS or Android app on start diff --git a/paths/paths_unix.go b/paths/paths_unix.go index 6a2b28733a93b..3df1dc13e8d15 100644 --- a/paths/paths_unix.go +++ b/paths/paths_unix.go @@ -11,8 +11,8 @@ import ( "path/filepath" "runtime" + "github.com/sagernet/tailscale/version/distro" "golang.org/x/sys/unix" - "tailscale.com/version/distro" ) func init() { diff --git a/paths/paths_windows.go b/paths/paths_windows.go index 4705400655212..6772e117aa0dd 100644 --- a/paths/paths_windows.go +++ b/paths/paths_windows.go @@ -8,8 +8,8 @@ import ( "path/filepath" "strings" + "github.com/sagernet/tailscale/util/winutil" "golang.org/x/sys/windows" - "tailscale.com/util/winutil" ) func init() { diff --git a/portlist/clean_test.go b/portlist/clean_test.go deleted file mode 100644 index 5a1e34405eed0..0000000000000 --- a/portlist/clean_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portlist - -import "testing" - -func TestArgvSubject(t *testing.T) { - tests := []struct { - in []string - want string - }{ - { - in: nil, - want: "", - }, - { - in: []string{"/usr/bin/sshd"}, - want: "sshd", - }, - { - in: []string{"/bin/mono"}, - want: "mono", - }, - { - in: []string{"/nix/store/x2cw2xjw98zdysf56bdlfzsr7cyxv0jf-mono-5.20.1.27/bin/mono", "/bin/exampleProgram.exe"}, - want: "exampleProgram", - }, - { - in: []string{"/bin/mono", "/sbin/exampleProgram.bin"}, - want: "exampleProgram.bin", - }, - { - in: []string{"/usr/bin/sshd_config [listener] 1 of 10-100 startups"}, - want: "sshd_config", - }, - { - in: []string{"/usr/bin/sshd [listener] 0 of 10-100 startups"}, - want: "sshd", - }, - { - in: []string{"/opt/aws/bin/eic_run_authorized_keys %u %f -o AuthorizedKeysCommandUser ec2-instance-connect [listener] 0 of 10-100 startups"}, - want: "eic_run_authorized_keys", - }, - { - in: []string{"/usr/bin/nginx worker"}, - want: "nginx", - }, - } - - for _, test := range tests { - got := argvSubject(test.in...) - if got != test.want { - t.Errorf("argvSubject(%v) = %q, want %q", test.in, got, test.want) - } - } -} diff --git a/portlist/netstat_test.go b/portlist/netstat_test.go deleted file mode 100644 index 023b75b794426..0000000000000 --- a/portlist/netstat_test.go +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build darwin && !ios - -package portlist - -import ( - "bufio" - "encoding/json" - "fmt" - "strings" - "testing" - - "go4.org/mem" -) - -func TestParsePort(t *testing.T) { - type InOut struct { - in string - expect int - } - tests := []InOut{ - {"1.2.3.4:5678", 5678}, - {"0.0.0.0.999", 999}, - {"1.2.3.4:*", 0}, - {"5.5.5.5:0", 0}, - {"[1::2]:5", 5}, - {"[1::2].5", 5}, - {"gibberish", -1}, - } - - for _, io := range tests { - got := parsePort(mem.S(io.in)) - if got != io.expect { - t.Fatalf("input:%#v expect:%v got:%v\n", io.in, io.expect, got) - } - } -} - -const netstatOutput = ` -// macOS -tcp4 0 0 *.23 *.* LISTEN -tcp6 0 0 *.24 *.* LISTEN -tcp4 0 0 *.8185 *.* LISTEN -tcp4 0 0 127.0.0.1.8186 *.* LISTEN -tcp6 0 0 ::1.8187 *.* LISTEN -tcp4 0 0 127.1.2.3.8188 *.* LISTEN - -udp6 0 0 *.106 *.* -udp4 0 0 *.104 *.* -udp46 0 0 *.146 *.* -` - -func TestParsePortsNetstat(t *testing.T) { - for _, loopBack := range [...]bool{false, true} { - t.Run(fmt.Sprintf("loopback_%v", loopBack), func(t *testing.T) { - want := List{ - {"tcp", 23, "", 0}, - {"tcp", 24, "", 0}, - {"udp", 104, "", 0}, - {"udp", 106, "", 0}, - {"udp", 146, "", 0}, - {"tcp", 8185, "", 0}, // but not 8186, 8187, 8188 on localhost, when loopback is false - } - if loopBack { - want = append(want, - Port{"tcp", 8186, "", 0}, - Port{"tcp", 8187, "", 0}, - Port{"tcp", 8188, "", 0}, - ) - } - pl, err := appendParsePortsNetstat(nil, bufio.NewReader(strings.NewReader(netstatOutput)), loopBack) - if err != nil { - t.Fatal(err) - } - pl = sortAndDedup(pl) - jgot, _ := json.MarshalIndent(pl, "", "\t") - jwant, _ := json.MarshalIndent(want, "", "\t") - if len(pl) != len(want) { - t.Fatalf("Got:\n%s\n\nWant:\n%s\n", jgot, jwant) - } - for i := range pl { - if pl[i] != want[i] { - t.Errorf("row#%d\n got: %+v\n\nwant: %+v\n", - i, pl[i], want[i]) - t.Fatalf("Got:\n%s\n\nWant:\n%s\n", jgot, jwant) - } - } - }) - } -} diff --git a/portlist/poller.go b/portlist/poller.go index 423bad3be33ba..82ed34a72a559 100644 --- a/portlist/poller.go +++ b/portlist/poller.go @@ -9,18 +9,14 @@ package portlist import ( "errors" "fmt" - "runtime" "slices" "sync" "time" - - "tailscale.com/envknob" ) var ( - newOSImpl func(includeLocalhost bool) osImpl // if non-nil, constructs a new osImpl. - pollInterval = 5 * time.Second // default; changed by some OS-specific init funcs - debugDisablePortlist = envknob.RegisterBool("TS_DEBUG_DISABLE_PORTLIST") + newOSImpl func(includeLocalhost bool) osImpl // if non-nil, constructs a new osImpl. + pollInterval = 5 * time.Second // default; changed by some OS-specific init funcs ) // PollInterval is the recommended OS-specific interval @@ -76,14 +72,7 @@ func (p *Poller) setPrev(pl List) { // init initializes the Poller by ensuring it has an underlying // OS implementation and is not turned off by envknob. func (p *Poller) init() { - switch { - case debugDisablePortlist(): - p.initErr = errors.New("portlist disabled by envknob") - case newOSImpl == nil: - p.initErr = errors.New("portlist poller not implemented on " + runtime.GOOS) - default: - p.os = newOSImpl(p.IncludeLocalhost) - } + p.initErr = errors.New("portlist disabled by sing-box") } // Close closes the Poller. diff --git a/portlist/portlist_linux.go b/portlist/portlist_linux.go index 94f843746c29d..d98ea661f9821 100644 --- a/portlist/portlist_linux.go +++ b/portlist/portlist_linux.go @@ -19,10 +19,10 @@ import ( "time" "unsafe" + "github.com/sagernet/tailscale/util/dirwalk" + "github.com/sagernet/tailscale/util/mak" "go4.org/mem" "golang.org/x/sys/unix" - "tailscale.com/util/dirwalk" - "tailscale.com/util/mak" ) func init() { diff --git a/portlist/portlist_linux_test.go b/portlist/portlist_linux_test.go deleted file mode 100644 index 24635fae26577..0000000000000 --- a/portlist/portlist_linux_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portlist - -import ( - "bufio" - "bytes" - "io" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestFieldIndex(t *testing.T) { - tests := []struct { - in string - field int - want int - }{ - {"foo", 0, 0}, - {" foo", 0, 2}, - {"foo bar", 1, 5}, - {" foo bar", 1, 6}, - {" foo bar", 2, -1}, - {" foo bar ", 2, -1}, - {" foo bar x", 2, 10}, - {" 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 34062 1 0000000000000000 100 0 0 10 0", - 2, 19}, - } - for _, tt := range tests { - if got := fieldIndex([]byte(tt.in), tt.field); got != tt.want { - t.Errorf("fieldIndex(%q, %v) = %v; want %v", tt.in, tt.field, got, tt.want) - } - } -} - -func TestParsePorts(t *testing.T) { - tests := []struct { - name string - in string - file string - want map[string]*portMeta - }{ - { - name: "empty", - in: "header line (ignored)\n", - want: map[string]*portMeta{}, - }, - { - name: "ipv4", - file: "tcp", - in: `header line - 0: 0100007F:0277 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 22303 1 0000000000000000 100 0 0 10 0 - 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 34062 1 0000000000000000 100 0 0 10 0 - 2: 5501A8C0:ADD4 B25E9536:01BB 01 00000000:00000000 02:00000B2B 00000000 1000 0 155276677 2 0000000000000000 22 4 30 10 -1 -`, - want: map[string]*portMeta{ - "socket:[34062]": { - port: Port{Proto: "tcp", Port: 22}, - }, - }, - }, - { - name: "ipv6", - file: "tcp6", - in: ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode - 0: 00000000000000000000000001000000:0277 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 35720 1 0000000000000000 100 0 0 10 0 - 1: 00000000000000000000000000000000:1F91 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 142240557 1 0000000000000000 100 0 0 10 0 - 2: 00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 34064 1 0000000000000000 100 0 0 10 0 - 3: 69050120005716BC64906EBE009ECD4D:D506 0047062600000000000000006E171268:01BB 01 00000000:00000000 02:0000009E 00000000 1000 0 151042856 2 0000000000000000 21 4 28 10 -1 -`, - want: map[string]*portMeta{ - "socket:[142240557]": { - port: Port{Proto: "tcp", Port: 8081}, - }, - "socket:[34064]": { - port: Port{Proto: "tcp", Port: 22}, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf := bytes.NewBufferString(tt.in) - r := bufio.NewReader(buf) - file := "tcp" - if tt.file != "" { - file = tt.file - } - li := newLinuxImplBase(false) - err := li.parseProcNetFile(r, file) - if err != nil { - t.Fatal(err) - } - for _, pm := range tt.want { - pm.keep = true - pm.needsProcName = true - } - if diff := cmp.Diff(li.known, tt.want, cmp.AllowUnexported(Port{}), cmp.AllowUnexported(portMeta{})); diff != "" { - t.Errorf("unexpected parsed ports (-got+want):\n%s", diff) - } - }) - } -} - -func BenchmarkParsePorts(b *testing.B) { - b.ReportAllocs() - - var contents bytes.Buffer - contents.WriteString(` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode - 0: 00000000000000000000000001000000:0277 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 35720 1 0000000000000000 100 0 0 10 0 - 1: 00000000000000000000000000000000:1F91 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 142240557 1 0000000000000000 100 0 0 10 0 - 2: 00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 34064 1 0000000000000000 100 0 0 10 0 -`) - for range 50000 { - contents.WriteString(" 3: 69050120005716BC64906EBE009ECD4D:D506 0047062600000000000000006E171268:01BB 01 00000000:00000000 02:0000009E 00000000 1000 0 151042856 2 0000000000000000 21 4 28 10 -1\n") - } - - li := newLinuxImplBase(false) - - r := bytes.NewReader(contents.Bytes()) - br := bufio.NewReader(&contents) - b.ResetTimer() - for range b.N { - r.Seek(0, io.SeekStart) - br.Reset(r) - err := li.parseProcNetFile(br, "tcp6") - if err != nil { - b.Fatal(err) - } - if len(li.known) != 2 { - b.Fatalf("wrong results; want 2 parsed got %d", len(li.known)) - } - } -} - -func BenchmarkFindProcessNames(b *testing.B) { - b.ReportAllocs() - li := &linuxImpl{} - need := map[string]*portMeta{ - "something-we'll-never-find": new(portMeta), - } - for range b.N { - if err := li.findProcessNames(need); err != nil { - b.Fatal(err) - } - } -} diff --git a/portlist/portlist_test.go b/portlist/portlist_test.go deleted file mode 100644 index 34277fdbaba91..0000000000000 --- a/portlist/portlist_test.go +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package portlist - -import ( - "net" - "testing" - - "tailscale.com/tstest" -) - -func TestGetList(t *testing.T) { - tstest.ResourceCheck(t) - - var p Poller - pl, _, err := p.Poll() - if err != nil { - t.Fatal(err) - } - for i, p := range pl { - t.Logf("[%d] %+v", i, p) - } - t.Logf("As String: %s", List(pl)) -} - -func TestIgnoreLocallyBoundPorts(t *testing.T) { - tstest.ResourceCheck(t) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Skipf("failed to bind: %v", err) - } - defer ln.Close() - ta := ln.Addr().(*net.TCPAddr) - port := ta.Port - var p Poller - pl, _, err := p.Poll() - if err != nil { - t.Fatal(err) - } - for _, p := range pl { - if p.Proto == "tcp" && int(p.Port) == port { - t.Fatal("didn't expect to find test's localhost ephemeral port") - } - } -} - -func TestPoller(t *testing.T) { - var p Poller - p.IncludeLocalhost = true - get := func(t *testing.T) []Port { - t.Helper() - s, _, err := p.Poll() - if err != nil { - t.Fatal(err) - } - return s - } - - p1 := get(t) - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Skipf("failed to bind: %v", err) - } - defer ln.Close() - port := uint16(ln.Addr().(*net.TCPAddr).Port) - containsPort := func(pl List) bool { - for _, p := range pl { - if p.Proto == "tcp" && p.Port == port { - return true - } - } - return false - } - if containsPort(p1) { - t.Error("unexpectedly found ephemeral port in p1, before it was opened", port) - } - p2 := get(t) - if !containsPort(p2) { - t.Error("didn't find ephemeral port in p2", port) - } - ln.Close() - p3 := get(t) - if containsPort(p3) { - t.Error("unexpectedly found ephemeral port in p3, after it was closed", port) - } -} - -func TestEqualLessThan(t *testing.T) { - tests := []struct { - name string - a, b Port - want bool - }{ - { - "Port a < b", - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - Port{Proto: "tcp", Port: 101, Process: "proc1"}, - true, - }, - { - "Port a > b", - Port{Proto: "tcp", Port: 101, Process: "proc1"}, - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - false, - }, - { - "Proto a < b", - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - Port{Proto: "udp", Port: 100, Process: "proc1"}, - true, - }, - { - "Proto a < b", - Port{Proto: "udp", Port: 100, Process: "proc1"}, - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - false, - }, - { - "Process a < b", - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - Port{Proto: "tcp", Port: 100, Process: "proc2"}, - true, - }, - { - "Process a > b", - Port{Proto: "tcp", Port: 100, Process: "proc2"}, - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - false, - }, - { - "Port evaluated first", - Port{Proto: "udp", Port: 100, Process: "proc2"}, - Port{Proto: "tcp", Port: 101, Process: "proc1"}, - true, - }, - { - "Proto evaluated second", - Port{Proto: "tcp", Port: 100, Process: "proc2"}, - Port{Proto: "udp", Port: 100, Process: "proc1"}, - true, - }, - { - "Process evaluated fourth", - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - Port{Proto: "tcp", Port: 100, Process: "proc2"}, - true, - }, - { - "equal", - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - Port{Proto: "tcp", Port: 100, Process: "proc1"}, - false, - }, - } - - for _, tt := range tests { - got := tt.a.lessThan(&tt.b) - if got != tt.want { - t.Errorf("%s: Equal = %v; want %v", tt.name, got, tt.want) - } - lessBack := tt.b.lessThan(&tt.a) - if got && lessBack { - t.Errorf("%s: both a and b report being less than each other", tt.name) - } - wantEqual := !got && !lessBack - gotEqual := tt.a.equal(&tt.b) - if gotEqual != wantEqual { - t.Errorf("%s: equal = %v; want %v", tt.name, gotEqual, wantEqual) - } - } -} - -func TestClose(t *testing.T) { - var p Poller - err := p.Close() - if err != nil { - t.Fatal(err) - } - p = Poller{} - _, _, err = p.Poll() - if err != nil { - t.Skipf("skipping due to poll error: %v", err) - } - err = p.Close() - if err != nil { - t.Fatal(err) - } -} - -func BenchmarkGetList(b *testing.B) { - benchmarkGetList(b, false) -} - -func BenchmarkGetListIncremental(b *testing.B) { - benchmarkGetList(b, true) -} - -func benchmarkGetList(b *testing.B, incremental bool) { - b.ReportAllocs() - var p Poller - p.init() - if p.initErr != nil { - b.Skip(p.initErr) - } - b.Cleanup(func() { p.Close() }) - for range b.N { - pl, err := p.getList() - if err != nil { - b.Fatal(err) - } - if incremental { - p.prev = pl - } - } -} diff --git a/portlist/portlist_windows.go b/portlist/portlist_windows.go index f449973599247..d87d4650d461d 100644 --- a/portlist/portlist_windows.go +++ b/portlist/portlist_windows.go @@ -6,7 +6,7 @@ package portlist import ( "time" - "tailscale.com/net/netstat" + "github.com/sagernet/tailscale/net/netstat" ) func init() { diff --git a/posture/hwaddr.go b/posture/hwaddr.go index dd0b6d8be77ce..97c01d9119edd 100644 --- a/posture/hwaddr.go +++ b/posture/hwaddr.go @@ -7,7 +7,7 @@ import ( "net/netip" "slices" - "tailscale.com/net/netmon" + "github.com/sagernet/tailscale/net/netmon" ) // GetHardwareAddrs returns the hardware addresses of all non-loopback diff --git a/posture/serialnumber_ios.go b/posture/serialnumber_ios.go index 55d0e438b54d5..f3498f3bc896b 100644 --- a/posture/serialnumber_ios.go +++ b/posture/serialnumber_ios.go @@ -6,8 +6,8 @@ package posture import ( "fmt" - "tailscale.com/types/logger" - "tailscale.com/util/syspolicy" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/syspolicy" ) // GetSerialNumbers returns the serial number of the iOS/tvOS device as reported by an diff --git a/posture/serialnumber_macos.go b/posture/serialnumber_macos.go index 48355d31393ee..b9167f0aa0ee4 100644 --- a/posture/serialnumber_macos.go +++ b/posture/serialnumber_macos.go @@ -54,11 +54,12 @@ package posture // return serialNumberBuf; // } import "C" + import ( "fmt" "strings" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // GetSerialNumber returns the platform serial sumber as reported by IOKit. diff --git a/posture/serialnumber_macos_test.go b/posture/serialnumber_macos_test.go deleted file mode 100644 index 9f0ce1c6a76d6..0000000000000 --- a/posture/serialnumber_macos_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build cgo && darwin && !ios - -package posture - -import ( - "fmt" - "testing" - - "tailscale.com/types/logger" - "tailscale.com/util/cibuild" -) - -func TestGetSerialNumberMac(t *testing.T) { - // Do not run this test on CI, it can only be ran on macOS - // and we currently only use Linux runners. - if cibuild.On() { - t.Skip() - } - - sns, err := GetSerialNumbers(logger.Discard) - if err != nil { - t.Fatalf("failed to get serial number: %s", err) - } - - if len(sns) != 1 { - t.Errorf("expected list of one serial number, got %v", sns) - } - - if len(sns[0]) <= 0 { - t.Errorf("expected a serial number with more than zero characters, got %s", sns[0]) - } - - fmt.Printf("serials: %v\n", sns) -} diff --git a/posture/serialnumber_notmacos.go b/posture/serialnumber_notmacos.go index 8b91738b04bfa..f252015a500c7 100644 --- a/posture/serialnumber_notmacos.go +++ b/posture/serialnumber_notmacos.go @@ -12,7 +12,7 @@ import ( "strings" "github.com/digitalocean/go-smbios/smbios" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // getByteFromSmbiosStructure retrieves a 8-bit unsigned integer at the given specOffset. diff --git a/posture/serialnumber_notmacos_test.go b/posture/serialnumber_notmacos_test.go deleted file mode 100644 index f2a15e0373caf..0000000000000 --- a/posture/serialnumber_notmacos_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Build on Windows, Linux and *BSD - -//go:build windows || (linux && !android) || freebsd || openbsd || dragonfly || netbsd - -package posture - -import ( - "fmt" - "testing" - - "tailscale.com/types/logger" -) - -func TestGetSerialNumberNotMac(t *testing.T) { - // This test is intentionally skipped as it will - // require root on Linux to get access to the serials. - // The test case is intended for local testing. - // Comment out skip for local testing. - t.Skip() - - sns, err := GetSerialNumbers(logger.Discard) - if err != nil { - t.Fatalf("failed to get serial number: %s", err) - } - - if len(sns) == 0 { - t.Fatalf("expected at least one serial number, got %v", sns) - } - - if len(sns[0]) <= 0 { - t.Errorf("expected a serial number with more than zero characters, got %s", sns[0]) - } - - fmt.Printf("serials: %v\n", sns) -} diff --git a/posture/serialnumber_stub.go b/posture/serialnumber_stub.go index cdabf03e5a417..22b4dae12245b 100644 --- a/posture/serialnumber_stub.go +++ b/posture/serialnumber_stub.go @@ -14,7 +14,7 @@ package posture import ( "errors" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // GetSerialNumber returns client machine serial number(s). diff --git a/posture/serialnumber_test.go b/posture/serialnumber_test.go deleted file mode 100644 index fac4392fab7d3..0000000000000 --- a/posture/serialnumber_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package posture - -import ( - "testing" - - "tailscale.com/types/logger" -) - -func TestGetSerialNumber(t *testing.T) { - // ensure GetSerialNumbers is implemented - // or covered by a stub on a given platform. - _, _ = GetSerialNumbers(logger.Discard) -} diff --git a/prober/derp.go b/prober/derp.go index b1ebc590d4f98..4249814c42b98 100644 --- a/prober/derp.go +++ b/prober/derp.go @@ -21,15 +21,15 @@ import ( "time" "github.com/prometheus/client_golang/prometheus" - "tailscale.com/client/tailscale" - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/net/netmon" - "tailscale.com/net/stun" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/client/tailscale" + "github.com/sagernet/tailscale/derp" + "github.com/sagernet/tailscale/derp/derphttp" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" ) // derpProber dynamically manages several probes for each DERP server diff --git a/prober/derp_test.go b/prober/derp_test.go deleted file mode 100644 index c084803e94f6a..0000000000000 --- a/prober/derp_test.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package prober - -import ( - "context" - "crypto/sha256" - "crypto/tls" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "testing" - "time" - - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/net/netmon" - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -func TestDerpProber(t *testing.T) { - dm := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 0: { - RegionID: 0, - RegionCode: "zero", - Nodes: []*tailcfg.DERPNode{ - { - Name: "n1", - RegionID: 0, - HostName: "derpn1.tailscale.test", - IPv4: "1.1.1.1", - IPv6: "::1", - }, - { - Name: "n2", - RegionID: 0, - HostName: "derpn2.tailscale.test", - IPv4: "1.1.1.1", - IPv6: "::1", - }, - }, - }, - 1: { - RegionID: 1, - RegionCode: "one", - Nodes: []*tailcfg.DERPNode{ - { - Name: "n3", - RegionID: 0, - HostName: "derpn3.tailscale.test", - IPv4: "1.1.1.1", - IPv6: "::1", - }, - }, - }, - }, - } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp, err := json.Marshal(dm) - if err != nil { - t.Fatal(err) - } - w.Write(resp) - })) - defer srv.Close() - - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker) - dp := &derpProber{ - p: p, - derpMapURL: srv.URL, - tlsInterval: time.Second, - tlsProbeFn: func(_ string) ProbeClass { return FuncProbe(func(context.Context) error { return nil }) }, - udpInterval: time.Second, - udpProbeFn: func(_ string, _ int) ProbeClass { return FuncProbe(func(context.Context) error { return nil }) }, - meshInterval: time.Second, - meshProbeFn: func(_, _ string) ProbeClass { return FuncProbe(func(context.Context) error { return nil }) }, - nodes: make(map[string]*tailcfg.DERPNode), - probes: make(map[string]*Probe), - regionCode: "zero", - } - if err := dp.probeMapFn(context.Background()); err != nil { - t.Errorf("unexpected probeMapFn() error: %s", err) - } - if len(dp.nodes) != 2 || dp.nodes["n1"] == nil || dp.nodes["n2"] == nil { - t.Errorf("unexpected nodes: %+v", dp.nodes) - } - // Probes expected for two nodes: - // - 3 regular probes per node (TLS, UDPv4, UDPv6) - // - 4 mesh probes (N1->N2, N1->N1, N2->N1, N2->N2) - if len(dp.probes) != 10 { - t.Errorf("unexpected probes: %+v", dp.probes) - } - - // Add one more node and check that probes got created. - dm.Regions[0].Nodes = append(dm.Regions[0].Nodes, &tailcfg.DERPNode{ - Name: "n4", - RegionID: 0, - HostName: "derpn4.tailscale.test", - IPv4: "1.1.1.1", - IPv6: "::1", - }) - if err := dp.probeMapFn(context.Background()); err != nil { - t.Errorf("unexpected probeMapFn() error: %s", err) - } - if len(dp.nodes) != 3 { - t.Errorf("unexpected nodes: %+v", dp.nodes) - } - // 9 regular probes + 9 mesh probes - if len(dp.probes) != 18 { - t.Errorf("unexpected probes: %+v", dp.probes) - } - - // Remove 2 nodes and check that probes have been destroyed. - dm.Regions[0].Nodes = dm.Regions[0].Nodes[:1] - if err := dp.probeMapFn(context.Background()); err != nil { - t.Errorf("unexpected probeMapFn() error: %s", err) - } - if len(dp.nodes) != 1 { - t.Errorf("unexpected nodes: %+v", dp.nodes) - } - // 3 regular probes + 1 mesh probe - if len(dp.probes) != 4 { - t.Errorf("unexpected probes: %+v", dp.probes) - } - - // Stop filtering regions. - dp.regionCode = "" - if err := dp.probeMapFn(context.Background()); err != nil { - t.Errorf("unexpected probeMapFn() error: %s", err) - } - if len(dp.nodes) != 2 { - t.Errorf("unexpected nodes: %+v", dp.nodes) - } - // 6 regular probes + 2 mesh probe - if len(dp.probes) != 8 { - t.Errorf("unexpected probes: %+v", dp.probes) - } -} - -func TestRunDerpProbeNodePair(t *testing.T) { - // os.Setenv("DERP_DEBUG_LOGS", "true") - serverPrivateKey := key.NewNode() - s := derp.NewServer(serverPrivateKey, t.Logf) - defer s.Close() - - httpsrv := &http.Server{ - TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), - Handler: derphttp.Handler(s), - } - ln, err := net.Listen("tcp4", "localhost:0") - if err != nil { - t.Fatal(err) - } - serverURL := "http://" + ln.Addr().String() - t.Logf("server URL: %s", serverURL) - - go func() { - if err := httpsrv.Serve(ln); err != nil { - if err == http.ErrServerClosed { - return - } - panic(err) - } - }() - newClient := func() *derphttp.Client { - c, err := derphttp.NewClient(key.NewNode(), serverURL, t.Logf, netmon.NewStatic()) - if err != nil { - t.Fatalf("NewClient: %v", err) - } - m, err := c.Recv() - if err != nil { - t.Fatalf("Recv: %v", err) - } - switch m.(type) { - case derp.ServerInfoMessage: - default: - t.Fatalf("unexpected first message type %T", m) - } - return c - } - - c1 := newClient() - defer c1.Close() - c2 := newClient() - defer c2.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - err = runDerpProbeNodePair(ctx, &tailcfg.DERPNode{Name: "c1"}, &tailcfg.DERPNode{Name: "c2"}, c1, c2, 100_000_000) - if err != nil { - t.Error(err) - } -} - -func Test_packetsForSize(t *testing.T) { - tests := []struct { - name string - size int - wantPackets int - wantUnique bool - }{ - {"small_unqiue", 8, 1, true}, - {"8k_unique", 8192, 1, true}, - {"full_size_packet", derp.MaxPacketSize, 1, true}, - {"larger_than_one", derp.MaxPacketSize + 1, 2, false}, - {"large", 500000, 8, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hashes := make(map[string]int) - for range 5 { - pkts := packetsForSize(int64(tt.size)) - if len(pkts) != tt.wantPackets { - t.Errorf("packetsForSize(%d) got %d packets, want %d", tt.size, len(pkts), tt.wantPackets) - } - var total int - hash := sha256.New() - for _, p := range pkts { - hash.Write(p) - total += len(p) - } - hashes[string(hash.Sum(nil))]++ - if total != tt.size { - t.Errorf("packetsForSize(%d) returned %d bytes total", tt.size, total) - } - } - unique := len(hashes) > 1 - if unique != tt.wantUnique { - t.Errorf("packetsForSize(%d) is unique=%v (returned %d different answers); want unique=%v", tt.size, unique, len(hashes), unique) - } - }) - } -} diff --git a/prober/dns.go b/prober/dns.go index 77e22ea3f89ba..7a1fbf37262bf 100644 --- a/prober/dns.go +++ b/prober/dns.go @@ -10,7 +10,7 @@ import ( "net/netip" "sync" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // ForEachAddrOpts contains options for ForEachAddr. The zero value for all diff --git a/prober/dns_example_test.go b/prober/dns_example_test.go deleted file mode 100644 index a8326fd721232..0000000000000 --- a/prober/dns_example_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package prober_test - -import ( - "context" - "flag" - "fmt" - "log" - "net" - "net/netip" - "os" - "os/signal" - "time" - - "tailscale.com/prober" - "tailscale.com/types/logger" -) - -const ( - every30s = 30 * time.Second -) - -var ( - hostname = flag.String("hostname", "tailscale.com", "hostname to probe") - oneshot = flag.Bool("oneshot", true, "run probes once and exit") - verbose = flag.Bool("verbose", false, "enable verbose logging") -) - -// This example demonstrates how to use ForEachAddr to create a TLS probe for -// each IP address in the DNS record of a given hostname. -func ExampleForEachAddr() { - flag.Parse() - - p := prober.New().WithSpread(true) - if *oneshot { - p = p.WithOnce(true) - } - - // This function is called every time we discover a new IP address to check. - makeTLSProbe := func(addr netip.Addr) []*prober.Probe { - pf := prober.TLSWithIP(*hostname, netip.AddrPortFrom(addr, 443)) - if *verbose { - logger := logger.WithPrefix(log.Printf, fmt.Sprintf("[tls %s]: ", addr)) - pf = probeLogWrapper(logger, pf) - } - - probe := p.Run(fmt.Sprintf("website/%s/tls", addr), every30s, nil, pf) - return []*prober.Probe{probe} - } - - // Determine whether to use IPv4 or IPv6 based on whether we can create - // an IPv6 listening socket on localhost. - sock, err := net.Listen("tcp", "[::1]:0") - supportsIPv6 := err == nil - if sock != nil { - sock.Close() - } - - networks := []string{"ip4"} - if supportsIPv6 { - networks = append(networks, "ip6") - } - - var vlogf logger.Logf = logger.Discard - if *verbose { - vlogf = log.Printf - } - - // This is the outer probe that resolves the hostname and creates a new - // TLS probe for each IP. - p.Run("website/dns", every30s, nil, prober.ForEachAddr(*hostname, makeTLSProbe, prober.ForEachAddrOpts{ - Logf: vlogf, - Networks: networks, - })) - - defer log.Printf("done") - - // Wait until all probes have run if we're running in oneshot mode. - if *oneshot { - p.Wait() - return - } - - // Otherwise, wait until we get a signal. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt) - <-sigCh -} - -func probeLogWrapper(logf logger.Logf, pc prober.ProbeClass) prober.ProbeClass { - return prober.ProbeClass{ - Probe: func(ctx context.Context) error { - logf("starting probe") - err := pc.Probe(ctx) - logf("probe finished with %v", err) - return err - }, - } -} diff --git a/prober/dns_test.go b/prober/dns_test.go deleted file mode 100644 index 1b6c31b554877..0000000000000 --- a/prober/dns_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package prober - -import ( - "context" - "fmt" - "net/netip" - "slices" - "sync" - "testing" - - "tailscale.com/syncs" -) - -func TestForEachAddr(t *testing.T) { - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker) - - opts := ForEachAddrOpts{ - Logf: t.Logf, - Networks: []string{"ip4", "ip6"}, - } - - var ( - addr4_1 = netip.MustParseAddr("76.76.21.21") - addr4_2 = netip.MustParseAddr("127.0.0.1") - - addr6_1 = netip.MustParseAddr("2600:9000:a602:b1e6:5b89:50a1:7cf7:67b8") - addr6_2 = netip.MustParseAddr("2600:9000:a51d:27c1:6748:d035:a989:fb3c") - ) - - var resolverAddrs4, resolverAddrs6 syncs.AtomicValue[[]netip.Addr] - resolverAddrs4.Store([]netip.Addr{addr4_1}) - resolverAddrs6.Store([]netip.Addr{addr6_1, addr6_2}) - - opts.LookupNetIP = func(_ context.Context, network string, _ string) ([]netip.Addr, error) { - if network == "ip4" { - return resolverAddrs4.Load(), nil - } else if network == "ip6" { - return resolverAddrs6.Load(), nil - } - return nil, fmt.Errorf("unknown network %q", network) - } - - var ( - mu sync.Mutex // protects following - registered []netip.Addr - ) - newProbe := func(addr netip.Addr) []*Probe { - // Called to register a new prober - t.Logf("called to register new probe for %v", addr) - - mu.Lock() - defer mu.Unlock() - registered = append(registered, addr) - - // Return a probe that does nothing; we don't care about what this does. - probe := p.Run(fmt.Sprintf("website/%s", addr), probeInterval, nil, FuncProbe(func(_ context.Context) error { - return nil - })) - return []*Probe{probe} - } - - fep := makeForEachAddr("tailscale.com", newProbe, opts) - - // Mimic a call from the prober; we do this ourselves instead of - // calling it via p.Run so we know that the probe has actually run. - ctx := context.Background() - if err := fep.run(ctx); err != nil { - t.Fatalf("run: %v", err) - } - - mu.Lock() - wantAddrs := []netip.Addr{addr4_1, addr6_1, addr6_2} - if !slices.Equal(registered, wantAddrs) { - t.Errorf("got registered addrs %v; want %v", registered, wantAddrs) - } - mu.Unlock() - - // Now, update our IP addresses to force the prober to close and - // re-create our probes. - resolverAddrs4.Store([]netip.Addr{addr4_2}) - resolverAddrs6.Store([]netip.Addr{addr6_2}) - - // Clear out our test data. - mu.Lock() - registered = nil - mu.Unlock() - - // Run our individual prober again manually (so we don't have to wait - // or coordinate with the created probers). - if err := fep.run(ctx); err != nil { - t.Fatalf("run: %v", err) - } - - // Ensure that we only registered our net-new address (addr4_2). - mu.Lock() - wantAddrs = []netip.Addr{addr4_2} - if !slices.Equal(registered, wantAddrs) { - t.Errorf("got registered addrs %v; want %v", registered, wantAddrs) - } - mu.Unlock() - - // Check that we don't have a probe for the addresses that we expect to - // have been removed (addr4_1 and addr6_1). - p.mu.Lock() - for _, addr := range []netip.Addr{addr4_1, addr6_1} { - _, ok := fep.probes[addr] - if ok { - t.Errorf("probe for %v still exists", addr) - } - } - p.mu.Unlock() -} diff --git a/prober/prober.go b/prober/prober.go index 2a43628bda908..b9eafd2325c46 100644 --- a/prober/prober.go +++ b/prober/prober.go @@ -20,7 +20,7 @@ import ( "time" "github.com/prometheus/client_golang/prometheus" - "tailscale.com/tsweb" + "github.com/sagernet/tailscale/tsweb" ) // recentHistSize is the number of recent probe results and latencies to keep diff --git a/prober/prober_test.go b/prober/prober_test.go deleted file mode 100644 index 742a914b24661..0000000000000 --- a/prober/prober_test.go +++ /dev/null @@ -1,656 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package prober - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http/httptest" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/prometheus/client_golang/prometheus/testutil" - "tailscale.com/tstest" - "tailscale.com/tsweb" -) - -const ( - probeInterval = 8 * time.Second // So expvars that are integer numbers of seconds change - halfProbeInterval = probeInterval / 2 - quarterProbeInterval = probeInterval / 4 - convergenceTimeout = time.Second - convergenceSleep = time.Millisecond - aFewMillis = 20 * time.Millisecond -) - -var epoch = time.Unix(0, 0) - -func TestProberTiming(t *testing.T) { - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker) - - invoked := make(chan struct{}, 1) - - notCalled := func() { - t.Helper() - select { - case <-invoked: - t.Fatal("probe was invoked earlier than expected") - default: - } - } - called := func() { - t.Helper() - select { - case <-invoked: - case <-time.After(2 * time.Second): - t.Fatal("probe wasn't invoked as expected") - } - } - - p.Run("test-probe", probeInterval, nil, FuncProbe(func(context.Context) error { - invoked <- struct{}{} - return nil - })) - - waitActiveProbes(t, p, clk, 1) - - called() - notCalled() - clk.Advance(probeInterval + halfProbeInterval) - called() - notCalled() - clk.Advance(quarterProbeInterval) - notCalled() - clk.Advance(probeInterval) - called() - notCalled() -} - -func TestProberTimingSpread(t *testing.T) { - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker).WithSpread(true) - - invoked := make(chan struct{}, 1) - - notCalled := func() { - t.Helper() - select { - case <-invoked: - t.Fatal("probe was invoked earlier than expected") - default: - } - } - called := func() { - t.Helper() - select { - case <-invoked: - case <-time.After(2 * time.Second): - t.Fatal("probe wasn't invoked as expected") - } - } - - probe := p.Run("test-spread-probe", probeInterval, nil, FuncProbe(func(context.Context) error { - invoked <- struct{}{} - return nil - })) - - waitActiveProbes(t, p, clk, 1) - - notCalled() - // Name of the probe (test-spread-probe) has been chosen to ensure that - // the initial delay is smaller than half of the probe interval. - clk.Advance(halfProbeInterval) - called() - notCalled() - - // We need to wait until the main (non-initial) ticker in Probe.loop is - // waiting, or we could race and advance the test clock between when - // the initial delay ticker completes and before the ticker for the - // main loop is created. In this race, we'd first advance the test - // clock, then the ticker would be registered, and the test would fail - // because that ticker would never be fired. - err := tstest.WaitFor(convergenceTimeout, func() error { - clk.Lock() - defer clk.Unlock() - for _, tick := range clk.tickers { - tick.Lock() - stopped, interval := tick.stopped, tick.interval - tick.Unlock() - - if stopped { - continue - } - // Test for the main loop, not the initialDelay - if interval == probe.interval { - return nil - } - } - - return fmt.Errorf("no ticker with interval %d found", probe.interval) - }) - if err != nil { - t.Fatal(err) - } - - clk.Advance(quarterProbeInterval) - notCalled() - clk.Advance(probeInterval) - called() - notCalled() -} - -func TestProberRun(t *testing.T) { - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker) - - var ( - mu sync.Mutex - cnt int - ) - - const startingProbes = 100 - var probes []*Probe - - for i := range startingProbes { - probes = append(probes, p.Run(fmt.Sprintf("probe%d", i), probeInterval, nil, FuncProbe(func(context.Context) error { - mu.Lock() - defer mu.Unlock() - cnt++ - return nil - }))) - } - - checkCnt := func(want int) { - t.Helper() - err := tstest.WaitFor(convergenceTimeout, func() error { - mu.Lock() - defer mu.Unlock() - if cnt == want { - cnt = 0 - return nil - } - return fmt.Errorf("wrong number of probe counter increments, got %d want %d", cnt, want) - }) - if err != nil { - t.Fatal(err) - } - } - - waitActiveProbes(t, p, clk, startingProbes) - checkCnt(startingProbes) - clk.Advance(probeInterval + halfProbeInterval) - checkCnt(startingProbes) - if c, err := testutil.GatherAndCount(p.metrics, "prober_result"); c != startingProbes || err != nil { - t.Fatalf("expected %d prober_result metrics; got %d (error %s)", startingProbes, c, err) - } - - keep := startingProbes / 2 - - for i := keep; i < startingProbes; i++ { - probes[i].Close() - } - waitActiveProbes(t, p, clk, keep) - - clk.Advance(probeInterval) - checkCnt(keep) - if c, err := testutil.GatherAndCount(p.metrics, "prober_result"); c != keep || err != nil { - t.Fatalf("expected %d prober_result metrics; got %d (error %s)", keep, c, err) - } -} - -func TestPrometheus(t *testing.T) { - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker).WithMetricNamespace("probe") - - var succeed atomic.Bool - p.Run("testprobe", probeInterval, map[string]string{"label": "value"}, FuncProbe(func(context.Context) error { - clk.Advance(aFewMillis) - if succeed.Load() { - return nil - } - return errors.New("failing, as instructed by test") - })) - - waitActiveProbes(t, p, clk, 1) - - err := tstest.WaitFor(convergenceTimeout, func() error { - want := fmt.Sprintf(` -# HELP probe_interval_secs Probe interval in seconds -# TYPE probe_interval_secs gauge -probe_interval_secs{class="",label="value",name="testprobe"} %f -# HELP probe_start_secs Latest probe start time (seconds since epoch) -# TYPE probe_start_secs gauge -probe_start_secs{class="",label="value",name="testprobe"} %d -# HELP probe_end_secs Latest probe end time (seconds since epoch) -# TYPE probe_end_secs gauge -probe_end_secs{class="",label="value",name="testprobe"} %d -# HELP probe_result Latest probe result (1 = success, 0 = failure) -# TYPE probe_result gauge -probe_result{class="",label="value",name="testprobe"} 0 -`, probeInterval.Seconds(), epoch.Unix(), epoch.Add(aFewMillis).Unix()) - return testutil.GatherAndCompare(p.metrics, strings.NewReader(want), - "probe_interval_secs", "probe_start_secs", "probe_end_secs", "probe_result") - }) - if err != nil { - t.Fatal(err) - } - - succeed.Store(true) - clk.Advance(probeInterval + halfProbeInterval) - - err = tstest.WaitFor(convergenceTimeout, func() error { - start := epoch.Add(probeInterval + halfProbeInterval) - end := start.Add(aFewMillis) - want := fmt.Sprintf(` -# HELP probe_interval_secs Probe interval in seconds -# TYPE probe_interval_secs gauge -probe_interval_secs{class="",label="value",name="testprobe"} %f -# HELP probe_start_secs Latest probe start time (seconds since epoch) -# TYPE probe_start_secs gauge -probe_start_secs{class="",label="value",name="testprobe"} %d -# HELP probe_end_secs Latest probe end time (seconds since epoch) -# TYPE probe_end_secs gauge -probe_end_secs{class="",label="value",name="testprobe"} %d -# HELP probe_latency_millis Latest probe latency (ms) -# TYPE probe_latency_millis gauge -probe_latency_millis{class="",label="value",name="testprobe"} %d -# HELP probe_result Latest probe result (1 = success, 0 = failure) -# TYPE probe_result gauge -probe_result{class="",label="value",name="testprobe"} 1 -`, probeInterval.Seconds(), start.Unix(), end.Unix(), aFewMillis.Milliseconds()) - return testutil.GatherAndCompare(p.metrics, strings.NewReader(want), - "probe_interval_secs", "probe_start_secs", "probe_end_secs", "probe_latency_millis", "probe_result") - }) - if err != nil { - t.Fatal(err) - } -} - -func TestOnceMode(t *testing.T) { - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker).WithOnce(true) - - p.Run("probe1", probeInterval, nil, FuncProbe(func(context.Context) error { return nil })) - p.Run("probe2", probeInterval, nil, FuncProbe(func(context.Context) error { return fmt.Errorf("error2") })) - p.Run("probe3", probeInterval, nil, FuncProbe(func(context.Context) error { - p.Run("probe4", probeInterval, nil, FuncProbe(func(context.Context) error { - return fmt.Errorf("error4") - })) - return nil - })) - - p.Wait() - wantCount := 4 - for _, metric := range []string{"prober_result", "prober_end_secs"} { - if c, err := testutil.GatherAndCount(p.metrics, metric); c != wantCount || err != nil { - t.Fatalf("expected %d %s metrics; got %d (error %s)", wantCount, metric, c, err) - } - } -} - -func TestProberProbeInfo(t *testing.T) { - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker).WithOnce(true) - - p.Run("probe1", probeInterval, nil, FuncProbe(func(context.Context) error { - clk.Advance(500 * time.Millisecond) - return nil - })) - p.Run("probe2", probeInterval, nil, FuncProbe(func(context.Context) error { return fmt.Errorf("error2") })) - p.Wait() - - info := p.ProbeInfo() - wantInfo := map[string]ProbeInfo{ - "probe1": { - Name: "probe1", - Interval: probeInterval, - Labels: map[string]string{"class": "", "name": "probe1"}, - Latency: 500 * time.Millisecond, - Result: true, - RecentResults: []bool{true}, - RecentLatencies: []time.Duration{500 * time.Millisecond}, - }, - "probe2": { - Name: "probe2", - Interval: probeInterval, - Labels: map[string]string{"class": "", "name": "probe2"}, - Error: "error2", - RecentResults: []bool{false}, - RecentLatencies: nil, // no latency for failed probes - }, - } - - if diff := cmp.Diff(wantInfo, info, cmpopts.IgnoreFields(ProbeInfo{}, "Start", "End")); diff != "" { - t.Fatalf("unexpected ProbeInfo (-want +got):\n%s", diff) - } -} - -func TestProbeInfoRecent(t *testing.T) { - type probeResult struct { - latency time.Duration - err error - } - tests := []struct { - name string - results []probeResult - wantProbeInfo ProbeInfo - wantRecentSuccessRatio float64 - wantRecentMedianLatency time.Duration - }{ - { - name: "no_runs", - wantProbeInfo: ProbeInfo{}, - wantRecentSuccessRatio: 0, - wantRecentMedianLatency: 0, - }, - { - name: "single_success", - results: []probeResult{{latency: 100 * time.Millisecond, err: nil}}, - wantProbeInfo: ProbeInfo{ - Latency: 100 * time.Millisecond, - Result: true, - RecentResults: []bool{true}, - RecentLatencies: []time.Duration{100 * time.Millisecond}, - }, - wantRecentSuccessRatio: 1, - wantRecentMedianLatency: 100 * time.Millisecond, - }, - { - name: "single_failure", - results: []probeResult{{latency: 100 * time.Millisecond, err: errors.New("error123")}}, - wantProbeInfo: ProbeInfo{ - Result: false, - RecentResults: []bool{false}, - RecentLatencies: nil, - Error: "error123", - }, - wantRecentSuccessRatio: 0, - wantRecentMedianLatency: 0, - }, - { - name: "recent_mix", - results: []probeResult{ - {latency: 10 * time.Millisecond, err: errors.New("error1")}, - {latency: 20 * time.Millisecond, err: nil}, - {latency: 30 * time.Millisecond, err: nil}, - {latency: 40 * time.Millisecond, err: errors.New("error4")}, - {latency: 50 * time.Millisecond, err: nil}, - {latency: 60 * time.Millisecond, err: nil}, - {latency: 70 * time.Millisecond, err: errors.New("error7")}, - {latency: 80 * time.Millisecond, err: nil}, - }, - wantProbeInfo: ProbeInfo{ - Result: true, - Latency: 80 * time.Millisecond, - RecentResults: []bool{false, true, true, false, true, true, false, true}, - RecentLatencies: []time.Duration{ - 20 * time.Millisecond, - 30 * time.Millisecond, - 50 * time.Millisecond, - 60 * time.Millisecond, - 80 * time.Millisecond, - }, - }, - wantRecentSuccessRatio: 0.625, - wantRecentMedianLatency: 50 * time.Millisecond, - }, - { - name: "only_last_10", - results: []probeResult{ - {latency: 10 * time.Millisecond, err: errors.New("old_error")}, - {latency: 20 * time.Millisecond, err: nil}, - {latency: 30 * time.Millisecond, err: nil}, - {latency: 40 * time.Millisecond, err: nil}, - {latency: 50 * time.Millisecond, err: nil}, - {latency: 60 * time.Millisecond, err: nil}, - {latency: 70 * time.Millisecond, err: nil}, - {latency: 80 * time.Millisecond, err: nil}, - {latency: 90 * time.Millisecond, err: nil}, - {latency: 100 * time.Millisecond, err: nil}, - {latency: 110 * time.Millisecond, err: nil}, - }, - wantProbeInfo: ProbeInfo{ - Result: true, - Latency: 110 * time.Millisecond, - RecentResults: []bool{true, true, true, true, true, true, true, true, true, true}, - RecentLatencies: []time.Duration{ - 20 * time.Millisecond, - 30 * time.Millisecond, - 40 * time.Millisecond, - 50 * time.Millisecond, - 60 * time.Millisecond, - 70 * time.Millisecond, - 80 * time.Millisecond, - 90 * time.Millisecond, - 100 * time.Millisecond, - 110 * time.Millisecond, - }, - }, - wantRecentSuccessRatio: 1, - wantRecentMedianLatency: 70 * time.Millisecond, - }, - } - - clk := newFakeTime() - p := newForTest(clk.Now, clk.NewTicker).WithOnce(true) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - probe := newProbe(p, "", probeInterval, nil, FuncProbe(func(context.Context) error { return nil })) - for _, r := range tt.results { - probe.recordStart() - clk.Advance(r.latency) - probe.recordEnd(r.err) - } - info := probe.probeInfoLocked() - if diff := cmp.Diff(tt.wantProbeInfo, info, cmpopts.IgnoreFields(ProbeInfo{}, "Start", "End", "Interval")); diff != "" { - t.Fatalf("unexpected ProbeInfo (-want +got):\n%s", diff) - } - if got := info.RecentSuccessRatio(); got != tt.wantRecentSuccessRatio { - t.Errorf("recentSuccessRatio() = %v, want %v", got, tt.wantRecentSuccessRatio) - } - if got := info.RecentMedianLatency(); got != tt.wantRecentMedianLatency { - t.Errorf("recentMedianLatency() = %v, want %v", got, tt.wantRecentMedianLatency) - } - }) - } -} - -func TestProberRunHandler(t *testing.T) { - clk := newFakeTime() - - tests := []struct { - name string - probeFunc func(context.Context) error - wantResponseCode int - wantJSONResponse RunHandlerResponse - wantPlaintextResponse string - }{ - { - name: "success", - probeFunc: func(context.Context) error { return nil }, - wantResponseCode: 200, - wantJSONResponse: RunHandlerResponse{ - ProbeInfo: ProbeInfo{ - Name: "success", - Interval: probeInterval, - Result: true, - RecentResults: []bool{true, true}, - }, - PreviousSuccessRatio: 1, - }, - wantPlaintextResponse: "Probe succeeded", - }, - { - name: "failure", - probeFunc: func(context.Context) error { return fmt.Errorf("error123") }, - wantResponseCode: 424, - wantJSONResponse: RunHandlerResponse{ - ProbeInfo: ProbeInfo{ - Name: "failure", - Interval: probeInterval, - Result: false, - Error: "error123", - RecentResults: []bool{false, false}, - }, - }, - wantPlaintextResponse: "Probe failed", - }, - } - - for _, tt := range tests { - for _, reqJSON := range []bool{true, false} { - t.Run(fmt.Sprintf("%s_json-%v", tt.name, reqJSON), func(t *testing.T) { - p := newForTest(clk.Now, clk.NewTicker).WithOnce(true) - probe := p.Run(tt.name, probeInterval, nil, FuncProbe(tt.probeFunc)) - defer probe.Close() - <-probe.stopped // wait for the first run. - - w := httptest.NewRecorder() - - req := httptest.NewRequest("GET", "/prober/run/?name="+tt.name, nil) - if reqJSON { - req.Header.Set("Accept", "application/json") - } - tsweb.StdHandler(tsweb.ReturnHandlerFunc(p.RunHandler), tsweb.HandlerOptions{}).ServeHTTP(w, req) - if w.Result().StatusCode != tt.wantResponseCode { - t.Errorf("unexpected response code: got %d, want %d", w.Code, tt.wantResponseCode) - } - - if reqJSON { - var gotJSON RunHandlerResponse - if err := json.Unmarshal(w.Body.Bytes(), &gotJSON); err != nil { - t.Fatalf("failed to unmarshal JSON response: %v; body: %s", err, w.Body.String()) - } - if diff := cmp.Diff(tt.wantJSONResponse, gotJSON, cmpopts.IgnoreFields(ProbeInfo{}, "Start", "End", "Labels", "RecentLatencies")); diff != "" { - t.Errorf("unexpected JSON response (-want +got):\n%s", diff) - } - } else { - body, _ := io.ReadAll(w.Result().Body) - if !strings.Contains(string(body), tt.wantPlaintextResponse) { - t.Errorf("unexpected response body: got %q, want to contain %q", body, tt.wantPlaintextResponse) - } - } - }) - } - } - -} - -type fakeTicker struct { - ch chan time.Time - interval time.Duration - - sync.Mutex - next time.Time - stopped bool -} - -func (t *fakeTicker) Chan() <-chan time.Time { - return t.ch -} - -func (t *fakeTicker) Stop() { - t.Lock() - defer t.Unlock() - t.stopped = true -} - -func (t *fakeTicker) fire(now time.Time) { - t.Lock() - defer t.Unlock() - // Slight deviation from the stdlib ticker: time.Ticker will - // adjust t.next to make up for missed ticks, whereas we tick on a - // fixed interval regardless of receiver behavior. In our case - // this is fine, since we're using the ticker as a wakeup - // mechanism and not a precise timekeeping system. - select { - case t.ch <- now: - default: - } - for now.After(t.next) { - t.next = t.next.Add(t.interval) - } -} - -type fakeTime struct { - sync.Mutex - *sync.Cond - curTime time.Time - tickers []*fakeTicker -} - -func newFakeTime() *fakeTime { - ret := &fakeTime{ - curTime: epoch, - } - ret.Cond = &sync.Cond{L: &ret.Mutex} - return ret -} - -func (t *fakeTime) Now() time.Time { - t.Lock() - defer t.Unlock() - ret := t.curTime - return ret -} - -func (t *fakeTime) NewTicker(d time.Duration) ticker { - t.Lock() - defer t.Unlock() - ret := &fakeTicker{ - ch: make(chan time.Time, 1), - interval: d, - next: t.curTime.Add(d), - } - t.tickers = append(t.tickers, ret) - t.Cond.Broadcast() - return ret -} - -func (t *fakeTime) Advance(d time.Duration) { - t.Lock() - defer t.Unlock() - t.curTime = t.curTime.Add(d) - for _, tick := range t.tickers { - if t.curTime.After(tick.next) { - tick.fire(t.curTime) - } - } -} - -func (t *fakeTime) activeTickers() (count int) { - t.Lock() - defer t.Unlock() - for _, tick := range t.tickers { - if !tick.stopped { - count += 1 - } - } - return -} - -func waitActiveProbes(t *testing.T, p *Prober, clk *fakeTime, want int) { - t.Helper() - err := tstest.WaitFor(convergenceTimeout, func() error { - if got := p.activeProbes(); got != want { - return fmt.Errorf("installed probe count is %d, want %d", got, want) - } - if got := clk.activeTickers(); got != want { - return fmt.Errorf("active ticker count is %d, want %d", got, want) - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} diff --git a/prober/status.go b/prober/status.go index aa9ef99d05d2c..7c2da49b1766c 100644 --- a/prober/status.go +++ b/prober/status.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "tailscale.com/tsweb" - "tailscale.com/util/mak" + "github.com/sagernet/tailscale/tsweb" + "github.com/sagernet/tailscale/util/mak" ) //go:embed status.html diff --git a/prober/tls.go b/prober/tls.go index 787df05c2c3a9..9176d798f7258 100644 --- a/prober/tls.go +++ b/prober/tls.go @@ -16,8 +16,8 @@ import ( "time" "github.com/pkg/errors" + "github.com/sagernet/tailscale/util/multierr" "golang.org/x/crypto/ocsp" - "tailscale.com/util/multierr" ) const expiresSoon = 7 * 24 * time.Hour // 7 days from now diff --git a/prober/tls_test.go b/prober/tls_test.go deleted file mode 100644 index 5bfb739db928d..0000000000000 --- a/prober/tls_test.go +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package prober - -import ( - "bytes" - "context" - "crypto" - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "net" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "golang.org/x/crypto/ocsp" -) - -var leafCert = x509.Certificate{ - SerialNumber: big.NewInt(10001), - Subject: pkix.Name{CommonName: "tlsprobe.test"}, - SignatureAlgorithm: x509.SHA256WithRSA, - PublicKeyAlgorithm: x509.RSA, - Version: 3, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - NotBefore: time.Now().Add(-5 * time.Minute), - NotAfter: time.Now().Add(60 * 24 * time.Hour), - SubjectKeyId: []byte{1, 2, 3}, - AuthorityKeyId: []byte{1, 2, 3, 4, 5}, // issuerCert below - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, - KeyUsage: x509.KeyUsageDigitalSignature, -} - -var issuerCertTpl = x509.Certificate{ - SerialNumber: big.NewInt(10002), - Subject: pkix.Name{CommonName: "tlsprobe.ca.test"}, - SignatureAlgorithm: x509.SHA256WithRSA, - PublicKeyAlgorithm: x509.RSA, - Version: 3, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - NotBefore: time.Now().Add(-5 * time.Minute), - NotAfter: time.Now().Add(60 * 24 * time.Hour), - SubjectKeyId: []byte{1, 2, 3, 4, 5}, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, - KeyUsage: x509.KeyUsageDigitalSignature, -} - -func simpleCert() (tls.Certificate, error) { - certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - return tls.Certificate{}, err - } - certPrivKeyPEM := new(bytes.Buffer) - pem.Encode(certPrivKeyPEM, &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), - }) - certBytes, err := x509.CreateCertificate(rand.Reader, &leafCert, &leafCert, &certPrivKey.PublicKey, certPrivKey) - if err != nil { - return tls.Certificate{}, err - } - certPEM := new(bytes.Buffer) - pem.Encode(certPEM, &pem.Block{ - Type: "CERTIFICATE", - Bytes: certBytes, - }) - return tls.X509KeyPair(certPEM.Bytes(), certPrivKeyPEM.Bytes()) -} - -func TestTLSConnection(t *testing.T) { - crt, err := simpleCert() - if err != nil { - t.Fatal(err) - } - srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - srv.TLS = &tls.Config{Certificates: []tls.Certificate{crt}} - srv.StartTLS() - defer srv.Close() - - err = probeTLS(context.Background(), "fail.example.com", srv.Listener.Addr().String()) - // The specific error message here is platform-specific ("certificate is not trusted" - // on macOS and "certificate signed by unknown authority" on Linux), so only check - // that it contains the word 'certificate'. - if err == nil || !strings.Contains(err.Error(), "certificate") { - t.Errorf("unexpected error: %q", err) - } -} - -func TestCertExpiration(t *testing.T) { - for _, tt := range []struct { - name string - cert func() *x509.Certificate - wantErr string - }{ - { - "cert not valid yet", - func() *x509.Certificate { - c := leafCert - c.NotBefore = time.Now().Add(time.Hour) - return &c - }, - "one of the certs has NotBefore in the future", - }, - { - "cert expiring soon", - func() *x509.Certificate { - c := leafCert - c.NotAfter = time.Now().Add(time.Hour) - return &c - }, - "one of the certs expires in", - }, - { - "valid duration but no OCSP", - func() *x509.Certificate { return &leafCert }, - "no OCSP server presented in leaf cert for CN=tlsprobe.test", - }, - } { - t.Run(tt.name, func(t *testing.T) { - cs := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{tt.cert()}} - err := validateConnState(context.Background(), cs) - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Errorf("unexpected error %q; want %q", err, tt.wantErr) - } - }) - } -} - -type ocspServer struct { - issuer *x509.Certificate - responderCert *x509.Certificate - template *ocsp.Response - priv crypto.Signer -} - -func (s *ocspServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if s.template == nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - resp, err := ocsp.CreateResponse(s.issuer, s.responderCert, *s.template, s.priv) - if err != nil { - panic(err) - } - w.Write(resp) -} - -func TestOCSP(t *testing.T) { - issuerKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - t.Fatal(err) - } - issuerBytes, err := x509.CreateCertificate(rand.Reader, &issuerCertTpl, &issuerCertTpl, &issuerKey.PublicKey, issuerKey) - if err != nil { - t.Fatal(err) - } - issuerCert, err := x509.ParseCertificate(issuerBytes) - if err != nil { - t.Fatal(err) - } - - responderKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - t.Fatal(err) - } - // issuer cert template re-used here, but with a different key - responderBytes, err := x509.CreateCertificate(rand.Reader, &issuerCertTpl, &issuerCertTpl, &responderKey.PublicKey, responderKey) - if err != nil { - t.Fatal(err) - } - responderCert, err := x509.ParseCertificate(responderBytes) - if err != nil { - t.Fatal(err) - } - - handler := &ocspServer{ - issuer: issuerCert, - responderCert: responderCert, - priv: issuerKey, - } - srv := httptest.NewUnstartedServer(handler) - srv.Start() - defer srv.Close() - - cert := leafCert - cert.OCSPServer = append(cert.OCSPServer, srv.URL) - key, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - t.Fatal(err) - } - certBytes, err := x509.CreateCertificate(rand.Reader, &cert, issuerCert, &key.PublicKey, issuerKey) - if err != nil { - t.Fatal(err) - } - parsed, err := x509.ParseCertificate(certBytes) - if err != nil { - t.Fatal(err) - } - - for _, tt := range []struct { - name string - resp *ocsp.Response - wantErr string - }{ - {"good response", &ocsp.Response{Status: ocsp.Good}, ""}, - {"unknown response", &ocsp.Response{Status: ocsp.Unknown}, "unknown OCSP verification status for CN=tlsprobe.test"}, - {"revoked response", &ocsp.Response{Status: ocsp.Revoked}, "cert for CN=tlsprobe.test has been revoked"}, - {"error 500 from ocsp", nil, "non-200 status code from OCSP"}, - } { - t.Run(tt.name, func(t *testing.T) { - handler.template = tt.resp - if handler.template != nil { - handler.template.SerialNumber = big.NewInt(1337) - } - cs := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{parsed, issuerCert}} - err := validateConnState(context.Background(), cs) - - if err == nil && tt.wantErr == "" { - return - } - - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Errorf("unexpected error %q; want %q", err, tt.wantErr) - } - }) - } -} diff --git a/proxymap/proxymap.go b/proxymap/proxymap.go index dfe6f2d586000..6ea69aa6e0fc1 100644 --- a/proxymap/proxymap.go +++ b/proxymap/proxymap.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "tailscale.com/util/mak" + "github.com/sagernet/tailscale/util/mak" ) // Mapper tracks which localhost ip:ports correspond to which remote Tailscale diff --git a/release/deb/debian.postinst.sh b/release/deb/debian.postinst.sh deleted file mode 100755 index 0ca3e2ebd232b..0000000000000 --- a/release/deb/debian.postinst.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then - deb-systemd-helper unmask 'tailscaled.service' >/dev/null || true - if deb-systemd-helper --quiet was-enabled 'tailscaled.service'; then - deb-systemd-helper enable 'tailscaled.service' >/dev/null || true - else - deb-systemd-helper update-state 'tailscaled.service' >/dev/null || true - fi - - if [ -d /run/systemd/system ]; then - systemctl --system daemon-reload >/dev/null || true - deb-systemd-invoke restart 'tailscaled.service' >/dev/null || true - fi -fi diff --git a/release/deb/debian.postrm.sh b/release/deb/debian.postrm.sh deleted file mode 100755 index f4dd4ed9cdc15..0000000000000 --- a/release/deb/debian.postrm.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -set -e -if [ -d /run/systemd/system ] ; then - systemctl --system daemon-reload >/dev/null || true -fi - -if [ -x "/usr/bin/deb-systemd-helper" ]; then - if [ "$1" = "remove" ]; then - deb-systemd-helper mask 'tailscaled.service' >/dev/null || true - fi - - if [ "$1" = "purge" ]; then - deb-systemd-helper purge 'tailscaled.service' >/dev/null || true - deb-systemd-helper unmask 'tailscaled.service' >/dev/null || true - rm -rf /var/lib/tailscale - fi -fi diff --git a/release/deb/debian.prerm.sh b/release/deb/debian.prerm.sh deleted file mode 100755 index 9be58ede4d963..0000000000000 --- a/release/deb/debian.prerm.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -set -e -if [ "$1" = "remove" ]; then - if [ -d /run/systemd/system ]; then - deb-systemd-invoke stop 'tailscaled.service' >/dev/null || true - fi -fi diff --git a/release/dist/cli/cli.go b/release/dist/cli/cli.go deleted file mode 100644 index 9b861ddd72dc6..0000000000000 --- a/release/dist/cli/cli.go +++ /dev/null @@ -1,345 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package cli provides the skeleton of a CLI for building release packages. -package cli - -import ( - "context" - "encoding/binary" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" - - "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/clientupdate/distsign" - "tailscale.com/release/dist" -) - -// CLI returns a CLI root command to build release packages. -// -// getTargets is a function that gets run in the Exec function of commands that -// need to know the target list. Its execution is deferred in this way to allow -// customization of command FlagSets with flags that influence the target list. -func CLI(getTargets func() ([]dist.Target, error)) *ffcli.Command { - return &ffcli.Command{ - Name: "dist", - ShortUsage: "dist [flags] [command flags]", - ShortHelp: "Build tailscale release packages for distribution", - LongHelp: `For help on subcommands, add --help after: "dist list --help".`, - Subcommands: []*ffcli.Command{ - { - Name: "list", - Exec: func(ctx context.Context, args []string) error { - targets, err := getTargets() - if err != nil { - return err - } - return runList(ctx, args, targets) - }, - ShortUsage: "dist list [target filters]", - ShortHelp: "List all available release targets.", - LongHelp: strings.TrimSpace(` - If filters are provided, only targets matching at least one filter are listed. - Filters can use glob patterns (* and ?). - `), - }, - { - Name: "build", - Exec: func(ctx context.Context, args []string) error { - targets, err := getTargets() - if err != nil { - return err - } - return runBuild(ctx, args, targets) - }, - ShortUsage: "dist build [target filters]", - ShortHelp: "Build release files", - FlagSet: (func() *flag.FlagSet { - fs := flag.NewFlagSet("build", flag.ExitOnError) - fs.StringVar(&buildArgs.manifest, "manifest", "", "manifest file to write") - fs.BoolVar(&buildArgs.verbose, "verbose", false, "verbose logging") - fs.StringVar(&buildArgs.webClientRoot, "web-client-root", "", "path to root of web client source to build") - return fs - })(), - LongHelp: strings.TrimSpace(` - If filters are provided, only targets matching at least one filter are built. - Filters can use glob patterns (* and ?). - `), - }, - { - Name: "gen-key", - Exec: func(ctx context.Context, args []string) error { - return runGenKey(ctx) - }, - ShortUsage: "dist gen-key", - ShortHelp: "Generate root or signing key pair", - FlagSet: (func() *flag.FlagSet { - fs := flag.NewFlagSet("gen-key", flag.ExitOnError) - fs.BoolVar(&genKeyArgs.root, "root", false, "generate a root key") - fs.BoolVar(&genKeyArgs.signing, "signing", false, "generate a signing key") - fs.StringVar(&genKeyArgs.privPath, "priv-path", "private-key.pem", "output path for the private key") - fs.StringVar(&genKeyArgs.pubPath, "pub-path", "public-key.pem", "output path for the public key") - return fs - })(), - }, - { - Name: "sign-key", - Exec: func(ctx context.Context, args []string) error { - return runSignKey(ctx) - }, - ShortUsage: "dist sign-key", - ShortHelp: "Sign signing keys with a root key", - FlagSet: (func() *flag.FlagSet { - fs := flag.NewFlagSet("sign-key", flag.ExitOnError) - fs.StringVar(&signKeyArgs.rootPrivPath, "root-priv-path", "root-private-key.pem", "path to the root private key to sign with") - fs.StringVar(&signKeyArgs.signPubPath, "sign-pub-path", "signing-public-keys.pem", "path to the signing public key bundle to sign; the bundle should include all active signing keys") - fs.StringVar(&signKeyArgs.sigPath, "sig-path", "signature.bin", "oputput path for the signature") - return fs - })(), - }, - { - Name: "verify-key-signature", - Exec: func(ctx context.Context, args []string) error { - return runVerifyKeySignature(ctx) - }, - ShortUsage: "dist verify-key-signature", - ShortHelp: "Verify a root signture of the signing keys' bundle", - FlagSet: (func() *flag.FlagSet { - fs := flag.NewFlagSet("verify-key-signature", flag.ExitOnError) - fs.StringVar(&verifyKeySignatureArgs.rootPubPath, "root-pub-path", "root-public-key.pem", "path to the root public key; this can be a bundle of multiple keys") - fs.StringVar(&verifyKeySignatureArgs.signPubPath, "sign-pub-path", "", "path to the signing public key bundle that was signed") - fs.StringVar(&verifyKeySignatureArgs.sigPath, "sig-path", "signature.bin", "path to the signature file") - return fs - })(), - }, - { - Name: "verify-package-signature", - Exec: func(ctx context.Context, args []string) error { - return runVerifyPackageSignature(ctx) - }, - ShortUsage: "dist verify-package-signature", - ShortHelp: "Verify a package signture using a signing key", - FlagSet: (func() *flag.FlagSet { - fs := flag.NewFlagSet("verify-package-signature", flag.ExitOnError) - fs.StringVar(&verifyPackageSignatureArgs.signPubPath, "sign-pub-path", "signing-public-key.pem", "path to the signing public key; this can be a bundle of multiple keys") - fs.StringVar(&verifyPackageSignatureArgs.packagePath, "package-path", "", "path to the package that was signed") - fs.StringVar(&verifyPackageSignatureArgs.sigPath, "sig-path", "signature.bin", "path to the signature file") - return fs - })(), - }, - }, - Exec: func(context.Context, []string) error { return flag.ErrHelp }, - } -} - -func runList(ctx context.Context, filters []string, targets []dist.Target) error { - if len(filters) == 0 { - filters = []string{"all"} - } - tgts, err := dist.FilterTargets(targets, filters) - if err != nil { - return err - } - for _, tgt := range tgts { - fmt.Println(tgt) - } - return nil -} - -var buildArgs struct { - manifest string - verbose bool - webClientRoot string -} - -func runBuild(ctx context.Context, filters []string, targets []dist.Target) error { - tgts, err := dist.FilterTargets(targets, filters) - if err != nil { - return err - } - if len(tgts) == 0 { - return errors.New("no targets matched (did you mean 'dist build all'?)") - } - - st := time.Now() - wd, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting working directory: %w", err) - } - b, err := dist.NewBuild(wd, filepath.Join(wd, "dist")) - if err != nil { - return fmt.Errorf("creating build context: %w", err) - } - defer b.Close() - b.Verbose = buildArgs.verbose - b.WebClientSource = buildArgs.webClientRoot - - out, err := b.Build(tgts) - if err != nil { - return fmt.Errorf("building targets: %w", err) - } - - if buildArgs.manifest != "" { - // Make the built paths relative to the manifest file. - manifest, err := filepath.Abs(buildArgs.manifest) - if err != nil { - return fmt.Errorf("getting absolute path of manifest: %w", err) - } - for i := range out { - if !filepath.IsAbs(out[i]) { - out[i] = filepath.Join(b.Out, out[i]) - } - rel, err := filepath.Rel(filepath.Dir(manifest), out[i]) - if err != nil { - return fmt.Errorf("making path relative: %w", err) - } - out[i] = rel - } - if err := os.WriteFile(manifest, []byte(strings.Join(out, "\n")), 0644); err != nil { - return fmt.Errorf("writing manifest: %w", err) - } - } - - fmt.Println("Done! Took", time.Since(st)) - return nil -} - -var genKeyArgs struct { - root bool - signing bool - privPath string - pubPath string -} - -func runGenKey(ctx context.Context) error { - var pub, priv []byte - var err error - switch { - case genKeyArgs.root && genKeyArgs.signing: - return errors.New("only one of --root or --signing can be set") - case !genKeyArgs.root && !genKeyArgs.signing: - return errors.New("set either --root or --signing") - case genKeyArgs.root: - priv, pub, err = distsign.GenerateRootKey() - case genKeyArgs.signing: - priv, pub, err = distsign.GenerateSigningKey() - } - if err != nil { - return err - } - if err := os.WriteFile(genKeyArgs.privPath, priv, 0400); err != nil { - return fmt.Errorf("failed writing private key: %w", err) - } - fmt.Println("wrote private key to", genKeyArgs.privPath) - if err := os.WriteFile(genKeyArgs.pubPath, pub, 0400); err != nil { - return fmt.Errorf("failed writing public key: %w", err) - } - fmt.Println("wrote public key to", genKeyArgs.pubPath) - return nil -} - -var signKeyArgs struct { - rootPrivPath string - signPubPath string - sigPath string -} - -func runSignKey(ctx context.Context) error { - rkRaw, err := os.ReadFile(signKeyArgs.rootPrivPath) - if err != nil { - return err - } - rk, err := distsign.ParseRootKey(rkRaw) - if err != nil { - return err - } - - bundle, err := os.ReadFile(signKeyArgs.signPubPath) - if err != nil { - return err - } - sig, err := rk.SignSigningKeys(bundle) - if err != nil { - return err - } - - if err := os.WriteFile(signKeyArgs.sigPath, sig, 0400); err != nil { - return fmt.Errorf("failed writing signature file: %w", err) - } - fmt.Println("wrote signature to", signKeyArgs.sigPath) - return nil -} - -var verifyKeySignatureArgs struct { - rootPubPath string - signPubPath string - sigPath string -} - -func runVerifyKeySignature(ctx context.Context) error { - args := verifyKeySignatureArgs - rootPubBundle, err := os.ReadFile(args.rootPubPath) - if err != nil { - return err - } - rootPubs, err := distsign.ParseRootKeyBundle(rootPubBundle) - if err != nil { - return fmt.Errorf("parsing %q: %w", args.rootPubPath, err) - } - signPubBundle, err := os.ReadFile(args.signPubPath) - if err != nil { - return err - } - sig, err := os.ReadFile(args.sigPath) - if err != nil { - return err - } - if !distsign.VerifyAny(rootPubs, signPubBundle, sig) { - return errors.New("signature not valid") - } - fmt.Println("signature ok") - return nil -} - -var verifyPackageSignatureArgs struct { - signPubPath string - packagePath string - sigPath string -} - -func runVerifyPackageSignature(ctx context.Context) error { - args := verifyPackageSignatureArgs - signPubBundle, err := os.ReadFile(args.signPubPath) - if err != nil { - return err - } - signPubs, err := distsign.ParseSigningKeyBundle(signPubBundle) - if err != nil { - return fmt.Errorf("parsing %q: %w", args.signPubPath, err) - } - pkg, err := os.Open(args.packagePath) - if err != nil { - return err - } - defer pkg.Close() - pkgHash := distsign.NewPackageHash() - if _, err := io.Copy(pkgHash, pkg); err != nil { - return fmt.Errorf("reading %q: %w", args.packagePath, err) - } - hash := binary.LittleEndian.AppendUint64(pkgHash.Sum(nil), uint64(pkgHash.Len())) - sig, err := os.ReadFile(args.sigPath) - if err != nil { - return err - } - if !distsign.VerifyAny(signPubs, hash, sig) { - return errors.New("signature not valid") - } - fmt.Println("signature ok") - return nil -} diff --git a/release/dist/dist.go b/release/dist/dist.go deleted file mode 100644 index 802d9041bab23..0000000000000 --- a/release/dist/dist.go +++ /dev/null @@ -1,414 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package dist is a release artifact builder library. -package dist - -import ( - "bytes" - "errors" - "fmt" - "io" - "log" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "sort" - "strings" - "sync" - "time" - - "tailscale.com/util/multierr" - "tailscale.com/version/mkversion" -) - -// A Target is something that can be build in a Build. -type Target interface { - String() string - Build(build *Build) ([]string, error) -} - -// Signer is pluggable signer for a Target. -type Signer func(io.Reader) ([]byte, error) - -// SignFile signs the file at filePath with s and writes the signature to -// sigPath. -func (s Signer) SignFile(filePath, sigPath string) error { - f, err := os.Open(filePath) - if err != nil { - return err - } - defer f.Close() - sig, err := s(f) - if err != nil { - return err - } - return os.WriteFile(sigPath, sig, 0644) -} - -// A Build is a build context for Targets. -type Build struct { - // Repo is a path to the root Go module for the build. - Repo string - // Out is where build artifacts are written. - Out string - // Verbose is whether to print all command output, rather than just failed - // commands. - Verbose bool - // WebClientSource is a path to the source for the web client. - // If non-empty, web client assets will be built. - WebClientSource string - - // Tmp is a temporary directory that gets deleted when the Builder is closed. - Tmp string - // Go is the path to the Go binary to use for building. - Go string - // Yarn is the path to the yarn binary to use for building the web client assets. - Yarn string - // Version is the version info of the build. - Version mkversion.VersionInfo - // Time is the timestamp of the build. - Time time.Time - - // once is a cache of function invocations that should run once per process - // (for example building a helper docker container) - once once - - extraMu sync.Mutex - extra map[any]any - - goBuilds Memoize[string] - // When running `dist build all` on a cold Go build cache, the fanout of - // gooses and goarches results in a very large number of compile processes, - // which bogs down the build machine. - // - // This throttles the number of concurrent `go build` invocations to the - // number of CPU cores, which empirically keeps the builder responsive - // without impacting overall build time. - goBuildLimit chan struct{} - - onCloseFuncs []func() error // funcs to be called when Builder is closed -} - -// NewBuild creates a new Build rooted at repo, and writing artifacts to out. -func NewBuild(repo, out string) (*Build, error) { - if err := os.MkdirAll(out, 0750); err != nil { - return nil, fmt.Errorf("creating out dir: %w", err) - } - tmp, err := os.MkdirTemp("", "dist-*") - if err != nil { - return nil, fmt.Errorf("creating tempdir: %w", err) - } - repo, err = findModRoot(repo) - if err != nil { - return nil, fmt.Errorf("finding module root: %w", err) - } - goTool, err := findTool(repo, "go") - if err != nil { - return nil, fmt.Errorf("finding go binary: %w", err) - } - yarnTool, err := findTool(repo, "yarn") - if err != nil { - return nil, fmt.Errorf("finding yarn binary: %w", err) - } - b := &Build{ - Repo: repo, - Tmp: tmp, - Out: out, - Go: goTool, - Yarn: yarnTool, - Version: mkversion.Info(), - Time: time.Now().UTC(), - extra: map[any]any{}, - goBuildLimit: make(chan struct{}, runtime.NumCPU()), - } - - return b, nil -} - -func (b *Build) AddOnCloseFunc(f func() error) { - b.onCloseFuncs = append(b.onCloseFuncs, f) -} - -// Close ends the build, cleans up temporary files, -// and runs any onCloseFuncs. -func (b *Build) Close() error { - var errs []error - errs = append(errs, os.RemoveAll(b.Tmp)) - for _, f := range b.onCloseFuncs { - errs = append(errs, f()) - } - return errors.Join(errs...) -} - -// Build builds all targets concurrently. -func (b *Build) Build(targets []Target) (files []string, err error) { - if len(targets) == 0 { - return nil, errors.New("no targets specified") - } - log.Printf("Building %d targets: %v", len(targets), targets) - var ( - wg sync.WaitGroup - errs = make([]error, len(targets)) - buildFiles = make([][]string, len(targets)) - ) - for i, t := range targets { - wg.Add(1) - go func(i int, t Target) { - var err error - defer func() { - if err != nil { - err = fmt.Errorf("%s: %w", t, err) - } - errs[i] = err - wg.Done() - }() - fs, err := t.Build(b) - buildFiles[i] = fs - }(i, t) - } - wg.Wait() - - for _, fs := range buildFiles { - files = append(files, fs...) - } - sort.Strings(files) - - return files, multierr.New(errs...) -} - -// Once runs fn if Once hasn't been called with name before. -func (b *Build) Once(name string, fn func() error) error { - return b.once.Do(name, fn) -} - -// Extra returns a value from the build's extra state, creating it if necessary. -func (b *Build) Extra(key any, constructor func() any) any { - b.extraMu.Lock() - defer b.extraMu.Unlock() - ret, ok := b.extra[key] - if !ok { - ret = constructor() - b.extra[key] = ret - } - return ret -} - -// GoPkg returns the path on disk of pkg. -// The module of pkg must be imported in b.Repo's go.mod. -func (b *Build) GoPkg(pkg string) (string, error) { - out, err := b.Command(b.Repo, b.Go, "list", "-f", "{{.Dir}}", pkg).CombinedOutput() - if err != nil { - return "", fmt.Errorf("finding package %q: %w", pkg, err) - } - return strings.TrimSpace(out), nil -} - -// TmpDir creates and returns a new empty temporary directory. -// The caller does not need to clean up the directory after use, it will get -// deleted by b.Close(). -func (b *Build) TmpDir() string { - // Because we're creating all temp dirs in our parent temp dir, the only - // failures that can happen at this point are sequence breaks (e.g. if b.Tmp - // is deleted while stuff is still running). So, panic on error to slightly - // simplify callsites. - ret, err := os.MkdirTemp(b.Tmp, "") - if err != nil { - panic(fmt.Sprintf("creating temp dir: %v", err)) - } - return ret -} - -// BuildWebClientAssets builds the JS and CSS assets used by the web client. -// If b.WebClientSource is non-empty, assets are built in a "build" sub-directory of that path. -// Otherwise, no assets are built. -func (b *Build) BuildWebClientAssets() error { - // Nothing in the web client assets is platform-specific, - // so we only need to build it once. - return b.Once("build-web-client-assets", func() error { - if b.WebClientSource == "" { - return nil - } - dir := b.WebClientSource - if err := b.Command(dir, b.Yarn, "install").Run(); err != nil { - return err - } - if err := b.Command(dir, b.Yarn, "build").Run(); err != nil { - return err - } - return nil - }) -} - -// BuildGoBinary builds the Go binary at path and returns the path to the -// binary. Builds are cached by path and env, so each build only happens once -// per process execution. -func (b *Build) BuildGoBinary(path string, env map[string]string) (string, error) { - return b.BuildGoBinaryWithTags(path, env, nil) -} - -// BuildGoBinaryWithTags builds the Go binary at path and returns the -// path to the binary. Builds are cached by path, env and tags, so -// each build only happens once per process execution. -// -// The passed in tags override gocross's automatic selection of build -// tags, so you will have to figure out and specify all the tags -// relevant to your build. -func (b *Build) BuildGoBinaryWithTags(path string, env map[string]string, tags []string) (string, error) { - err := b.Once("init-go", func() error { - log.Printf("Initializing Go toolchain") - // If the build is using a tool/go, it may need to download a toolchain - // and do other initialization. Running `go version` once takes care of - // all of that and avoids that initialization happening concurrently - // later on in builds. - _, err := b.Command(b.Repo, b.Go, "version").CombinedOutput() - return err - }) - if err != nil { - return "", err - } - - buildKey := []any{"go-build", path, env, tags} - return b.goBuilds.Do(buildKey, func() (string, error) { - b.goBuildLimit <- struct{}{} - defer func() { <-b.goBuildLimit }() - - var envStrs []string - for k, v := range env { - envStrs = append(envStrs, k+"="+v) - } - sort.Strings(envStrs) - buildDir := b.TmpDir() - outPath := buildDir - if env["GOOS"] == "windowsdll" { - // DLL builds fail unless we use a fully-qualified path to the output binary. - outPath = filepath.Join(buildDir, filepath.Base(path)+".dll") - } - args := []string{"build", "-v", "-o", outPath} - if len(tags) > 0 { - tagsStr := strings.Join(tags, ",") - log.Printf("Building %s (with env %s, tags %s)", path, strings.Join(envStrs, " "), tagsStr) - args = append(args, "-tags="+tagsStr) - } else { - log.Printf("Building %s (with env %s)", path, strings.Join(envStrs, " ")) - } - args = append(args, path) - cmd := b.Command(b.Repo, b.Go, args...) - for k, v := range env { - cmd.Cmd.Env = append(cmd.Cmd.Env, k+"="+v) - } - if err := cmd.Run(); err != nil { - return "", err - } - out := filepath.Join(buildDir, filepath.Base(path)) - if env["GOOS"] == "windows" || env["GOOS"] == "windowsgui" { - out += ".exe" - } else if env["GOOS"] == "windowsdll" { - out += ".dll" - } - return out, nil - }) -} - -// Command prepares an exec.Cmd to run [cmd, args...] in dir. -func (b *Build) Command(dir, cmd string, args ...string) *Command { - ret := &Command{ - Cmd: exec.Command(cmd, args...), - } - if b.Verbose { - ret.Cmd.Stdout = os.Stdout - ret.Cmd.Stderr = os.Stderr - } else { - ret.Cmd.Stdout = &ret.Output - ret.Cmd.Stderr = &ret.Output - } - // dist always wants to use gocross if any Go is involved. - ret.Cmd.Env = append(os.Environ(), "TS_USE_GOCROSS=1") - ret.Cmd.Dir = dir - return ret -} - -// Command runs an exec.Cmd and returns its exit status. If the command fails, -// its output is printed to os.Stdout, otherwise it's suppressed. -type Command struct { - Cmd *exec.Cmd - Output bytes.Buffer -} - -// Run is like c.Cmd.Run, but if the command fails, its output is printed to -// os.Stdout before returning the error. -func (c *Command) Run() error { - err := c.Cmd.Run() - if err != nil { - // Command failed, dump its output. - os.Stdout.Write(c.Output.Bytes()) - } - return err -} - -// CombinedOutput is like c.Cmd.CombinedOutput, but returns the output as a -// string instead of a byte slice. -func (c *Command) CombinedOutput() (string, error) { - c.Cmd.Stdout = nil - c.Cmd.Stderr = nil - bs, err := c.Cmd.CombinedOutput() - return string(bs), err -} - -func findModRoot(path string) (string, error) { - for { - modpath := filepath.Join(path, "go.mod") - if _, err := os.Stat(modpath); err == nil { - return path, nil - } else if !errors.Is(err, os.ErrNotExist) { - return "", err - } - path = filepath.Dir(path) - if path == "/" { - return "", fmt.Errorf("no go.mod found in %q or any parent directory", path) - } - } -} - -// findTool returns the path to the specified named tool. -// It first looks in the "tool" directory in the provided path, -// then in the $PATH environment variable. -func findTool(path, name string) (string, error) { - tool := filepath.Join(path, "tool", name) - if _, err := os.Stat(tool); err == nil { - return tool, nil - } - tool, err := exec.LookPath(name) - if err != nil { - return "", err - } - return tool, nil -} - -// FilterTargets returns the subset of targets that match any of the filters. -// If filters is empty, returns all targets. -func FilterTargets(targets []Target, filters []string) ([]Target, error) { - var filts []*regexp.Regexp - for _, f := range filters { - if f == "all" { - return targets, nil - } - filt, err := regexp.Compile(f) - if err != nil { - return nil, fmt.Errorf("invalid filter %q: %w", f, err) - } - filts = append(filts, filt) - } - var ret []Target - for _, t := range targets { - for _, filt := range filts { - if filt.MatchString(t.String()) { - ret = append(ret, t) - break - } - } - } - return ret, nil -} diff --git a/release/dist/memoize.go b/release/dist/memoize.go deleted file mode 100644 index 0927ac0a81540..0000000000000 --- a/release/dist/memoize.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dist - -import ( - "sync" - - "tailscale.com/util/deephash" -) - -// MemoizedFn is a function that memoize.Do can call. -type MemoizedFn[T any] func() (T, error) - -// Memoize runs MemoizedFns and remembers their results. -type Memoize[O any] struct { - mu sync.Mutex - cond *sync.Cond - outs map[deephash.Sum]O - errs map[deephash.Sum]error - inflight map[deephash.Sum]bool -} - -// Do runs fn and returns its result. -// fn is only run once per unique key. Subsequent Do calls with the same key -// return the memoized result of the first call, even if fn is a different -// function. -func (m *Memoize[O]) Do(key any, fn MemoizedFn[O]) (ret O, err error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.cond == nil { - m.cond = sync.NewCond(&m.mu) - m.outs = map[deephash.Sum]O{} - m.errs = map[deephash.Sum]error{} - m.inflight = map[deephash.Sum]bool{} - } - - k := deephash.Hash(&key) - - for m.inflight[k] { - m.cond.Wait() - } - if err := m.errs[k]; err != nil { - var ret O - return ret, err - } - if ret, ok := m.outs[k]; ok { - return ret, nil - } - - m.inflight[k] = true - m.mu.Unlock() - defer func() { - m.mu.Lock() - delete(m.inflight, k) - if err != nil { - m.errs[k] = err - } else { - m.outs[k] = ret - } - m.cond.Broadcast() - }() - - ret, err = fn() - if err != nil { - var ret O - return ret, err - } - return ret, nil -} - -// once is like memoize, but for functions that don't return non-error values. -type once struct { - m Memoize[any] -} - -// Do runs fn. -// fn is only run once per unique key. Subsequent Do calls with the same key -// return the memoized result of the first call, even if fn is a different -// function. -func (o *once) Do(key any, fn func() error) error { - _, err := o.m.Do(key, func() (any, error) { - return nil, fn() - }) - return err -} diff --git a/release/dist/qnap/files/Tailscale/build_sign.csv b/release/dist/qnap/files/Tailscale/build_sign.csv deleted file mode 100755 index 430183ab7de0e..0000000000000 --- a/release/dist/qnap/files/Tailscale/build_sign.csv +++ /dev/null @@ -1 +0,0 @@ -,/Tailscale.sh, diff --git a/release/dist/qnap/files/Tailscale/config/.gitkeep b/release/dist/qnap/files/Tailscale/config/.gitkeep deleted file mode 100755 index e69de29bb2d1d..0000000000000 diff --git a/release/dist/qnap/files/Tailscale/icons/.gitkeep b/release/dist/qnap/files/Tailscale/icons/.gitkeep deleted file mode 100755 index e69de29bb2d1d..0000000000000 diff --git a/release/dist/qnap/files/Tailscale/icons/Tailscale.gif b/release/dist/qnap/files/Tailscale/icons/Tailscale.gif deleted file mode 100644 index 7ba203c337c10..0000000000000 Binary files a/release/dist/qnap/files/Tailscale/icons/Tailscale.gif and /dev/null differ diff --git a/release/dist/qnap/files/Tailscale/icons/Tailscale_80.gif b/release/dist/qnap/files/Tailscale/icons/Tailscale_80.gif deleted file mode 100644 index 38e797bc2f413..0000000000000 Binary files a/release/dist/qnap/files/Tailscale/icons/Tailscale_80.gif and /dev/null differ diff --git a/release/dist/qnap/files/Tailscale/icons/Tailscale_gray.gif b/release/dist/qnap/files/Tailscale/icons/Tailscale_gray.gif deleted file mode 100644 index 26fd5130d3484..0000000000000 Binary files a/release/dist/qnap/files/Tailscale/icons/Tailscale_gray.gif and /dev/null differ diff --git a/release/dist/qnap/files/Tailscale/package_routines b/release/dist/qnap/files/Tailscale/package_routines deleted file mode 100755 index faa32f32a81b1..0000000000000 --- a/release/dist/qnap/files/Tailscale/package_routines +++ /dev/null @@ -1,143 +0,0 @@ -###################################################################### -# List of available definitions (it's not necessary to uncomment them) -###################################################################### -###### Command definitions ##### -#CMD_AWK="/bin/awk" -#CMD_CAT="/bin/cat" -#CMD_CHMOD="/bin/chmod" -#CMD_CHOWN="/bin/chown" -#CMD_CP="/bin/cp" -#CMD_CUT="/bin/cut" -#CMD_DATE="/bin/date" -#CMD_ECHO="/bin/echo" -#CMD_EXPR="/usr/bin/expr" -#CMD_FIND="/usr/bin/find" -#CMD_GETCFG="/sbin/getcfg" -#CMD_GREP="/bin/grep" -#CMD_GZIP="/bin/gzip" -#CMD_HOSTNAME="/bin/hostname" -#CMD_LN="/bin/ln" -#CMD_LOG_TOOL="/sbin/log_tool" -#CMD_MD5SUM="/bin/md5sum" -#CMD_MKDIR="/bin/mkdir" -#CMD_MV="/bin/mv" -#CMD_RM="/bin/rm" -#CMD_RMDIR="/bin/rmdir" -#CMD_SED="/bin/sed" -#CMD_SETCFG="/sbin/setcfg" -#CMD_SLEEP="/bin/sleep" -#CMD_SORT="/usr/bin/sort" -#CMD_SYNC="/bin/sync" -#CMD_TAR="/bin/tar" -#CMD_TOUCH="/bin/touch" -#CMD_WGET="/usr/bin/wget" -#CMD_WLOG="/sbin/write_log" -#CMD_XARGS="/usr/bin/xargs" -#CMD_7Z="/usr/local/sbin/7z" -# -###### System definitions ##### -#SYS_EXTRACT_DIR="$(pwd)" -#SYS_CONFIG_DIR="/etc/config" -#SYS_INIT_DIR="/etc/init.d" -#SYS_STARTUP_DIR="/etc/rcS.d" -#SYS_SHUTDOWN_DIR="/etc/rcK.d" -#SYS_RSS_IMG_DIR="/home/httpd/RSS/images" -#SYS_QPKG_DATA_FILE_GZIP="./data.tar.gz" -#SYS_QPKG_DATA_FILE_BZIP2="./data.tar.bz2" -#SYS_QPKG_DATA_FILE_7ZIP="./data.tar.7z" -#SYS_QPKG_DATA_CONFIG_FILE="./conf.tar.gz" -#SYS_QPKG_DATA_MD5SUM_FILE="./md5sum" -#SYS_QPKG_DATA_PACKAGES_FILE="./Packages.gz" -#SYS_QPKG_CONFIG_FILE="$SYS_CONFIG_DIR/qpkg.conf" -#SYS_QPKG_CONF_FIELD_QPKGFILE="QPKG_File" -#SYS_QPKG_CONF_FIELD_NAME="Name" -#SYS_QPKG_CONF_FIELD_VERSION="Version" -#SYS_QPKG_CONF_FIELD_ENABLE="Enable" -#SYS_QPKG_CONF_FIELD_DATE="Date" -#SYS_QPKG_CONF_FIELD_SHELL="Shell" -#SYS_QPKG_CONF_FIELD_INSTALL_PATH="Install_Path" -#SYS_QPKG_CONF_FIELD_CONFIG_PATH="Config_Path" -#SYS_QPKG_CONF_FIELD_WEBUI="WebUI" -#SYS_QPKG_CONF_FIELD_WEBPORT="Web_Port" -#SYS_QPKG_CONF_FIELD_SERVICEPORT="Service_Port" -#SYS_QPKG_CONF_FIELD_SERVICE_PIDFILE="Pid_File" -#SYS_QPKG_CONF_FIELD_AUTHOR="Author" -#SYS_QPKG_CONF_FIELD_RC_NUMBER="RC_Number" -## The following variables are assigned values at run-time. -#SYS_HOSTNAME=$($CMD_HOSTNAME) -## Data file name (one of SYS_QPKG_DATA_FILE_GZIP, SYS_QPKG_DATA_FILE_BZIP2, -## or SYS_QPKG_DATA_FILE_7ZIP) -#SYS_QPKG_DATA_FILE= -## Base location. -#SYS_QPKG_BASE="" -## Base location of QPKG installed packages. -#SYS_QPKG_INSTALL_PATH="" -## Location of installed software. -#SYS_QPKG_DIR="" -## If the QPKG should be enabled or disabled after the installation/upgrade. -#SYS_QPKG_SERVICE_ENABLED="" -## Architecture of the device the QPKG is installed on. -#SYS_CPU_ARCH="" -## Name and location of system shares -#SYS_PUBLIC_SHARE="" -#SYS_PUBLIC_PATH="" -#SYS_DOWNLOAD_SHARE="" -#SYS_DOWNLOAD_PATH="" -#SYS_MULTIMEDIA_SHARE="" -#SYS_MULTIMEDIA_PATH="" -#SYS_RECORDINGS_SHARE="" -#SYS_RECORDINGS_PATH="" -#SYS_USB_SHARE="" -#SYS_USB_PATH="" -#SYS_WEB_SHARE="" -#SYS_WEB_PATH="" -## Path to ipkg or opkg package tool if installed. -#CMD_PKG_TOOL= -# -###################################################################### -# All package specific functions shall call 'err_log MSG' if an error -# is detected that shall terminate the installation. -###################################################################### -# -###################################################################### -# Define any package specific operations that shall be performed when -# the package is removed. -###################################################################### -#PKG_PRE_REMOVE="{ -#}" -# -#PKG_MAIN_REMOVE="{ -#}" -# -PKG_POST_REMOVE="{ - rm -f /home/httpd/cgi-bin/qpkg/Tailscale - rm -rf /tmp/tailscale - if [ -f /etc/resolv.pre-tailscale-backup.conf ] && grep -q 100.100.100.100 /etc/resolv.conf; then - mv /etc/resolv.pre-tailscale-backup.conf /etc/resolv.conf - fi -}" -# -###################################################################### -# Define any package specific initialization that shall be performed -# before the package is installed. -###################################################################### -#pkg_init(){ -#} -# -###################################################################### -# Define any package specific requirement checks that shall be -# performed before the package is installed. -###################################################################### -#pkg_check_requirement(){ -#} -# -###################################################################### -# Define any package specific operations that shall be performed when -# the package is installed. -###################################################################### -pkg_install(){ - ${CMD_MKDIR} -p ${SYS_QPKG_DIR}/state -} - -#pkg_post_install(){ -#} diff --git a/release/dist/qnap/files/Tailscale/qpkg.cfg.in b/release/dist/qnap/files/Tailscale/qpkg.cfg.in deleted file mode 100644 index 4084eb2ca1bcf..0000000000000 --- a/release/dist/qnap/files/Tailscale/qpkg.cfg.in +++ /dev/null @@ -1,99 +0,0 @@ -# Name of the packaged application. -QPKG_NAME="Tailscale" -# Name of the display application. -#QPKG_DISPLAY_NAME="" -# Version of the packaged application. -QPKG_VER="$QPKG_VER" -# Author or maintainer of the package -QPKG_AUTHOR="Tailscale Inc." -# License for the packaged application -#QPKG_LICENSE="" -# One-line description of the packaged application -#QPKG_SUMMARY="Connect all your devices using WireGuard, without the hassle." - -# Preferred number in start/stop sequence. -QPKG_RC_NUM="101" -# Init-script used to control the start and stop of the installed application. -QPKG_SERVICE_PROGRAM="Tailscale.sh" - -# Optional 1 is enable. Path of starting/ stopping shall script. (no start/stop on App Center) -#QPKG_DISABLE_APPCENTER_UI_SERVICE=1 - -# Specifies any packages required for the current package to operate. -QPKG_REQUIRE="" -# Specifies what packages cannot be installed if the current package -# is to operate properly. -#QPKG_CONFLICT="Python" -# Name of configuration file (multiple definitions are allowed). -#QPKG_CONFIG="Tailscale.cfg" -#QPKG_CONFIG="/etc/config/myApp.conf" -# Port number used by service program. -QPKG_SERVICE_PORT="41641" -# Location of file with running service's PID -#QPKG_SERVICE_PIDFILE="" -# Relative path to web interface -QPKG_WEBUI="/cgi-bin/qpkg/Tailscale/index.cgi" -# Port number for the web interface. -#QPKG_WEB_PORT="" -# Port number for the SSL web interface. -#QPKG_WEB_SSL_PORT="" - -# Use QTS HTTP Proxy and set Proxy_Path in the qpkg.conf. -# When the QPKG has its own HTTP service port, and want clients to connect via QTS HTTP port (default 8080). -# Usually use this option when the QPKG need to connect via myQNAPcloud service. -QPKG_USE_PROXY="1" -#QPKG_PROXY_PATH="/qpkg_name" - -#Desktop Application (since 4.1) -# Set value to 1 means to open the QPKG's Web UI inside QTS desktop instead of new window. -#QPKG_DESKTOP_APP="1" -# Desktop Application Window default inner width (since 4.1) (not over 1178) -#QPKG_DESKTOP_APP_WIN_WIDTH="" -# Desktop Application Window default inner height (since 4.1) (not over 600) -#QPKG_DESKTOP_APP_WIN_HEIGHT="" - -# Minimum QTS version requirement -QTS_MINI_VERSION="5.0.0" -# Maximum QTS version requirement -#QTS_MAX_VERSION="5.0.0" - -# Select volume -# 1: support installation -# 2: support migration -# 3 (1+2): support both installation and migration -QPKG_VOLUME_SELECT="1" - -# Set timeout for QPKG enable and QPKG disable (since 4.1.0) -# Format in seconds (enable, disable) -#QPKG_TIMEOUT="10,30" - -# Visible setting for the QPKG that has web UI, show this QPKG on the Main menu of -# 1(default): administrators, 2: all NAS users. -QPKG_VISIBLE="1" - -# Location of icons for the packaged application. -QDK_DATA_DIR_ICONS="icons" -# Location of files specific to arm-x19 packages. -#QDK_DATA_DIR_X19="arm-x19" -# Location of files specific to arm-x31 packages. -#QDK_DATA_DIR_X31="arm-x31" -# Location of files specific to arm-x41 packages. -#QDK_DATA_DIR_X41="arm_al" -# Location of files specific to x86 packages. -#QDK_DATA_DIR_X86="x86" -# Location of files specific to x86 (64-bit) packages. -#QDK_DATA_DIR_X86_64="x86_64" -# Location of files common to all architectures. -QDK_DATA_DIR_SHARED="shared" -# Location of configuration files. -#QDK_DATA_DIR_CONFIG="config" -# Name of local data package. -#QDK_DATA_FILE="" -# Name of extra package (multiple definitions are allowed). -#QDK_EXTRA_FILE="" -# For QNAP code signing (currently can be done only inside QNAP) -# Uncomment the following four options if you want to enable code signing for this QPKG -#QNAP_CODE_SIGNING="0" -#QNAP_CODE_SIGNING_SERVER_IP="codesigning.qnap.com.tw" -#QNAP_CODE_SIGNING_SERVER_PORT="5001" -#QNAP_CODE_SIGNING_CSV="build_sign.csv" \ No newline at end of file diff --git a/release/dist/qnap/files/Tailscale/shared/Tailscale.sh b/release/dist/qnap/files/Tailscale/shared/Tailscale.sh deleted file mode 100755 index 78b3a45d23043..0000000000000 --- a/release/dist/qnap/files/Tailscale/shared/Tailscale.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/sh -CONF=/etc/config/qpkg.conf -QPKG_NAME="Tailscale" -QPKG_ROOT=`/sbin/getcfg ${QPKG_NAME} Install_Path -f ${CONF}` -QPKG_PORT=`/sbin/getcfg ${QPKG_NAME} Service_Port -f ${CONF}` -export QNAP_QPKG=${QPKG_NAME} -set -e - -case "$1" in - start) - ENABLED=$(/sbin/getcfg ${QPKG_NAME} Enable -u -d FALSE -f ${CONF}) - if [ "${ENABLED}" != "TRUE" ]; then - echo "${QPKG_NAME} is disabled." - exit 1 - fi - mkdir -p /home/httpd/cgi-bin/qpkg - ln -sf ${QPKG_ROOT}/ui /home/httpd/cgi-bin/qpkg/${QPKG_NAME} - mkdir -p -m 0755 /tmp/tailscale - if [ -e /tmp/tailscale/tailscaled.pid ]; then - PID=$(cat /tmp/tailscale/tailscaled.pid) - if [ -d /proc/${PID}/ ]; then - echo "${QPKG_NAME} is already running." - exit 0 - fi - fi - ${QPKG_ROOT}/tailscaled --port ${QPKG_PORT} --statedir=${QPKG_ROOT}/state --socket=/tmp/tailscale/tailscaled.sock 2> /dev/null & - echo $! > /tmp/tailscale/tailscaled.pid - ;; - - stop) - if [ -e /tmp/tailscale/tailscaled.pid ]; then - PID=$(cat /tmp/tailscale/tailscaled.pid) - kill -9 ${PID} || true - rm -f /tmp/tailscale/tailscaled.pid - fi - ;; - - restart) - $0 stop - $0 start - ;; - remove) - ;; - - *) - echo "Usage: $0 {start|stop|restart|remove}" - exit 1 -esac - -exit 0 diff --git a/release/dist/qnap/files/Tailscale/shared/ui/.htaccess b/release/dist/qnap/files/Tailscale/shared/ui/.htaccess deleted file mode 100644 index 1695f503f6fad..0000000000000 --- a/release/dist/qnap/files/Tailscale/shared/ui/.htaccess +++ /dev/null @@ -1,2 +0,0 @@ -Options +ExecCGI -AddHandler cgi-script .cgi diff --git a/release/dist/qnap/files/Tailscale/shared/ui/index.cgi b/release/dist/qnap/files/Tailscale/shared/ui/index.cgi deleted file mode 100755 index 961fc8bc4ede3..0000000000000 --- a/release/dist/qnap/files/Tailscale/shared/ui/index.cgi +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -CONF=/etc/config/qpkg.conf -QPKG_NAME="Tailscale" -QPKG_ROOT=$(/sbin/getcfg ${QPKG_NAME} Install_Path -f ${CONF} -d"") -exec "${QPKG_ROOT}/tailscale" --socket=/tmp/tailscale/tailscaled.sock web --cgi --prefix="/cgi-bin/qpkg/Tailscale/index.cgi/" diff --git a/release/dist/qnap/files/scripts/Dockerfile.qpkg b/release/dist/qnap/files/scripts/Dockerfile.qpkg deleted file mode 100644 index 135d5d20fc94c..0000000000000 --- a/release/dist/qnap/files/scripts/Dockerfile.qpkg +++ /dev/null @@ -1,9 +0,0 @@ -FROM ubuntu:20.04 - -RUN apt-get update -y && \ - apt-get install -y --no-install-recommends \ - git-core \ - ca-certificates -RUN git clone https://github.com/qnap-dev/QDK.git -RUN cd /QDK && ./InstallToUbuntu.sh install -ENV PATH="/usr/share/QDK/bin:${PATH}" \ No newline at end of file diff --git a/release/dist/qnap/files/scripts/build-qpkg.sh b/release/dist/qnap/files/scripts/build-qpkg.sh deleted file mode 100755 index d478bfe6b26e6..0000000000000 --- a/release/dist/qnap/files/scripts/build-qpkg.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash - -set -eu - -# Clean up folders and files created during build. -function cleanup() { - rm -rf /Tailscale/$ARCH - rm -f /Tailscale/sed* - rm -f /Tailscale/qpkg.cfg - - # If this build was signed, a .qpkg.codesigning file will be created as an - # artifact of the build - # (see https://github.com/qnap-dev/qdk2/blob/93ac75c76941b90ee668557f7ce01e4b23881054/QDK_2.x/bin/qbuild#L992). - # - # go/client-release doesn't seem to need these, so we delete them here to - # avoid uploading them to pkgs.tailscale.com. - rm -f /out/*.qpkg.codesigning -} -trap cleanup EXIT - -mkdir -p /Tailscale/$ARCH -cp /tailscaled /Tailscale/$ARCH/tailscaled -cp /tailscale /Tailscale/$ARCH/tailscale - -sed "s/\$QPKG_VER/$TSTAG-$QNAPTAG/g" /Tailscale/qpkg.cfg.in > /Tailscale/qpkg.cfg - -qbuild --root /Tailscale --build-arch $ARCH --build-dir /out diff --git a/release/dist/qnap/pkgs.go b/release/dist/qnap/pkgs.go deleted file mode 100644 index 9df649ddbfed4..0000000000000 --- a/release/dist/qnap/pkgs.go +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package qnap contains dist Targets for building QNAP Tailscale packages. -// -// QNAP dev docs over at https://www.qnap.com/en/how-to/tutorial/article/qpkg-development-guidelines. -package qnap - -import ( - "embed" - "fmt" - "io/fs" - "log" - "os" - "os/exec" - "path/filepath" - "slices" - "sync" - - "tailscale.com/release/dist" -) - -type target struct { - goenv map[string]string - arch string - signer *signer -} - -type signer struct { - privateKeyPath string - certificatePath string -} - -func (t *target) String() string { - return fmt.Sprintf("qnap/%s", t.arch) -} - -func (t *target) Build(b *dist.Build) ([]string, error) { - // Stop early if we don't have docker running. - if _, err := exec.LookPath("docker"); err != nil { - return nil, fmt.Errorf("docker not found, cannot build: %w", err) - } - - qnapBuilds := getQnapBuilds(b, t.signer) - inner, err := qnapBuilds.buildInnerPackage(b, t.goenv) - if err != nil { - return nil, err - } - - return t.buildQPKG(b, qnapBuilds, inner) -} - -const ( - qnapTag = "1" // currently static, we don't seem to bump this -) - -func (t *target) buildQPKG(b *dist.Build, qnapBuilds *qnapBuilds, inner *innerPkg) ([]string, error) { - if _, err := exec.LookPath("docker"); err != nil { - return nil, fmt.Errorf("docker not found, cannot build: %w", err) - } - - if err := qnapBuilds.makeDockerImage(b); err != nil { - return nil, fmt.Errorf("makeDockerImage: %w", err) - } - - filename := fmt.Sprintf("Tailscale_%s-%s_%s.qpkg", b.Version.Short, qnapTag, t.arch) - filePath := filepath.Join(b.Out, filename) - - cmd := b.Command(b.Repo, "docker", "run", "--rm", - "-e", fmt.Sprintf("ARCH=%s", t.arch), - "-e", fmt.Sprintf("TSTAG=%s", b.Version.Short), - "-e", fmt.Sprintf("QNAPTAG=%s", qnapTag), - "-v", fmt.Sprintf("%s:/tailscale", inner.tailscalePath), - "-v", fmt.Sprintf("%s:/tailscaled", inner.tailscaledPath), - // Tailscale folder has QNAP package setup files needed for building. - "-v", fmt.Sprintf("%s:/Tailscale", filepath.Join(qnapBuilds.tmpDir, "files/Tailscale")), - "-v", fmt.Sprintf("%s:/build-qpkg.sh", filepath.Join(qnapBuilds.tmpDir, "files/scripts/build-qpkg.sh")), - "-v", fmt.Sprintf("%s:/out", b.Out), - "build.tailscale.io/qdk:latest", - "/build-qpkg.sh", - ) - - // dist.Build runs target builds in parallel goroutines by default. - // For QNAP, this is an issue because the underlaying qbuild builder will - // create tmp directories in the shared docker image that end up conflicting - // with one another. - // So we use a mutex to only allow one "docker run" at a time. - qnapBuilds.dockerImageMu.Lock() - defer qnapBuilds.dockerImageMu.Unlock() - - log.Printf("Building %s", filePath) - out, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("docker run %v: %s", err, out) - } - - return []string{filePath, filePath + ".md5"}, nil -} - -type qnapBuildsMemoizeKey struct{} - -type innerPkg struct { - tailscalePath string - tailscaledPath string -} - -// qnapBuilds holds extra build context shared by all qnap builds. -type qnapBuilds struct { - // innerPkgs contains per-goenv compiled binary paths. - // It is used to avoid repeated compilations for the same architecture. - innerPkgs dist.Memoize[*innerPkg] - dockerImageMu sync.Mutex - // tmpDir is a temp directory used for building qpkgs. - // It gets cleaned up when the dist.Build is closed. - tmpDir string -} - -// getQnapBuilds returns the qnapBuilds for b, creating one if needed. -func getQnapBuilds(b *dist.Build, signer *signer) *qnapBuilds { - return b.Extra(qnapBuildsMemoizeKey{}, func() any { - builds, err := newQNAPBuilds(b, signer) - if err != nil { - panic(fmt.Errorf("setUpTmpDir: %v", err)) - } - return builds - }).(*qnapBuilds) -} - -//go:embed all:files -var buildFiles embed.FS - -// newQNAPBuilds creates a new qnapBuilds instance to hold context shared by -// all qnap targets, and sets up its local temp directory used for building. -// -// The qnapBuilds.tmpDir is filled with the contents of the buildFiles embedded -// FS for building. -// -// We do this to allow for this tailscale.com/release/dist/qnap package to be -// used from both the corp and OSS repos. When built from OSS source directly, -// this is a superfluous extra step, but when imported as a go module to another -// repo (such as corp), we must do this to allow for the module's build files -// to be reachable and editable from docker. -// -// This runs only once per dist.Build instance, is shared by all qnap targets, -// and gets cleaned up upon close of the dist.Build. -// -// When a signer is provided, newQNAPBuilds also sets up the qpkg signature -// files in qbuild's expected location within m.tmpDir. -func newQNAPBuilds(b *dist.Build, signer *signer) (*qnapBuilds, error) { - m := new(qnapBuilds) - - log.Print("Setting up qnap tmp build directory") - m.tmpDir = filepath.Join(b.Repo, "tmp-qnap-build") - b.AddOnCloseFunc(func() error { - return os.RemoveAll(m.tmpDir) - }) - - if err := fs.WalkDir(buildFiles, "files", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - outPath := filepath.Join(m.tmpDir, path) - if d.IsDir() { - return os.MkdirAll(outPath, 0755) - } - file, err := fs.ReadFile(buildFiles, path) - if err != nil { - return err - } - perm := fs.FileMode(0644) - if slices.Contains([]string{".sh", ".cgi"}, filepath.Ext(path)) { - perm = 0755 - } - return os.WriteFile(outPath, file, perm) - }); err != nil { - return nil, err - } - - if signer != nil { - log.Print("Setting up qnap signing files") - - key, err := os.ReadFile(signer.privateKeyPath) - if err != nil { - return nil, err - } - cert, err := os.ReadFile(signer.certificatePath) - if err != nil { - return nil, err - } - - // QNAP's qbuild command expects key and cert files to be in the root - // of the project directory (in our case release/dist/qnap/Tailscale). - // So here, we copy the key and cert over to the project folder for the - // duration of qnap package building and then delete them on close. - - keyPath := filepath.Join(m.tmpDir, "files/Tailscale/private_key") - if err := os.WriteFile(keyPath, key, 0400); err != nil { - return nil, err - } - certPath := filepath.Join(m.tmpDir, "files/Tailscale/certificate") - if err := os.WriteFile(certPath, cert, 0400); err != nil { - return nil, err - } - } - return m, nil -} - -// buildInnerPackage builds the go binaries used for qnap packages. -// These binaries get embedded with Tailscale package metadata to form qnap -// releases. -func (m *qnapBuilds) buildInnerPackage(b *dist.Build, goenv map[string]string) (*innerPkg, error) { - return m.innerPkgs.Do(goenv, func() (*innerPkg, error) { - if err := b.BuildWebClientAssets(); err != nil { - return nil, err - } - ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", goenv) - if err != nil { - return nil, err - } - tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", goenv) - if err != nil { - return nil, err - } - - // The go binaries above get built and put into a /tmp directory created - // by b.TmpDir(). But, we build QNAP with docker, which doesn't always - // allow for mounting tmp directories (seemingly dependent on docker - // host). - // https://stackoverflow.com/questions/65267251/docker-bind-mount-directory-in-tmp-not-working - // - // So here, we move the binaries into a directory within the b.Repo - // path and clean it up when the builder closes. - - tmpDir := filepath.Join(m.tmpDir, fmt.Sprintf("/binaries-%s-%s-%s", b.Version.Short, goenv["GOOS"], goenv["GOARCH"])) - if err = os.MkdirAll(tmpDir, 0755); err != nil { - return nil, err - } - b.AddOnCloseFunc(func() error { - return os.RemoveAll(tmpDir) - }) - - tsBytes, err := os.ReadFile(ts) - if err != nil { - return nil, err - } - tsdBytes, err := os.ReadFile(tsd) - if err != nil { - return nil, err - } - - tsPath := filepath.Join(tmpDir, "tailscale") - if err := os.WriteFile(tsPath, tsBytes, 0755); err != nil { - return nil, err - } - tsdPath := filepath.Join(tmpDir, "tailscaled") - if err := os.WriteFile(tsdPath, tsdBytes, 0755); err != nil { - return nil, err - } - - return &innerPkg{tailscalePath: tsPath, tailscaledPath: tsdPath}, nil - }) -} - -func (m *qnapBuilds) makeDockerImage(b *dist.Build) error { - return b.Once("make-qnap-docker-image", func() error { - log.Printf("Building qnapbuilder docker image") - - cmd := b.Command(b.Repo, "docker", "build", - "-f", filepath.Join(m.tmpDir, "files/scripts/Dockerfile.qpkg"), - "-t", "build.tailscale.io/qdk:latest", - filepath.Join(m.tmpDir, "files/scripts"), - ) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("docker build %v: %s", err, out) - } - return nil - }) -} diff --git a/release/dist/qnap/targets.go b/release/dist/qnap/targets.go deleted file mode 100644 index a069dd6238513..0000000000000 --- a/release/dist/qnap/targets.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package qnap - -import "tailscale.com/release/dist" - -// Targets defines the dist.Targets for QNAP devices. -// -// If privateKeyPath and certificatePath are both provided non-empty, -// these targets will be signed for QNAP app store release with built. -func Targets(privateKeyPath, certificatePath string) []dist.Target { - var signerInfo *signer - if privateKeyPath != "" && certificatePath != "" { - signerInfo = &signer{privateKeyPath, certificatePath} - } - return []dist.Target{ - &target{ - arch: "x86", - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "386", - }, - signer: signerInfo, - }, - &target{ - arch: "x86_ce53xx", - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "386", - }, - signer: signerInfo, - }, - &target{ - arch: "x86_64", - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "amd64", - }, - signer: signerInfo, - }, - &target{ - arch: "arm-x31", - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "arm", - }, - signer: signerInfo, - }, - &target{ - arch: "arm-x41", - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "arm", - }, - signer: signerInfo, - }, - &target{ - arch: "arm-x19", - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "arm", - }, - signer: signerInfo, - }, - &target{ - arch: "arm_64", - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "arm64", - }, - signer: signerInfo, - }, - } -} diff --git a/release/dist/synology/files/PACKAGE_ICON.PNG b/release/dist/synology/files/PACKAGE_ICON.PNG deleted file mode 100644 index 67b09b5d2f05c..0000000000000 Binary files a/release/dist/synology/files/PACKAGE_ICON.PNG and /dev/null differ diff --git a/release/dist/synology/files/PACKAGE_ICON_256.PNG b/release/dist/synology/files/PACKAGE_ICON_256.PNG deleted file mode 100644 index 927b5f8f4d680..0000000000000 Binary files a/release/dist/synology/files/PACKAGE_ICON_256.PNG and /dev/null differ diff --git a/release/dist/synology/files/Tailscale.sc b/release/dist/synology/files/Tailscale.sc deleted file mode 100644 index 707ac6bb079b1..0000000000000 --- a/release/dist/synology/files/Tailscale.sc +++ /dev/null @@ -1,6 +0,0 @@ -[Tailscale] -title="Tailscale" -desc="Tailscale VPN" -port_forward="no" -src.ports="41641/udp" -dst.ports="41641/udp" \ No newline at end of file diff --git a/release/dist/synology/files/config b/release/dist/synology/files/config deleted file mode 100644 index 4dbc48dfb9434..0000000000000 --- a/release/dist/synology/files/config +++ /dev/null @@ -1,11 +0,0 @@ -{ - ".url": { - "SYNO.SDS.Tailscale": { - "type": "url", - "title": "Tailscale", - "icon": "PACKAGE_ICON_256.PNG", - "url": "webman/3rdparty/Tailscale/index.cgi/", - "urlTarget": "_syno_tailscale" - } - } -} diff --git a/release/dist/synology/files/index.cgi b/release/dist/synology/files/index.cgi deleted file mode 100755 index 2c1990cfd138a..0000000000000 --- a/release/dist/synology/files/index.cgi +++ /dev/null @@ -1,2 +0,0 @@ -#! /bin/sh -exec /var/packages/Tailscale/target/bin/tailscale web -cgi -prefix="/webman/3rdparty/Tailscale/index.cgi/" diff --git a/release/dist/synology/files/logrotate-dsm6 b/release/dist/synology/files/logrotate-dsm6 deleted file mode 100644 index 2df64283afc30..0000000000000 --- a/release/dist/synology/files/logrotate-dsm6 +++ /dev/null @@ -1,8 +0,0 @@ -/var/packages/Tailscale/etc/tailscaled.stdout.log { - size 10M - rotate 3 - missingok - copytruncate - compress - notifempty -} diff --git a/release/dist/synology/files/logrotate-dsm7 b/release/dist/synology/files/logrotate-dsm7 deleted file mode 100644 index 7020dc925c2ca..0000000000000 --- a/release/dist/synology/files/logrotate-dsm7 +++ /dev/null @@ -1,8 +0,0 @@ -/var/packages/Tailscale/var/tailscaled.stdout.log { - size 10M - rotate 3 - missingok - copytruncate - compress - notifempty -} diff --git a/release/dist/synology/files/privilege-dsm6 b/release/dist/synology/files/privilege-dsm6 deleted file mode 100644 index 4b6fe093a1f23..0000000000000 --- a/release/dist/synology/files/privilege-dsm6 +++ /dev/null @@ -1,7 +0,0 @@ -{ - "defaults":{ - "run-as": "root" - }, - "username": "tailscale", - "groupname": "tailscale" -} diff --git a/release/dist/synology/files/privilege-dsm7 b/release/dist/synology/files/privilege-dsm7 deleted file mode 100644 index 93a9c4f7d7bb5..0000000000000 --- a/release/dist/synology/files/privilege-dsm7 +++ /dev/null @@ -1,7 +0,0 @@ -{ - "defaults":{ - "run-as": "package" - }, - "username": "tailscale", - "groupname": "tailscale" -} diff --git a/release/dist/synology/files/privilege-dsm7.for-package-center b/release/dist/synology/files/privilege-dsm7.for-package-center deleted file mode 100644 index db14683460909..0000000000000 --- a/release/dist/synology/files/privilege-dsm7.for-package-center +++ /dev/null @@ -1,13 +0,0 @@ -{ - "defaults":{ - "run-as": "package" - }, - "username": "tailscale", - "groupname": "tailscale", - "tool": [{ - "relpath": "bin/tailscaled", - "user": "package", - "group": "package", - "capabilities": "cap_net_admin,cap_chown,cap_net_raw" - }] -} diff --git a/release/dist/synology/files/resource b/release/dist/synology/files/resource deleted file mode 100644 index 0da0002ef2fb2..0000000000000 --- a/release/dist/synology/files/resource +++ /dev/null @@ -1,11 +0,0 @@ -{ - "port-config": { - "protocol-file": "conf/Tailscale.sc" - }, - "usr-local-linker": { - "bin": ["bin/tailscale"] - }, - "syslog-config": { - "logrotate-relpath": "conf/logrotate.conf" - } -} \ No newline at end of file diff --git a/release/dist/synology/files/scripts/postupgrade b/release/dist/synology/files/scripts/postupgrade deleted file mode 100644 index 92b94c40c5f2b..0000000000000 --- a/release/dist/synology/files/scripts/postupgrade +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -exit 0 \ No newline at end of file diff --git a/release/dist/synology/files/scripts/preupgrade b/release/dist/synology/files/scripts/preupgrade deleted file mode 100644 index 92b94c40c5f2b..0000000000000 --- a/release/dist/synology/files/scripts/preupgrade +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -exit 0 \ No newline at end of file diff --git a/release/dist/synology/files/scripts/start-stop-status b/release/dist/synology/files/scripts/start-stop-status deleted file mode 100755 index e6ece04e3383e..0000000000000 --- a/release/dist/synology/files/scripts/start-stop-status +++ /dev/null @@ -1,129 +0,0 @@ -#!/bin/bash - -SERVICE_NAME="tailscale" - -if [ "${SYNOPKG_DSM_VERSION_MAJOR}" -eq "6" ]; then - PKGVAR="/var/packages/Tailscale/etc" -else - PKGVAR="${SYNOPKG_PKGVAR}" -fi - -PID_FILE="${PKGVAR}/tailscaled.pid" -LOG_FILE="${PKGVAR}/tailscaled.stdout.log" -STATE_FILE="${PKGVAR}/tailscaled.state" -SOCKET_FILE="${PKGVAR}/tailscaled.sock" -PORT="41641" - -SERVICE_COMMAND="${SYNOPKG_PKGDEST}/bin/tailscaled \ ---state=${STATE_FILE} \ ---socket=${SOCKET_FILE} \ ---port=$PORT" - -if [ "${SYNOPKG_DSM_VERSION_MAJOR}" -eq "7" -a ! -e "/dev/net/tun" ]; then - # TODO(maisem/crawshaw): Disable the tun device in DSM7 for now. - SERVICE_COMMAND="${SERVICE_COMMAND} --tun=userspace-networking" -fi - -if [ "${SYNOPKG_DSM_VERSION_MAJOR}" -eq "6" ]; then - chown -R tailscale:tailscale "${PKGVAR}/" -fi - -start_daemon() { - local ts=$(date --iso-8601=second) - echo "${ts} Starting ${SERVICE_NAME} with: ${SERVICE_COMMAND}" >${LOG_FILE} - STATE_DIRECTORY=${PKGVAR} ${SERVICE_COMMAND} 2>&1 | sed -u '1,200p;201s,.*,[further tailscaled logs suppressed],p;d' >>${LOG_FILE} & - # We pipe tailscaled's output to sed, so "$!" retrieves the PID of sed not tailscaled. - # Use jobs -p to retrieve the PID of the most recent process group leader. - jobs -p >"${PID_FILE}" -} - -stop_daemon() { - if [ -r "${PID_FILE}" ]; then - local PID=$(cat "${PID_FILE}") - local ts=$(date --iso-8601=second) - echo "${ts} Stopping ${SERVICE_NAME} service PID=${PID}" >>${LOG_FILE} - kill -TERM $PID >>${LOG_FILE} 2>&1 - wait_for_status 1 || kill -KILL $PID >>${LOG_FILE} 2>&1 - rm -f "${PID_FILE}" >/dev/null - fi -} - -daemon_status() { - if [ -r "${PID_FILE}" ]; then - local PID=$(cat "${PID_FILE}") - if ps -o pid -p ${PID} > /dev/null; then - return - fi - rm -f "${PID_FILE}" >/dev/null - fi - return 1 -} - -wait_for_status() { - # 20 tries - # sleeps for 1 second after each try - local counter=20 - while [ ${counter} -gt 0 ]; do - daemon_status - [ $? -eq $1 ] && return - counter=$((counter - 1)) - sleep 1 - done - return 1 -} - -ensure_tun_created() { - if [ "${SYNOPKG_DSM_VERSION_MAJOR}" -eq "7" ]; then - # TODO(maisem/crawshaw): Disable the tun device in DSM7 for now. - return - fi - # Create the necessary file structure for /dev/net/tun - if ([ ! -c /dev/net/tun ]); then - if ([ ! -d /dev/net ]); then - mkdir -m 755 /dev/net - fi - mknod /dev/net/tun c 10 200 - chmod 0755 /dev/net/tun - fi - - # Load the tun module if not already loaded - if (!(lsmod | grep -q "^tun\s")); then - insmod /lib/modules/tun.ko - fi -} - -case $1 in -start) - if daemon_status; then - exit 0 - else - ensure_tun_created - start_daemon - exit $? - fi - ;; -stop) - if daemon_status; then - stop_daemon - exit $? - else - exit 0 - fi - ;; -status) - if daemon_status; then - echo "${SERVICE_NAME} is running" - exit 0 - else - echo "${SERVICE_NAME} is not running" - exit 3 - fi - ;; -log) - exit 0 - ;; -*) - echo "command $1 is not implemented" - exit 0 - ;; -esac diff --git a/release/dist/synology/pkgs.go b/release/dist/synology/pkgs.go deleted file mode 100644 index ab89dbee3e19f..0000000000000 --- a/release/dist/synology/pkgs.go +++ /dev/null @@ -1,366 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package synology contains dist Targets for building Synology Tailscale packages. -package synology - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "embed" - "errors" - "fmt" - "io" - "io/fs" - "log" - "os" - "path/filepath" - "time" - - "tailscale.com/release/dist" -) - -type target struct { - filenameArch string - dsmMajorVersion int - dsmMinorVersion int - goenv map[string]string - packageCenter bool - signer dist.Signer -} - -func (t *target) String() string { - return fmt.Sprintf("synology/dsm%s/%s", t.dsmVersionString(), t.filenameArch) -} - -func (t *target) Build(b *dist.Build) ([]string, error) { - inner, err := getSynologyBuilds(b).buildInnerPackage(b, t.dsmMajorVersion, t.goenv) - if err != nil { - return nil, err - } - - return t.buildSPK(b, inner) -} - -// dsmVersionInt combines major and minor version info into an int -// representation. -// -// Version 7.2 becomes 72 as an example. -func (t *target) dsmVersionInt() int { - return t.dsmMajorVersion*10 + t.dsmMinorVersion -} - -// dsmVersionString returns a string representation of the version -// including minor version information if it exists. -// -// If dsmMinorVersion is 0 this returns dsmMajorVersion as a string, -// otherwise it returns "dsmMajorVersion-dsmMinorVersion". -func (t *target) dsmVersionString() string { - dsmVersionString := fmt.Sprintf("%d", t.dsmMajorVersion) - if t.dsmMinorVersion != 0 { - dsmVersionString = fmt.Sprintf("%s-%d", dsmVersionString, t.dsmMinorVersion) - } - - return dsmVersionString -} - -func (t *target) buildSPK(b *dist.Build, inner *innerPkg) ([]string, error) { - synoVersion := b.Version.Synology[t.dsmVersionInt()] - filename := fmt.Sprintf("tailscale-%s-%s-%d-dsm%s.spk", t.filenameArch, b.Version.Short, synoVersion, t.dsmVersionString()) - out := filepath.Join(b.Out, filename) - if t.packageCenter { - log.Printf("Building %s (for package center)", filename) - } else { - log.Printf("Building %s (for sideloading)", filename) - } - - if synoVersion > 2147483647 { - // Synology requires that version number is within int32 range. - // Erroring here if we create a build with a higher version. - // In this case, we'll want to adjust the VersionInfo.Synology logic in - // the mkversion package. - return nil, errors.New("syno version exceeds int32 range") - } - - privFile := fmt.Sprintf("privilege-dsm%d", t.dsmMajorVersion) - if t.packageCenter && t.dsmMajorVersion == 7 { - privFile += ".for-package-center" - } - - f, err := os.Create(out) - if err != nil { - return nil, err - } - defer f.Close() - tw := tar.NewWriter(f) - defer tw.Close() - - err = writeTar(tw, b.Time, - memFile("INFO", t.mkInfo(b, inner.uncompressedSz), 0644), - static("PACKAGE_ICON.PNG", "PACKAGE_ICON.PNG", 0644), - static("PACKAGE_ICON_256.PNG", "PACKAGE_ICON_256.PNG", 0644), - static("Tailscale.sc", "Tailscale.sc", 0644), - dir("conf"), - static("resource", "conf/resource", 0644), - static(privFile, "conf/privilege", 0644), - file(inner.path, "package.tgz", 0644), - dir("scripts"), - static("scripts/start-stop-status", "scripts/start-stop-status", 0644), - static("scripts/postupgrade", "scripts/postupgrade", 0644), - static("scripts/preupgrade", "scripts/preupgrade", 0644), - ) - if err != nil { - return nil, err - } - - if err := tw.Close(); err != nil { - return nil, err - } - if err := f.Close(); err != nil { - return nil, err - } - - files := []string{out} - - if t.signer != nil { - outSig := out + ".sig" - if err := t.signer.SignFile(out, outSig); err != nil { - return nil, err - } - files = append(files, outSig) - } - - return files, nil -} - -func (t *target) mkInfo(b *dist.Build, uncompressedSz int64) []byte { - var ret bytes.Buffer - f := func(k, v string) { - fmt.Fprintf(&ret, "%s=%q\n", k, v) - } - f("package", "Tailscale") - f("version", fmt.Sprintf("%s-%d", b.Version.Short, b.Version.Synology[t.dsmVersionInt()])) - f("arch", t.filenameArch) - f("description", "Connect all your devices using WireGuard, without the hassle.") - f("displayname", "Tailscale") - f("maintainer", "Tailscale, Inc.") - f("maintainer_url", "https://github.com/tailscale/tailscale") - f("create_time", b.Time.Format("20060102-15:04:05")) - f("dsmuidir", "ui") - f("dsmappname", "SYNO.SDS.Tailscale") - f("startstop_restart_services", "nginx") - switch t.dsmMajorVersion { - case 6: - f("os_min_ver", "6.0.1-7445") - f("os_max_ver", "7.0-40000") - case 7: - if t.packageCenter { - switch t.dsmMinorVersion { - case 0: - f("os_min_ver", "7.0-40000") - f("os_max_ver", "7.2-60000") - case 2: - f("os_min_ver", "7.2-60000") - default: - panic(fmt.Sprintf("unsupported DSM major.minor version %s", t.dsmVersionString())) - } - } else { - // We do not clamp the os_max_ver currently for non-package center builds as - // the binaries for 7.0 and 7.2 are identical. - f("os_min_ver", "7.0-40000") - f("os_max_ver", "") - } - default: - panic(fmt.Sprintf("unsupported DSM major version %d", t.dsmMajorVersion)) - } - f("extractsize", fmt.Sprintf("%v", uncompressedSz>>10)) // in KiB - return ret.Bytes() -} - -type synologyBuildsMemoizeKey struct{} - -type innerPkg struct { - path string - uncompressedSz int64 -} - -// synologyBuilds is extra build context shared by all synology builds. -type synologyBuilds struct { - innerPkgs dist.Memoize[*innerPkg] -} - -// getSynologyBuilds returns the synologyBuilds for b, creating one if needed. -func getSynologyBuilds(b *dist.Build) *synologyBuilds { - return b.Extra(synologyBuildsMemoizeKey{}, func() any { return new(synologyBuilds) }).(*synologyBuilds) -} - -// buildInnerPackage builds the inner tarball for synology packages, -// which contains the files to unpack to disk on installation (as -// opposed to the outer tarball, which contains package metadata) -func (m *synologyBuilds) buildInnerPackage(b *dist.Build, dsmVersion int, goenv map[string]string) (*innerPkg, error) { - key := []any{dsmVersion, goenv} - return m.innerPkgs.Do(key, func() (*innerPkg, error) { - if err := b.BuildWebClientAssets(); err != nil { - return nil, err - } - ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", goenv) - if err != nil { - return nil, err - } - tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", goenv) - if err != nil { - return nil, err - } - - tmp := b.TmpDir() - out := filepath.Join(tmp, "package.tgz") - - f, err := os.Create(out) - if err != nil { - return nil, err - } - defer f.Close() - gw := gzip.NewWriter(f) - defer gw.Close() - cw := &countingWriter{gw, 0} - tw := tar.NewWriter(cw) - defer tw.Close() - - err = writeTar(tw, b.Time, - dir("bin"), - file(tsd, "bin/tailscaled", 0755), - file(ts, "bin/tailscale", 0755), - dir("conf"), - static("Tailscale.sc", "conf/Tailscale.sc", 0644), - static(fmt.Sprintf("logrotate-dsm%d", dsmVersion), "conf/logrotate.conf", 0644), - dir("ui"), - static("PACKAGE_ICON_256.PNG", "ui/PACKAGE_ICON_256.PNG", 0644), - static("config", "ui/config", 0644), - static("index.cgi", "ui/index.cgi", 0755)) - if err != nil { - return nil, err - } - - if err := tw.Close(); err != nil { - return nil, err - } - if err := gw.Close(); err != nil { - return nil, err - } - if err := f.Close(); err != nil { - return nil, err - } - - return &innerPkg{out, cw.n}, nil - }) -} - -// writeTar writes ents to tw. -func writeTar(tw *tar.Writer, modTime time.Time, ents ...tarEntry) error { - for _, ent := range ents { - if err := ent(tw, modTime); err != nil { - return err - } - } - return nil -} - -// tarEntry is a function that writes tar entries (files or -// directories) to a tar.Writer. -type tarEntry func(*tar.Writer, time.Time) error - -// fsFile returns a tarEntry that writes src in fsys to dst in the tar -// file, with mode. -func fsFile(fsys fs.FS, src, dst string, mode int64) tarEntry { - return func(tw *tar.Writer, modTime time.Time) error { - f, err := fsys.Open(src) - if err != nil { - return err - } - defer f.Close() - fi, err := f.Stat() - if err != nil { - return err - } - hdr := &tar.Header{ - Name: dst, - Size: fi.Size(), - Mode: mode, - ModTime: modTime, - } - if err := tw.WriteHeader(hdr); err != nil { - return err - } - if _, err = io.Copy(tw, f); err != nil { - return err - } - return nil - } -} - -// file returns a tarEntry that writes src on disk into the tar file as -// dst, with mode. -func file(src, dst string, mode int64) tarEntry { - return fsFile(os.DirFS(filepath.Dir(src)), filepath.Base(src), dst, mode) -} - -//go:embed files/* -var files embed.FS - -// static returns a tarEntry that writes src in files/ into the tar -// file as dst, with mode. -func static(src, dst string, mode int64) tarEntry { - fsys, err := fs.Sub(files, "files") - if err != nil { - panic(err) - } - return fsFile(fsys, src, dst, mode) -} - -// memFile returns a tarEntry that writes bs to dst in the tar file, -// with mode. -func memFile(dst string, bs []byte, mode int64) tarEntry { - return func(tw *tar.Writer, modTime time.Time) error { - hdr := &tar.Header{ - Name: dst, - Size: int64(len(bs)), - Mode: mode, - ModTime: modTime, - } - if err := tw.WriteHeader(hdr); err != nil { - return err - } - if _, err := tw.Write(bs); err != nil { - return err - } - return nil - } -} - -// dir returns a tarEntry that creates a world-readable directory in -// the tar file. -func dir(name string) tarEntry { - return func(tw *tar.Writer, modTime time.Time) error { - return tw.WriteHeader(&tar.Header{ - Typeflag: tar.TypeDir, - Name: name + "/", - Mode: 0755, - ModTime: modTime, - // TODO: why tailscale? Files are being written as owned by root. - Uname: "tailscale", - Gname: "tailscale", - }) - } -} - -type countingWriter struct { - w io.Writer - n int64 -} - -func (cw *countingWriter) Write(bs []byte) (int, error) { - n, err := cw.w.Write(bs) - cw.n += int64(n) - return n, err -} diff --git a/release/dist/synology/targets.go b/release/dist/synology/targets.go deleted file mode 100644 index bc7b20afca5d3..0000000000000 --- a/release/dist/synology/targets.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package synology - -import "tailscale.com/release/dist" - -var v5Models = []string{ - "armv5", - "88f6281", - "88f6282", - // hi3535 is actually an armv7 under the hood, but with no - // hardware floating point. To the Go compiler, that means it's an - // armv5. - "hi3535", -} - -var v7Models = []string{ - "armv7", - "alpine", - "armada370", - "armada375", - "armada38x", - "armadaxp", - "comcerto2k", - "monaco", -} - -func Targets(forPackageCenter bool, signer dist.Signer) []dist.Target { - var ret []dist.Target - for _, dsmVersion := range []struct { - major int - minor int - }{ - // DSM6 - {major: 6}, - // DSM7 - {major: 7}, - // DSM7.2 - {major: 7, minor: 2}, - } { - ret = append(ret, - &target{ - filenameArch: "x86_64", - dsmMajorVersion: dsmVersion.major, - dsmMinorVersion: dsmVersion.minor, - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "amd64", - }, - packageCenter: forPackageCenter, - signer: signer, - }, - &target{ - filenameArch: "i686", - dsmMajorVersion: dsmVersion.major, - dsmMinorVersion: dsmVersion.minor, - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "386", - }, - packageCenter: forPackageCenter, - signer: signer, - }, - &target{ - filenameArch: "armv8", - dsmMajorVersion: dsmVersion.major, - dsmMinorVersion: dsmVersion.minor, - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "arm64", - }, - packageCenter: forPackageCenter, - signer: signer, - }) - - // On older ARMv5 and ARMv7 platforms, synology used a whole - // mess of SoC-specific target names, even though the packages - // built for each are identical apart from metadata. - for _, v5Arch := range v5Models { - ret = append(ret, &target{ - filenameArch: v5Arch, - dsmMajorVersion: dsmVersion.major, - dsmMinorVersion: dsmVersion.minor, - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "arm", - "GOARM": "5", - }, - packageCenter: forPackageCenter, - signer: signer, - }) - } - for _, v7Arch := range v7Models { - ret = append(ret, &target{ - filenameArch: v7Arch, - dsmMajorVersion: dsmVersion.major, - dsmMinorVersion: dsmVersion.minor, - goenv: map[string]string{ - "GOOS": "linux", - "GOARCH": "arm", - "GOARM": "7", - }, - packageCenter: forPackageCenter, - signer: signer, - }) - } - } - return ret -} diff --git a/release/dist/unixpkgs/pkgs.go b/release/dist/unixpkgs/pkgs.go deleted file mode 100644 index bad6ce572e675..0000000000000 --- a/release/dist/unixpkgs/pkgs.go +++ /dev/null @@ -1,472 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package unixpkgs contains dist Targets for building unix Tailscale packages. -package unixpkgs - -import ( - "archive/tar" - "compress/gzip" - "errors" - "fmt" - "io" - "log" - "os" - "path/filepath" - "strings" - - "github.com/goreleaser/nfpm/v2" - "github.com/goreleaser/nfpm/v2/files" - "tailscale.com/release/dist" -) - -type tgzTarget struct { - filenameArch string // arch to use in filename instead of deriving from goEnv["GOARCH"] - goEnv map[string]string - signer dist.Signer -} - -func (t *tgzTarget) arch() string { - if t.filenameArch != "" { - return t.filenameArch - } - return t.goEnv["GOARCH"] -} - -func (t *tgzTarget) os() string { - return t.goEnv["GOOS"] -} - -func (t *tgzTarget) String() string { - return fmt.Sprintf("%s/%s/tgz", t.os(), t.arch()) -} - -func (t *tgzTarget) Build(b *dist.Build) ([]string, error) { - var filename string - if t.goEnv["GOOS"] == "linux" { - // Linux used to be the only tgz architecture, so we didn't put the OS - // name in the filename. - filename = fmt.Sprintf("tailscale_%s_%s.tgz", b.Version.Short, t.arch()) - } else { - filename = fmt.Sprintf("tailscale_%s_%s_%s.tgz", b.Version.Short, t.os(), t.arch()) - } - if err := b.BuildWebClientAssets(); err != nil { - return nil, err - } - ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goEnv) - if err != nil { - return nil, err - } - tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goEnv) - if err != nil { - return nil, err - } - - log.Printf("Building %s", filename) - - out := filepath.Join(b.Out, filename) - f, err := os.Create(out) - if err != nil { - return nil, err - } - defer f.Close() - gw := gzip.NewWriter(f) - defer gw.Close() - tw := tar.NewWriter(gw) - defer tw.Close() - - addFile := func(src, dst string, mode int64) error { - f, err := os.Open(src) - if err != nil { - return err - } - defer f.Close() - fi, err := f.Stat() - if err != nil { - return err - } - hdr := &tar.Header{ - Name: dst, - Size: fi.Size(), - Mode: mode, - ModTime: b.Time, - Uid: 0, - Gid: 0, - Uname: "root", - Gname: "root", - } - if err := tw.WriteHeader(hdr); err != nil { - return err - } - if _, err = io.Copy(tw, f); err != nil { - return err - } - return nil - } - addDir := func(name string) error { - hdr := &tar.Header{ - Name: name + "/", - Mode: 0755, - ModTime: b.Time, - Uid: 0, - Gid: 0, - Uname: "root", - Gname: "root", - } - return tw.WriteHeader(hdr) - } - dir := strings.TrimSuffix(filename, ".tgz") - if err := addDir(dir); err != nil { - return nil, err - } - if err := addFile(tsd, filepath.Join(dir, "tailscaled"), 0755); err != nil { - return nil, err - } - if err := addFile(ts, filepath.Join(dir, "tailscale"), 0755); err != nil { - return nil, err - } - if t.os() == "linux" { - dir = filepath.Join(dir, "systemd") - if err := addDir(dir); err != nil { - return nil, err - } - tailscaledDir, err := b.GoPkg("tailscale.com/cmd/tailscaled") - if err != nil { - return nil, err - } - if err := addFile(filepath.Join(tailscaledDir, "tailscaled.service"), filepath.Join(dir, "tailscaled.service"), 0644); err != nil { - return nil, err - } - if err := addFile(filepath.Join(tailscaledDir, "tailscaled.defaults"), filepath.Join(dir, "tailscaled.defaults"), 0644); err != nil { - return nil, err - } - } - if err := tw.Close(); err != nil { - return nil, err - } - if err := gw.Close(); err != nil { - return nil, err - } - if err := f.Close(); err != nil { - return nil, err - } - - files := []string{filename} - - if t.signer != nil { - outSig := out + ".sig" - if err := t.signer.SignFile(out, outSig); err != nil { - return nil, err - } - files = append(files, filepath.Base(outSig)) - } - - return files, nil -} - -type debTarget struct { - goEnv map[string]string -} - -func (t *debTarget) os() string { - return t.goEnv["GOOS"] -} - -func (t *debTarget) arch() string { - return t.goEnv["GOARCH"] -} - -func (t *debTarget) String() string { - return fmt.Sprintf("linux/%s/deb", t.goEnv["GOARCH"]) -} - -func (t *debTarget) Build(b *dist.Build) ([]string, error) { - if t.os() != "linux" { - return nil, errors.New("deb only supported on linux") - } - - if err := b.BuildWebClientAssets(); err != nil { - return nil, err - } - ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goEnv) - if err != nil { - return nil, err - } - tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goEnv) - if err != nil { - return nil, err - } - - tailscaledDir, err := b.GoPkg("tailscale.com/cmd/tailscaled") - if err != nil { - return nil, err - } - repoDir, err := b.GoPkg("tailscale.com") - if err != nil { - return nil, err - } - - arch := debArch(t.arch()) - contents, err := files.PrepareForPackager(files.Contents{ - &files.Content{ - Type: files.TypeFile, - Source: ts, - Destination: "/usr/bin/tailscale", - }, - &files.Content{ - Type: files.TypeFile, - Source: tsd, - Destination: "/usr/sbin/tailscaled", - }, - &files.Content{ - Type: files.TypeFile, - Source: filepath.Join(tailscaledDir, "tailscaled.service"), - Destination: "/lib/systemd/system/tailscaled.service", - }, - &files.Content{ - Type: files.TypeConfigNoReplace, - Source: filepath.Join(tailscaledDir, "tailscaled.defaults"), - Destination: "/etc/default/tailscaled", - }, - }, 0, "deb", false) - if err != nil { - return nil, err - } - info := nfpm.WithDefaults(&nfpm.Info{ - Name: "tailscale", - Arch: arch, - Platform: "linux", - Version: b.Version.Short, - Maintainer: "Tailscale Inc ", - Description: "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", - Homepage: "https://www.tailscale.com", - License: "MIT", - Section: "net", - Priority: "extra", - Overridables: nfpm.Overridables{ - Contents: contents, - Scripts: nfpm.Scripts{ - PostInstall: filepath.Join(repoDir, "release/deb/debian.postinst.sh"), - PreRemove: filepath.Join(repoDir, "release/deb/debian.prerm.sh"), - PostRemove: filepath.Join(repoDir, "release/deb/debian.postrm.sh"), - }, - Depends: []string{ - // iptables is almost always required but not strictly needed. - // Even if you can technically run Tailscale without it (by - // manually configuring nftables or userspace mode), we still - // mark this as "Depends" because our previous experiment in - // https://github.com/tailscale/tailscale/issues/9236 of making - // it only Recommends caused too many problems. Until our - // nftables table is more mature, we'd rather err on the side of - // wasting a little disk by including iptables for people who - // might not need it rather than handle reports of it being - // missing. - "iptables", - }, - Recommends: []string{ - "tailscale-archive-keyring (>= 1.35.181)", - // The "ip" command isn't needed since 2021-11-01 in - // 408b0923a61972ed but kept as an option as of - // 2021-11-18 in d24ed3f68e35e802d531371. See - // https://github.com/tailscale/tailscale/issues/391. - // We keep it recommended because it's usually - // installed anyway and it's useful for debugging. But - // we can live without it, so it's not Depends. - "iproute2", - }, - Replaces: []string{"tailscale-relay"}, - Conflicts: []string{"tailscale-relay"}, - }, - }) - pkg, err := nfpm.Get("deb") - if err != nil { - return nil, err - } - - filename := fmt.Sprintf("tailscale_%s_%s.deb", b.Version.Short, arch) - log.Printf("Building %s", filename) - f, err := os.Create(filepath.Join(b.Out, filename)) - if err != nil { - return nil, err - } - defer f.Close() - if err := pkg.Package(info, f); err != nil { - return nil, err - } - if err := f.Close(); err != nil { - return nil, err - } - - return []string{filename}, nil -} - -type rpmTarget struct { - goEnv map[string]string - signer dist.Signer -} - -func (t *rpmTarget) os() string { - return t.goEnv["GOOS"] -} - -func (t *rpmTarget) arch() string { - return t.goEnv["GOARCH"] -} - -func (t *rpmTarget) String() string { - return fmt.Sprintf("linux/%s/rpm", t.arch()) -} - -func (t *rpmTarget) Build(b *dist.Build) ([]string, error) { - if t.os() != "linux" { - return nil, errors.New("rpm only supported on linux") - } - - if err := b.BuildWebClientAssets(); err != nil { - return nil, err - } - ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goEnv) - if err != nil { - return nil, err - } - tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goEnv) - if err != nil { - return nil, err - } - - tailscaledDir, err := b.GoPkg("tailscale.com/cmd/tailscaled") - if err != nil { - return nil, err - } - repoDir, err := b.GoPkg("tailscale.com") - if err != nil { - return nil, err - } - - arch := rpmArch(t.arch()) - contents, err := files.PrepareForPackager(files.Contents{ - &files.Content{ - Type: files.TypeFile, - Source: ts, - Destination: "/usr/bin/tailscale", - }, - &files.Content{ - Type: files.TypeFile, - Source: tsd, - Destination: "/usr/sbin/tailscaled", - }, - &files.Content{ - Type: files.TypeFile, - Source: filepath.Join(tailscaledDir, "tailscaled.service"), - Destination: "/lib/systemd/system/tailscaled.service", - }, - &files.Content{ - Type: files.TypeConfigNoReplace, - Source: filepath.Join(tailscaledDir, "tailscaled.defaults"), - Destination: "/etc/default/tailscaled", - }, - // SELinux policy on e.g. CentOS 8 forbids writing to /var/cache. - // Creating an empty directory at install time resolves this issue. - &files.Content{ - Type: files.TypeDir, - Destination: "/var/cache/tailscale", - }, - }, 0, "rpm", false) - if err != nil { - return nil, err - } - info := nfpm.WithDefaults(&nfpm.Info{ - Name: "tailscale", - Arch: arch, - Platform: "linux", - Version: b.Version.Short, - Maintainer: "Tailscale Inc ", - Description: "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", - Homepage: "https://www.tailscale.com", - License: "MIT", - Overridables: nfpm.Overridables{ - Contents: contents, - Scripts: nfpm.Scripts{ - PostInstall: filepath.Join(repoDir, "release/rpm/rpm.postinst.sh"), - PreRemove: filepath.Join(repoDir, "release/rpm/rpm.prerm.sh"), - PostRemove: filepath.Join(repoDir, "release/rpm/rpm.postrm.sh"), - }, - Depends: []string{"iptables", "iproute"}, - Replaces: []string{"tailscale-relay"}, - Conflicts: []string{"tailscale-relay"}, - RPM: nfpm.RPM{ - Group: "Network", - Signature: nfpm.RPMSignature{ - PackageSignature: nfpm.PackageSignature{ - SignFn: t.signer, - }, - }, - }, - }, - }) - pkg, err := nfpm.Get("rpm") - if err != nil { - return nil, err - } - - filename := fmt.Sprintf("tailscale_%s_%s.rpm", b.Version.Short, arch) - log.Printf("Building %s", filename) - - f, err := os.Create(filepath.Join(b.Out, filename)) - if err != nil { - return nil, err - } - defer f.Close() - if err := pkg.Package(info, f); err != nil { - return nil, err - } - if err := f.Close(); err != nil { - return nil, err - } - - return []string{filename}, nil -} - -// debArch returns the debian arch name for the given Go arch name. -// nfpm also does this translation internally, but we need to do it outside nfpm -// because we also need the filename to be correct. -func debArch(arch string) string { - switch arch { - case "386": - return "i386" - case "arm": - // TODO: this is supposed to be "armel" for GOARM=5, and "armhf" for - // GOARM=6 and 7. But we have some tech debt to pay off here before we - // can ship more than 1 ARM deb, so for now match redo's behavior of - // shipping armv5 binaries in an armv7 trenchcoat. - return "armhf" - case "mipsle": - return "mipsel" - case "mips64le": - return "mips64el" - default: - return arch - } -} - -// rpmArch returns the RPM arch name for the given Go arch name. -// nfpm also does this translation internally, but we need to do it outside nfpm -// because we also need the filename to be correct. -func rpmArch(arch string) string { - switch arch { - case "amd64": - return "x86_64" - case "386": - return "i386" - case "arm": - return "armv7hl" - case "arm64": - return "aarch64" - case "mipsle": - return "mipsel" - case "mips64le": - return "mips64el" - default: - return arch - } -} diff --git a/release/dist/unixpkgs/targets.go b/release/dist/unixpkgs/targets.go deleted file mode 100644 index 42bab6d3b2685..0000000000000 --- a/release/dist/unixpkgs/targets.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package unixpkgs - -import ( - "fmt" - "sort" - "strings" - - "tailscale.com/release/dist" - - _ "github.com/goreleaser/nfpm/v2/deb" - _ "github.com/goreleaser/nfpm/v2/rpm" -) - -type Signers struct { - Tarball dist.Signer - RPM dist.Signer -} - -func Targets(signers Signers) []dist.Target { - var ret []dist.Target - for goosgoarch := range tarballs { - goos, goarch := splitGoosGoarch(goosgoarch) - ret = append(ret, &tgzTarget{ - goEnv: map[string]string{ - "GOOS": goos, - "GOARCH": goarch, - }, - signer: signers.Tarball, - }) - } - for goosgoarch := range debs { - goos, goarch := splitGoosGoarch(goosgoarch) - ret = append(ret, &debTarget{ - goEnv: map[string]string{ - "GOOS": goos, - "GOARCH": goarch, - }, - }) - } - for goosgoarch := range rpms { - goos, goarch := splitGoosGoarch(goosgoarch) - ret = append(ret, &rpmTarget{ - goEnv: map[string]string{ - "GOOS": goos, - "GOARCH": goarch, - }, - signer: signers.RPM, - }) - } - - // Special case: AMD Geode is 386 with softfloat. Tarballs only since it's - // an ancient architecture. - ret = append(ret, &tgzTarget{ - filenameArch: "geode", - goEnv: map[string]string{ - "GOOS": "linux", - "GOARCH": "386", - "GO386": "softfloat", - }, - signer: signers.Tarball, - }) - - sort.Slice(ret, func(i, j int) bool { - return ret[i].String() < ret[j].String() - }) - - return ret -} - -var ( - tarballs = map[string]bool{ - "linux/386": true, - "linux/amd64": true, - "linux/arm": true, - "linux/arm64": true, - "linux/mips64": true, - "linux/mips64le": true, - "linux/mips": true, - "linux/mipsle": true, - "linux/riscv64": true, - // TODO: more tarballs we could distribute, but don't currently. Leaving - // out for initial parity with redo. - // "darwin/amd64": true, - // "darwin/arm64": true, - // "freebsd/amd64": true, - // "openbsd/amd64": true, - } - - debs = map[string]bool{ - "linux/386": true, - "linux/amd64": true, - "linux/arm": true, - "linux/arm64": true, - "linux/riscv64": true, - "linux/mipsle": true, - "linux/mips64le": true, - "linux/mips": true, - // Debian does not support big endian mips64. Leave that out until we know - // we need it. - // "linux/mips64": true, - } - - rpms = map[string]bool{ - "linux/386": true, - "linux/amd64": true, - "linux/arm": true, - "linux/arm64": true, - "linux/riscv64": true, - "linux/mipsle": true, - "linux/mips64le": true, - // Fedora only supports little endian mipses. Maybe some other distribution - // supports big-endian? Leave them out for now. - // "linux/mips": true, - // "linux/mips64": true, - } -) - -func splitGoosGoarch(s string) (string, string) { - goos, goarch, ok := strings.Cut(s, "/") - if !ok { - panic(fmt.Sprintf("invalid target %q", s)) - } - return goos, goarch -} diff --git a/release/release.go b/release/release.go deleted file mode 100644 index a8d0e6b62e8d7..0000000000000 --- a/release/release.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package release provides functionality for building client releases. -package release - -import "embed" - -// This contains all files in the release directory, -// notably the files needed for deb, rpm, and similar packages. -// Because we assign this to the blank identifier, it does not actually embed the files. -// However, this does cause `go mod vendor` to include the files when vendoring the package. -// -//go:embed * -var _ embed.FS diff --git a/release/rpm/rpm.postinst.sh b/release/rpm/rpm.postinst.sh deleted file mode 100755 index 3d264c5f60b18..0000000000000 --- a/release/rpm/rpm.postinst.sh +++ /dev/null @@ -1,41 +0,0 @@ -# $1 == 1 for initial installation. -# $1 == 2 for upgrades. - -if [ $1 -eq 1 ] ; then - # Normally, the tailscale-relay package would request shutdown of - # its service before uninstallation. Unfortunately, the - # tailscale-relay package we distributed doesn't have those - # scriptlets. We definitely want relaynode to be stopped when - # installing tailscaled though, so we blindly try to turn off - # relaynode here. - # - # However, we also want this package installation to look like an - # upgrade from relaynode! Therefore, if relaynode is currently - # enabled, we want to also enable tailscaled. If relaynode is - # currently running, we also want to start tailscaled. - # - # If there doesn't seem to be an active or enabled relaynode on - # the system, we follow the RPM convention for package installs, - # which is to not enable or start the service. - relaynode_enabled=0 - relaynode_running=0 - if systemctl is-enabled tailscale-relay.service >/dev/null 2>&1; then - relaynode_enabled=1 - fi - if systemctl is-active tailscale-relay.service >/dev/null 2>&1; then - relaynode_running=1 - fi - - systemctl --no-reload disable tailscale-relay.service >/dev/null 2>&1 || : - systemctl stop tailscale-relay.service >/dev/null 2>&1 || : - - if [ $relaynode_enabled -eq 1 ]; then - systemctl enable tailscaled.service >/dev/null 2>&1 || : - else - systemctl preset tailscaled.service >/dev/null 2>&1 || : - fi - - if [ $relaynode_running -eq 1 ]; then - systemctl start tailscaled.service >/dev/null 2>&1 || : - fi -fi diff --git a/release/rpm/rpm.postrm.sh b/release/rpm/rpm.postrm.sh deleted file mode 100755 index d74f7e9deac77..0000000000000 --- a/release/rpm/rpm.postrm.sh +++ /dev/null @@ -1,8 +0,0 @@ -# $1 == 0 for uninstallation. -# $1 == 1 for removing old package during upgrade. - -systemctl daemon-reload >/dev/null 2>&1 || : -if [ $1 -ge 1 ] ; then - # Package upgrade, not uninstall - systemctl try-restart tailscaled.service >/dev/null 2>&1 || : -fi diff --git a/release/rpm/rpm.prerm.sh b/release/rpm/rpm.prerm.sh deleted file mode 100755 index 682c01bd574d8..0000000000000 --- a/release/rpm/rpm.prerm.sh +++ /dev/null @@ -1,8 +0,0 @@ -# $1 == 0 for uninstallation. -# $1 == 1 for removing old package during upgrade. - -if [ $1 -eq 0 ] ; then - # Package removal, not upgrade - systemctl --no-reload disable tailscaled.service > /dev/null 2>&1 || : - systemctl stop tailscaled.service > /dev/null 2>&1 || : -fi diff --git a/remove-unused.sh b/remove-unused.sh new file mode 100755 index 0000000000000..9399654ed461f --- /dev/null +++ b/remove-unused.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +function remove_unused() { + git rm -rf --ignore-unmatch \ + .github \ + **/*_test.go \ + tstest/ \ + release/ \ + cmd/ \ + util/winutil/s4u/ \ + k8s-operator/ \ + ssh/ +} + +remove_unused +remove_unused + +go mod tidy +git commit -a -m "Remove unused" diff --git a/rename-module.sh b/rename-module.sh new file mode 100755 index 0000000000000..d23d81fcd1f4d --- /dev/null +++ b/rename-module.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +rules=' +id: replace-module +language: go +rule: + kind: import_spec + pattern: $OLD_IMPORT +constraints: + OLD_IMPORT: + has: + field: path + regex: ^"tailscale.com +transform: + NEW_IMPORT: + replace: + source: $OLD_IMPORT + replace: tailscale.com(?.*) + by: github.com/sagernet/tailscale$PATH +fix: $NEW_IMPORT +' + +sg scan --inline-rules "$rules" -U + +sed -i 's|module tailscale.com|module github.com/sagernet/tailscale|' go.mod + +go mod tidy + +gci write . + +git commit -m "Rename module" -a diff --git a/safesocket/basic_test.go b/safesocket/basic_test.go deleted file mode 100644 index 292a3438a0e75..0000000000000 --- a/safesocket/basic_test.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package safesocket - -import ( - "context" - "fmt" - "path/filepath" - "runtime" - "testing" -) - -// downgradeSDDL is a no-op test helper on non-Windows systems. -var downgradeSDDL = func() func() { return func() {} } - -func TestBasics(t *testing.T) { - // Make the socket in a temp dir rather than the cwd - // so that the test can be run from a mounted filesystem (#2367). - dir := t.TempDir() - var sock string - if runtime.GOOS != "windows" { - sock = filepath.Join(dir, "test") - } else { - sock = fmt.Sprintf(`\\.\pipe\tailscale-test`) - t.Cleanup(downgradeSDDL()) - } - - ln, err := Listen(sock) - if err != nil { - t.Fatal(err) - } - - errs := make(chan error, 2) - - go func() { - s, err := ln.Accept() - if err != nil { - errs <- err - return - } - ln.Close() - s.Write([]byte("hello")) - - b := make([]byte, 1024) - n, err := s.Read(b) - if err != nil { - errs <- err - return - } - t.Logf("server read %d bytes.", n) - if string(b[:n]) != "world" { - errs <- fmt.Errorf("got %#v, expected %#v\n", string(b[:n]), "world") - return - } - s.Close() - errs <- nil - }() - - go func() { - c, err := ConnectContext(context.Background(), sock) - if err != nil { - errs <- err - return - } - c.Write([]byte("world")) - b := make([]byte, 1024) - n, err := c.Read(b) - if err != nil { - errs <- err - return - } - if string(b[:n]) != "hello" { - errs <- fmt.Errorf("got %#v, expected %#v\n", string(b[:n]), "hello") - } - c.Close() - errs <- nil - }() - - for range 2 { - if err := <-errs; err != nil { - t.Fatal(err) - } - } -} diff --git a/safesocket/pipe_windows_test.go b/safesocket/pipe_windows_test.go deleted file mode 100644 index 054781f235abd..0000000000000 --- a/safesocket/pipe_windows_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package safesocket - -import ( - "fmt" - "testing" - - "tailscale.com/util/winutil" -) - -func init() { - // downgradeSDDL is a test helper that downgrades the windowsSDDL variable if - // the currently running user does not have sufficient priviliges to set the - // SDDL. - downgradeSDDL = func() (cleanup func()) { - // The current default descriptor can not be set by mere mortal users, - // so we need to undo that for executing tests as a regular user. - if !winutil.IsCurrentProcessElevated() { - var orig string - orig, windowsSDDL = windowsSDDL, "" - return func() { windowsSDDL = orig } - } - return func() {} - } -} - -// TestExpectedWindowsTypes is a copy of TestBasics specialized for Windows with -// type assertions about the types of listeners and conns we expect. -func TestExpectedWindowsTypes(t *testing.T) { - t.Cleanup(downgradeSDDL()) - const sock = `\\.\pipe\tailscale-test` - ln, err := Listen(sock) - if err != nil { - t.Fatal(err) - } - if got, want := fmt.Sprintf("%T", ln), "*safesocket.winIOPipeListener"; got != want { - t.Errorf("got listener type %q; want %q", got, want) - } - - errs := make(chan error, 2) - - go func() { - s, err := ln.Accept() - if err != nil { - errs <- err - return - } - ln.Close() - - wcc, ok := s.(*WindowsClientConn) - if !ok { - s.Close() - errs <- fmt.Errorf("accepted type %T; want WindowsClientConn", s) - return - } - if wcc.winioPipeConn.Fd() == 0 { - t.Error("accepted conn had unexpected zero fd") - } - if wcc.token == 0 { - t.Error("accepted conn had unexpected zero token") - } - - s.Write([]byte("hello")) - - b := make([]byte, 1024) - n, err := s.Read(b) - if err != nil { - errs <- err - return - } - t.Logf("server read %d bytes.", n) - if string(b[:n]) != "world" { - errs <- fmt.Errorf("got %#v, expected %#v\n", string(b[:n]), "world") - return - } - s.Close() - errs <- nil - }() - - go func() { - c, err := Connect(sock) - if err != nil { - errs <- err - return - } - c.Write([]byte("world")) - b := make([]byte, 1024) - n, err := c.Read(b) - if err != nil { - errs <- err - return - } - if string(b[:n]) != "hello" { - errs <- fmt.Errorf("got %#v, expected %#v\n", string(b[:n]), "hello") - } - c.Close() - errs <- nil - }() - - for range 2 { - if err := <-errs; err != nil { - t.Fatal(err) - } - } -} diff --git a/safesocket/safesocket_darwin.go b/safesocket/safesocket_darwin.go index 62e6f7e6de340..170931b715590 100644 --- a/safesocket/safesocket_darwin.go +++ b/safesocket/safesocket_darwin.go @@ -17,7 +17,7 @@ import ( "sync" "time" - "tailscale.com/version" + "github.com/sagernet/tailscale/version" ) func init() { diff --git a/safesocket/safesocket_test.go b/safesocket/safesocket_test.go deleted file mode 100644 index 3f36a1cf6ca1f..0000000000000 --- a/safesocket/safesocket_test.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package safesocket - -import "testing" - -func TestLocalTCPPortAndToken(t *testing.T) { - // Just test that it compiles for now (is available on all platforms). - port, token, err := LocalTCPPortAndToken() - t.Logf("got %v, %s, %v", port, token, err) -} diff --git a/safeweb/http_test.go b/safeweb/http_test.go deleted file mode 100644 index 852ce326ba374..0000000000000 --- a/safeweb/http_test.go +++ /dev/null @@ -1,649 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package safeweb - -import ( - "io" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/gorilla/csrf" -) - -func TestCompleteCORSConfig(t *testing.T) { - _, err := NewServer(Config{AccessControlAllowOrigin: []string{"https://foobar.com"}}) - if err == nil { - t.Fatalf("expected error when AccessControlAllowOrigin is provided without AccessControlAllowMethods") - } - - _, err = NewServer(Config{AccessControlAllowMethods: []string{"GET", "POST"}}) - if err == nil { - t.Fatalf("expected error when AccessControlAllowMethods is provided without AccessControlAllowOrigin") - } - - _, err = NewServer(Config{AccessControlAllowOrigin: []string{"https://foobar.com"}, AccessControlAllowMethods: []string{"GET", "POST"}}) - if err != nil { - t.Fatalf("error creating server with complete CORS configuration: %v", err) - } -} - -func TestPostRequestContentTypeValidation(t *testing.T) { - tests := []struct { - name string - browserRoute bool - contentType string - wantErr bool - }{ - { - name: "API routes should accept `application/json` content-type", - browserRoute: false, - contentType: "application/json", - wantErr: false, - }, - { - name: "API routes should reject `application/x-www-form-urlencoded` content-type", - browserRoute: false, - contentType: "application/x-www-form-urlencoded", - wantErr: true, - }, - { - name: "Browser routes should accept `application/x-www-form-urlencoded` content-type", - browserRoute: true, - contentType: "application/x-www-form-urlencoded", - wantErr: false, - }, - { - name: "non Browser routes should accept `application/json` content-type", - browserRoute: true, - contentType: "application/json", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - var s *Server - var err error - if tt.browserRoute { - s, err = NewServer(Config{BrowserMux: h}) - } else { - s, err = NewServer(Config{APIMux: h}) - } - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest("POST", "/", nil) - req.Header.Set("Content-Type", tt.contentType) - - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - if tt.wantErr && resp.StatusCode != http.StatusBadRequest { - t.Fatalf("content type validation failed: got %v; want %v", resp.StatusCode, http.StatusBadRequest) - } - }) - } -} - -func TestAPIMuxCrossOriginResourceSharingHeaders(t *testing.T) { - tests := []struct { - name string - httpMethod string - wantCORSHeaders bool - corsOrigins []string - corsMethods []string - }{ - { - name: "do not set CORS headers for non-OPTIONS requests", - corsOrigins: []string{"https://foobar.com"}, - corsMethods: []string{"GET", "POST", "HEAD"}, - httpMethod: "GET", - wantCORSHeaders: false, - }, - { - name: "set CORS headers for non-OPTIONS requests", - corsOrigins: []string{"https://foobar.com"}, - corsMethods: []string{"GET", "POST", "HEAD"}, - httpMethod: "OPTIONS", - wantCORSHeaders: true, - }, - { - name: "do not serve CORS headers for OPTIONS requests with no configured origins", - httpMethod: "OPTIONS", - wantCORSHeaders: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - s, err := NewServer(Config{ - APIMux: h, - AccessControlAllowOrigin: tt.corsOrigins, - AccessControlAllowMethods: tt.corsMethods, - }) - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest(tt.httpMethod, "/", nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - - if (resp.Header.Get("Access-Control-Allow-Origin") == "") == tt.wantCORSHeaders { - t.Fatalf("access-control-allow-origin want: %v; got: %v", tt.wantCORSHeaders, resp.Header.Get("Access-Control-Allow-Origin")) - } - }) - } -} - -func TestCSRFProtection(t *testing.T) { - tests := []struct { - name string - apiRoute bool - passCSRFToken bool - wantStatus int - }{ - { - name: "POST requests to non-API routes require CSRF token and fail if not provided", - apiRoute: false, - passCSRFToken: false, - wantStatus: http.StatusForbidden, - }, - { - name: "POST requests to non-API routes require CSRF token and pass if provided", - apiRoute: false, - passCSRFToken: true, - wantStatus: http.StatusOK, - }, - { - name: "POST requests to /api/ routes do not require CSRF token", - apiRoute: true, - passCSRFToken: false, - wantStatus: http.StatusOK, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - var s *Server - var err error - if tt.apiRoute { - s, err = NewServer(Config{APIMux: h}) - } else { - s, err = NewServer(Config{BrowserMux: h}) - } - if err != nil { - t.Fatal(err) - } - defer s.Close() - - // construct the test request - req := httptest.NewRequest("POST", "/", nil) - - // send JSON for API routes, form data for browser routes - if tt.apiRoute { - req.Header.Set("Content-Type", "application/json") - } else { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - - // retrieve CSRF cookie & pass it in the test request - // ref: https://github.com/gorilla/csrf/blob/main/csrf_test.go#L344-L347 - var token string - if tt.passCSRFToken { - h.Handle("/csrf", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - token = csrf.Token(r) - })) - get := httptest.NewRequest("GET", "/csrf", nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, get) - resp := w.Result() - - // pass the token & cookie in our subsequent test request - req.Header.Set("X-CSRF-Token", token) - for _, c := range resp.Cookies() { - req.AddCookie(c) - } - } - - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - - if resp.StatusCode != tt.wantStatus { - t.Fatalf("csrf protection check failed: got %v; want %v", resp.StatusCode, tt.wantStatus) - } - }) - } -} - -func TestContentSecurityPolicyHeader(t *testing.T) { - tests := []struct { - name string - csp CSP - apiRoute bool - wantCSP string - }{ - { - name: "default CSP", - wantCSP: `base-uri 'self'; block-all-mixed-content; default-src 'self'; form-action 'self'; frame-ancestors 'none';`, - }, - { - name: "custom CSP", - csp: CSP{ - "default-src": {"'self'", "https://tailscale.com"}, - "upgrade-insecure-requests": nil, - }, - wantCSP: `default-src 'self' https://tailscale.com; upgrade-insecure-requests;`, - }, - { - name: "`/api/*` routes do not get CSP headers", - apiRoute: true, - wantCSP: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - var s *Server - var err error - if tt.apiRoute { - s, err = NewServer(Config{APIMux: h, CSP: tt.csp}) - } else { - s, err = NewServer(Config{BrowserMux: h, CSP: tt.csp}) - } - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - - if got := resp.Header.Get("Content-Security-Policy"); got != tt.wantCSP { - t.Fatalf("content security policy want: %q; got: %q", tt.wantCSP, got) - } - }) - } -} - -func TestCSRFCookieSecureMode(t *testing.T) { - tests := []struct { - name string - secureMode bool - wantSecure bool - }{ - { - name: "CSRF cookie should be secure when server is in secure context", - secureMode: true, - wantSecure: true, - }, - { - name: "CSRF cookie should not be secure when server is not in secure context", - secureMode: false, - wantSecure: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - s, err := NewServer(Config{BrowserMux: h, SecureContext: tt.secureMode}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - - cookie := resp.Cookies()[0] - if (cookie.Secure == tt.wantSecure) == false { - t.Fatalf("csrf cookie secure flag want: %v; got: %v", tt.wantSecure, cookie.Secure) - } - }) - } -} - -func TestRefererPolicy(t *testing.T) { - tests := []struct { - name string - browserRoute bool - wantRefererPolicy bool - }{ - { - name: "BrowserMux routes get Referer-Policy headers", - browserRoute: true, - wantRefererPolicy: true, - }, - { - name: "APIMux routes do not get Referer-Policy headers", - browserRoute: false, - wantRefererPolicy: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - var s *Server - var err error - if tt.browserRoute { - s, err = NewServer(Config{BrowserMux: h}) - } else { - s, err = NewServer(Config{APIMux: h}) - } - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - - if (resp.Header.Get("Referer-Policy") == "") == tt.wantRefererPolicy { - t.Fatalf("referer policy want: %v; got: %v", tt.wantRefererPolicy, resp.Header.Get("Referer-Policy")) - } - }) - } -} - -func TestCSPAllowInlineStyles(t *testing.T) { - for _, allow := range []bool{false, true} { - t.Run(strconv.FormatBool(allow), func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - s, err := NewServer(Config{BrowserMux: h, CSPAllowInlineStyles: allow}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - - csp := resp.Header.Get("Content-Security-Policy") - allowsStyles := strings.Contains(csp, "style-src 'self' 'unsafe-inline'") - if allowsStyles != allow { - t.Fatalf("CSP inline styles want: %v, got: %v in %q", allow, allowsStyles, csp) - } - }) - } -} - -func TestRouting(t *testing.T) { - for _, tt := range []struct { - desc string - browserPatterns []string - apiPatterns []string - requestPath string - want string - }{ - { - desc: "only browser mux", - browserPatterns: []string{"/"}, - requestPath: "/index.html", - want: "browser", - }, - { - desc: "only API mux", - apiPatterns: []string{"/api/"}, - requestPath: "/api/foo", - want: "api", - }, - { - desc: "browser mux match", - browserPatterns: []string{"/content/"}, - apiPatterns: []string{"/api/"}, - requestPath: "/content/index.html", - want: "browser", - }, - { - desc: "API mux match", - browserPatterns: []string{"/content/"}, - apiPatterns: []string{"/api/"}, - requestPath: "/api/foo", - want: "api", - }, - { - desc: "browser wildcard match", - browserPatterns: []string{"/"}, - apiPatterns: []string{"/api/"}, - requestPath: "/index.html", - want: "browser", - }, - { - desc: "API wildcard match", - browserPatterns: []string{"/content/"}, - apiPatterns: []string{"/"}, - requestPath: "/api/foo", - want: "api", - }, - { - desc: "path conflict", - browserPatterns: []string{"/foo/"}, - apiPatterns: []string{"/foo/bar/"}, - requestPath: "/foo/bar/baz", - want: "api", - }, - { - desc: "no match", - browserPatterns: []string{"/foo/"}, - apiPatterns: []string{"/bar/"}, - requestPath: "/baz", - want: "404 page not found", - }, - } { - t.Run(tt.desc, func(t *testing.T) { - bm := &http.ServeMux{} - for _, p := range tt.browserPatterns { - bm.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("browser")) - }) - } - am := &http.ServeMux{} - for _, p := range tt.apiPatterns { - am.HandleFunc(p, func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("api")) - }) - } - s, err := NewServer(Config{BrowserMux: bm, APIMux: am}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest("GET", tt.requestPath, nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp, err := io.ReadAll(w.Result().Body) - if err != nil { - t.Fatal(err) - } - if got := strings.TrimSpace(string(resp)); got != tt.want { - t.Errorf("got response %q, want %q", got, tt.want) - } - }) - } -} - -func TestGetMoreSpecificPattern(t *testing.T) { - for _, tt := range []struct { - desc string - a string - b string - want handlerType - }{ - { - desc: "identical", - a: "/foo/bar", - b: "/foo/bar", - want: unknownHandler, - }, - { - desc: "identical prefix", - a: "/foo/bar/", - b: "/foo/bar/", - want: unknownHandler, - }, - { - desc: "trailing slash", - a: "/foo", - b: "/foo/", // path.Clean will strip the trailing slash. - want: unknownHandler, - }, - { - desc: "same prefix", - a: "/foo/bar/quux", - b: "/foo/bar/", // path.Clean will strip the trailing slash. - want: apiHandler, - }, - { - desc: "almost same prefix, but not a path component", - a: "/goat/sheep/cheese", - b: "/goat/sheepcheese/", // path.Clean will strip the trailing slash. - want: apiHandler, - }, - { - desc: "attempt to make less-specific pattern look more specific", - a: "/goat/cat/buddy", - b: "/goat/../../../../../../../cat", // path.Clean catches this foolishness - want: apiHandler, - }, - { - desc: "2 names for / (1)", - a: "/", - b: "/../../../../../../", - want: unknownHandler, - }, - { - desc: "2 names for / (2)", - a: "/", - b: "///////", - want: unknownHandler, - }, - { - desc: "root-level", - a: "/latest", - b: "/", // path.Clean will NOT strip the trailing slash. - want: apiHandler, - }, - } { - t.Run(tt.desc, func(t *testing.T) { - got := checkHandlerType(tt.a, tt.b) - if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestStrictTransportSecurityOptions(t *testing.T) { - tests := []struct { - name string - options string - secureContext bool - expect string - }{ - { - name: "off by default", - }, - { - name: "default HSTS options in the secure context", - secureContext: true, - expect: DefaultStrictTransportSecurityOptions, - }, - { - name: "custom options sent in the secure context", - options: DefaultStrictTransportSecurityOptions + "; includeSubDomains", - secureContext: true, - expect: DefaultStrictTransportSecurityOptions + "; includeSubDomains", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := &http.ServeMux{} - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) - })) - s, err := NewServer(Config{BrowserMux: h, SecureContext: tt.secureContext, StrictTransportSecurityOptions: tt.options}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - s.h.Handler.ServeHTTP(w, req) - resp := w.Result() - - if cmp.Diff(tt.expect, resp.Header.Get("Strict-Transport-Security")) != "" { - t.Fatalf("HSTS want: %q; got: %q", tt.expect, resp.Header.Get("Strict-Transport-Security")) - } - }) - } -} - -func TestOverrideHTTPServer(t *testing.T) { - s, err := NewServer(Config{}) - if err != nil { - t.Fatalf("NewServer: %v", err) - } - if s.h.IdleTimeout != 0 { - t.Fatalf("got %v; want 0", s.h.IdleTimeout) - } - - c := http.Server{ - IdleTimeout: 10 * time.Second, - } - - s, err = NewServer(Config{HTTPServer: &c}) - if err != nil { - t.Fatalf("NewServer: %v", err) - } - - if s.h.IdleTimeout != c.IdleTimeout { - t.Fatalf("got %v; want %v", s.h.IdleTimeout, c.IdleTimeout) - } -} diff --git a/sessionrecording/connect.go b/sessionrecording/connect.go index 94761393f885d..1f2f7d1bf5d1e 100644 --- a/sessionrecording/connect.go +++ b/sessionrecording/connect.go @@ -19,10 +19,10 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/util/httpm" + "github.com/sagernet/tailscale/util/multierr" "golang.org/x/net/http2" - "tailscale.com/tailcfg" - "tailscale.com/util/httpm" - "tailscale.com/util/multierr" ) const ( diff --git a/sessionrecording/connect_test.go b/sessionrecording/connect_test.go deleted file mode 100644 index c0fcf6d40c617..0000000000000 --- a/sessionrecording/connect_test.go +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package sessionrecording - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/json" - "io" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "testing" - "time" - - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" -) - -func TestConnectToRecorder(t *testing.T) { - tests := []struct { - desc string - http2 bool - // setup returns a recorder server mux, and a channel which sends the - // hash of the recording uploaded to it. The channel is expected to - // fire only once. - setup func(t *testing.T) (*http.ServeMux, <-chan []byte) - wantErr bool - }{ - { - desc: "v1 recorder", - setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) { - uploadHash := make(chan []byte, 1) - mux := http.NewServeMux() - mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) { - hash := sha256.New() - if _, err := io.Copy(hash, r.Body); err != nil { - t.Error(err) - } - uploadHash <- hash.Sum(nil) - }) - return mux, uploadHash - }, - }, - { - desc: "v2 recorder", - http2: true, - setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) { - uploadHash := make(chan []byte, 1) - mux := http.NewServeMux() - mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) { - t.Error("received request to v1 endpoint") - http.Error(w, "not found", http.StatusNotFound) - }) - mux.HandleFunc("POST /v2/record", func(w http.ResponseWriter, r *http.Request) { - // Force the status to send to unblock the client waiting - // for it. - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - - body := &readCounter{r: r.Body} - hash := sha256.New() - ctx, cancel := context.WithCancel(r.Context()) - go func() { - defer cancel() - if _, err := io.Copy(hash, body); err != nil { - t.Error(err) - } - }() - - // Send acks for received bytes. - tick := time.NewTicker(time.Millisecond) - defer tick.Stop() - enc := json.NewEncoder(w) - outer: - for { - select { - case <-ctx.Done(): - break outer - case <-tick.C: - if err := enc.Encode(v2ResponseFrame{Ack: body.sent.Load()}); err != nil { - t.Errorf("writing ack frame: %v", err) - break outer - } - } - } - - uploadHash <- hash.Sum(nil) - }) - // Probing HEAD endpoint which always returns 200 OK. - mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {}) - return mux, uploadHash - }, - }, - { - desc: "v2 recorder no acks", - http2: true, - wantErr: true, - setup: func(t *testing.T) (*http.ServeMux, <-chan []byte) { - // Make the client no-ack timeout quick for the test. - oldAckWindow := uploadAckWindow - uploadAckWindow = 100 * time.Millisecond - t.Cleanup(func() { uploadAckWindow = oldAckWindow }) - - uploadHash := make(chan []byte, 1) - mux := http.NewServeMux() - mux.HandleFunc("POST /record", func(w http.ResponseWriter, r *http.Request) { - t.Error("received request to v1 endpoint") - http.Error(w, "not found", http.StatusNotFound) - }) - mux.HandleFunc("POST /v2/record", func(w http.ResponseWriter, r *http.Request) { - // Force the status to send to unblock the client waiting - // for it. - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - - // Consume the whole request body but don't send any acks - // back. - hash := sha256.New() - if _, err := io.Copy(hash, r.Body); err != nil { - t.Error(err) - } - // Goes in the channel buffer, non-blocking. - uploadHash <- hash.Sum(nil) - - // Block until the parent test case ends to prevent the - // request termination. We want to exercise the ack - // tracking logic specifically. - ctx, cancel := context.WithCancel(r.Context()) - t.Cleanup(cancel) - <-ctx.Done() - }) - mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {}) - return mux, uploadHash - }, - }, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - mux, uploadHash := tt.setup(t) - - srv := httptest.NewUnstartedServer(mux) - if tt.http2 { - // Wire up h2c-compatible HTTP/2 server. This is optional - // because the v1 recorder didn't support HTTP/2 and we try to - // mimic that. - h2s := &http2.Server{} - srv.Config.Handler = h2c.NewHandler(mux, h2s) - if err := http2.ConfigureServer(srv.Config, h2s); err != nil { - t.Errorf("configuring HTTP/2 support in server: %v", err) - } - } - srv.Start() - t.Cleanup(srv.Close) - - d := new(net.Dialer) - - ctx := context.Background() - w, _, errc, err := ConnectToRecorder(ctx, []netip.AddrPort{netip.MustParseAddrPort(srv.Listener.Addr().String())}, d.DialContext) - if err != nil { - t.Fatalf("ConnectToRecorder: %v", err) - } - - // Send some random data and hash it to compare with the recorded - // data hash. - hash := sha256.New() - const numBytes = 1 << 20 // 1MB - if _, err := io.CopyN(io.MultiWriter(w, hash), rand.Reader, numBytes); err != nil { - t.Fatalf("writing recording data: %v", err) - } - if err := w.Close(); err != nil { - t.Fatalf("closing recording stream: %v", err) - } - if err := <-errc; err != nil && !tt.wantErr { - t.Fatalf("error from the channel: %v", err) - } else if err == nil && tt.wantErr { - t.Fatalf("did not receive expected error from the channel") - } - - if recv, sent := <-uploadHash, hash.Sum(nil); !bytes.Equal(recv, sent) { - t.Errorf("mismatch in recording data hash, sent %x, received %x", sent, recv) - } - }) - } -} diff --git a/sessionrecording/header.go b/sessionrecording/header.go index 4806f6585f976..94868fb62fe92 100644 --- a/sessionrecording/header.go +++ b/sessionrecording/header.go @@ -3,7 +3,7 @@ package sessionrecording -import "tailscale.com/tailcfg" +import "github.com/sagernet/tailscale/tailcfg" // CastHeader is the header of an asciinema file. type CastHeader struct { diff --git a/smallzstd/zstd_test.go b/smallzstd/zstd_test.go deleted file mode 100644 index d1225bfac6058..0000000000000 --- a/smallzstd/zstd_test.go +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package smallzstd - -import ( - "os" - "testing" - - "github.com/klauspost/compress/zstd" -) - -func BenchmarkSmallEncoder(b *testing.B) { - benchEncoder(b, func() (*zstd.Encoder, error) { return NewEncoder(nil) }) -} - -func BenchmarkSmallEncoderWithBuild(b *testing.B) { - benchEncoderWithConstruction(b, func() (*zstd.Encoder, error) { return NewEncoder(nil) }) -} - -func BenchmarkStockEncoder(b *testing.B) { - benchEncoder(b, func() (*zstd.Encoder, error) { return zstd.NewWriter(nil) }) -} - -func BenchmarkStockEncoderWithBuild(b *testing.B) { - benchEncoderWithConstruction(b, func() (*zstd.Encoder, error) { return zstd.NewWriter(nil) }) -} - -func BenchmarkSmallDecoder(b *testing.B) { - benchDecoder(b, func() (*zstd.Decoder, error) { return NewDecoder(nil) }) -} - -func BenchmarkSmallDecoderWithBuild(b *testing.B) { - benchDecoderWithConstruction(b, func() (*zstd.Decoder, error) { return NewDecoder(nil) }) -} - -func BenchmarkStockDecoder(b *testing.B) { - benchDecoder(b, func() (*zstd.Decoder, error) { return zstd.NewReader(nil) }) -} - -func BenchmarkStockDecoderWithBuild(b *testing.B) { - benchDecoderWithConstruction(b, func() (*zstd.Decoder, error) { return zstd.NewReader(nil) }) -} - -func benchEncoder(b *testing.B, mk func() (*zstd.Encoder, error)) { - b.ReportAllocs() - - in := testdata(b) - out := make([]byte, 0, 10<<10) // 10kiB - - e, err := mk() - if err != nil { - b.Fatalf("making encoder: %v", err) - } - - b.ResetTimer() - for range b.N { - e.EncodeAll(in, out) - } -} - -func benchEncoderWithConstruction(b *testing.B, mk func() (*zstd.Encoder, error)) { - b.ReportAllocs() - - in := testdata(b) - out := make([]byte, 0, 10<<10) // 10kiB - - b.ResetTimer() - for range b.N { - e, err := mk() - if err != nil { - b.Fatalf("making encoder: %v", err) - } - - e.EncodeAll(in, out) - } -} - -func benchDecoder(b *testing.B, mk func() (*zstd.Decoder, error)) { - b.ReportAllocs() - - in := compressedTestdata(b) - out := make([]byte, 0, 10<<10) - - d, err := mk() - if err != nil { - b.Fatalf("creating decoder: %v", err) - } - - b.ResetTimer() - for range b.N { - d.DecodeAll(in, out) - } -} - -func benchDecoderWithConstruction(b *testing.B, mk func() (*zstd.Decoder, error)) { - b.ReportAllocs() - - in := compressedTestdata(b) - out := make([]byte, 0, 10<<10) - - b.ResetTimer() - for range b.N { - d, err := mk() - if err != nil { - b.Fatalf("creating decoder: %v", err) - } - - d.DecodeAll(in, out) - } -} - -func testdata(b *testing.B) []byte { - b.Helper() - in, err := os.ReadFile("testdata") - if err != nil { - b.Fatalf("reading testdata: %v", err) - } - return in -} - -func compressedTestdata(b *testing.B) []byte { - b.Helper() - uncomp := testdata(b) - e, err := NewEncoder(nil) - if err != nil { - b.Fatalf("creating encoder: %v", err) - } - return e.EncodeAll(uncomp, nil) -} diff --git a/ssh/tailssh/accept_env.go b/ssh/tailssh/accept_env.go deleted file mode 100644 index 6461a79a3408b..0000000000000 --- a/ssh/tailssh/accept_env.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tailssh - -import ( - "fmt" - "slices" - "strings" -) - -// filterEnv filters a passed in environ string slice (a slice with strings -// representing environment variables in the form "key=value") based on -// the supplied slice of acceptEnv values. -// -// acceptEnv is a slice of environment variable names that are allowlisted -// for the SSH rule in the policy file. -// -// acceptEnv values may contain * and ? wildcard characters which match against -// zero or one or more characters and a single character respectively. -func filterEnv(acceptEnv []string, environ []string) ([]string, error) { - var acceptedPairs []string - - // Quick return if we have an empty list. - if acceptEnv == nil || len(acceptEnv) == 0 { - return acceptedPairs, nil - } - - for _, envPair := range environ { - variableName, _, ok := strings.Cut(envPair, "=") - if !ok { - return nil, fmt.Errorf(`invalid environment variable: %q. Variables must be in "KEY=VALUE" format`, envPair) - } - - // Short circuit if we have a direct match between the environment - // variable and an AcceptEnv value. - if slices.Contains(acceptEnv, variableName) { - acceptedPairs = append(acceptedPairs, envPair) - continue - } - - // Otherwise check if we have a wildcard pattern that matches. - if matchAcceptEnv(acceptEnv, variableName) { - acceptedPairs = append(acceptedPairs, envPair) - continue - } - } - - return acceptedPairs, nil -} - -// matchAcceptEnv is a convenience function that wraps calling matchAcceptEnvPattern -// with every value in acceptEnv for a given env that is being matched against. -func matchAcceptEnv(acceptEnv []string, env string) bool { - for _, pattern := range acceptEnv { - if matchAcceptEnvPattern(pattern, env) { - return true - } - } - - return false -} - -// matchAcceptEnvPattern returns true if the pattern matches against the target string. -// Patterns may include * and ? wildcard characters which match against zero or one or -// more characters and a single character respectively. -func matchAcceptEnvPattern(pattern string, target string) bool { - patternIdx := 0 - targetIdx := 0 - - for { - // If we are at the end of the pattern we can only have a match if we - // are also at the end of the target. - if patternIdx >= len(pattern) { - return targetIdx >= len(target) - } - - if pattern[patternIdx] == '*' { - // Optimization to skip through any repeated asterisks as they - // have the same net effect on our search. - for patternIdx < len(pattern) { - if pattern[patternIdx] != '*' { - break - } - - patternIdx++ - } - - // We are at the end of the pattern after matching the asterisk, - // implying a match. - if patternIdx >= len(pattern) { - return true - } - - // Search through the target sequentially for the next character - // from the pattern string, recursing into matchAcceptEnvPattern - // to try and find a match. - for ; targetIdx < len(target); targetIdx++ { - if matchAcceptEnvPattern(pattern[patternIdx:], target[targetIdx:]) { - return true - } - } - - // No match after searching through the entire target. - return false - } - - if targetIdx >= len(target) { - return false - } - - if pattern[patternIdx] != '?' && pattern[patternIdx] != target[targetIdx] { - return false - } - - patternIdx++ - targetIdx++ - } -} diff --git a/ssh/tailssh/accept_env_test.go b/ssh/tailssh/accept_env_test.go deleted file mode 100644 index b54c980978ece..0000000000000 --- a/ssh/tailssh/accept_env_test.go +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tailssh - -import ( - "fmt" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestMatchAcceptEnvPattern(t *testing.T) { - testCases := []struct { - pattern string - target string - match bool - }{ - {pattern: "*", target: "EXAMPLE_ENV", match: true}, - {pattern: "***", target: "123456", match: true}, - - {pattern: "?", target: "A", match: true}, - {pattern: "?", target: "123", match: false}, - - {pattern: "?*", target: "EXAMPLE_2", match: true}, - {pattern: "?*", target: "", match: false}, - - {pattern: "*?", target: "A", match: true}, - {pattern: "*?", target: "", match: false}, - - {pattern: "??", target: "CC", match: true}, - {pattern: "??", target: "123", match: false}, - - {pattern: "*?*", target: "ABCDEFG", match: true}, - {pattern: "*?*", target: "C", match: true}, - {pattern: "*?*", target: "", match: false}, - - {pattern: "?*?", target: "ABCDEFG", match: true}, - {pattern: "?*?", target: "A", match: false}, - - {pattern: "**?TEST", target: "_TEST", match: true}, - {pattern: "**?TEST", target: "_TESTING", match: false}, - - {pattern: "TEST**?", target: "TEST_", match: true}, - {pattern: "TEST**?", target: "A_TEST_", match: false}, - - {pattern: "TEST_*", target: "TEST_A", match: true}, - {pattern: "TEST_*", target: "TEST_A_LONG_ENVIRONMENT_VARIABLE_NAME", match: true}, - {pattern: "TEST_*", target: "TEST", match: false}, - - {pattern: "EXAMPLE_?_ENV", target: "EXAMPLE_A_ENV", match: true}, - {pattern: "EXAMPLE_?_ENV", target: "EXAMPLE_ENV", match: false}, - - {pattern: "EXAMPLE_*_ENV", target: "EXAMPLE_aBcd2231---_ENV", match: true}, - {pattern: "EXAMPLE_*_ENV", target: "EXAMPLEENV", match: false}, - - {pattern: "COMPLICA?ED_PATTERN*", target: "COMPLICATED_PATTERN_REST", match: true}, - {pattern: "COMPLICA?ED_PATTERN*", target: "COMPLICATED_PATT", match: false}, - - {pattern: "COMPLICAT???ED_PATT??ERN", target: "COMPLICAT123ED_PATTggERN", match: true}, - {pattern: "COMPLICAT???ED_PATT??ERN", target: "COMPLICATED_PATTERN", match: false}, - - {pattern: "DIRECT_MATCH", target: "DIRECT_MATCH", match: true}, - {pattern: "DIRECT_MATCH", target: "MISS", match: false}, - - // OpenSSH compatibility cases - // See https://github.com/openssh/openssh-portable/blob/master/regress/unittests/match/tests.c - {pattern: "", target: "", match: true}, - {pattern: "aaa", target: "", match: false}, - {pattern: "", target: "aaa", match: false}, - {pattern: "aaaa", target: "aaa", match: false}, - {pattern: "aaa", target: "aaaa", match: false}, - {pattern: "*", target: "", match: true}, - {pattern: "?", target: "a", match: true}, - {pattern: "a?", target: "aa", match: true}, - {pattern: "*", target: "a", match: true}, - {pattern: "a*", target: "aa", match: true}, - {pattern: "?*", target: "aa", match: true}, - {pattern: "**", target: "aa", match: true}, - {pattern: "?a", target: "aa", match: true}, - {pattern: "*a", target: "aa", match: true}, - {pattern: "a?", target: "ba", match: false}, - {pattern: "a*", target: "ba", match: false}, - {pattern: "?a", target: "ab", match: false}, - {pattern: "*a", target: "ab", match: false}, - } - - for _, tc := range testCases { - name := fmt.Sprintf("pattern_%s_target_%s", tc.pattern, tc.target) - if tc.match { - name += "_should_match" - } else { - name += "_should_not_match" - } - - t.Run(name, func(t *testing.T) { - match := matchAcceptEnvPattern(tc.pattern, tc.target) - if match != tc.match { - t.Errorf("got %v, want %v", match, tc.match) - } - }) - } -} - -func TestFilterEnv(t *testing.T) { - testCases := []struct { - name string - acceptEnv []string - environ []string - expectedFiltered []string - wantErrMessage string - }{ - { - name: "simple direct matches", - acceptEnv: []string{"FOO", "FOO2", "FOO_3"}, - environ: []string{"FOO=BAR", "FOO2=BAZ", "FOO_3=123", "FOOOO4-2=AbCdEfG"}, - expectedFiltered: []string{"FOO=BAR", "FOO2=BAZ", "FOO_3=123"}, - }, - { - name: "bare wildcard", - acceptEnv: []string{"*"}, - environ: []string{"FOO=BAR", "FOO2=BAZ", "FOO_3=123", "FOOOO4-2=AbCdEfG"}, - expectedFiltered: []string{"FOO=BAR", "FOO2=BAZ", "FOO_3=123", "FOOOO4-2=AbCdEfG"}, - }, - { - name: "complex matches", - acceptEnv: []string{"FO?", "FOOO*", "FO*5?7"}, - environ: []string{"FOO=BAR", "FOO2=BAZ", "FOO_3=123", "FOOOO4-2=AbCdEfG", "FO1-kmndGamc79567=ABC", "FO57=BAR2"}, - expectedFiltered: []string{"FOO=BAR", "FOOOO4-2=AbCdEfG", "FO1-kmndGamc79567=ABC"}, - }, - { - name: "environ format invalid", - acceptEnv: []string{"FO?", "FOOO*", "FO*5?7"}, - environ: []string{"FOOBAR"}, - expectedFiltered: nil, - wantErrMessage: `invalid environment variable: "FOOBAR". Variables must be in "KEY=VALUE" format`, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - filtered, err := filterEnv(tc.acceptEnv, tc.environ) - if err == nil && tc.wantErrMessage != "" { - t.Errorf("wanted error with message %q but error was nil", tc.wantErrMessage) - } - - if err != nil && err.Error() != tc.wantErrMessage { - t.Errorf("err = %v; want %v", err, tc.wantErrMessage) - } - - if diff := cmp.Diff(tc.expectedFiltered, filtered); diff != "" { - t.Errorf("unexpected filter result (-got,+want): \n%s", diff) - } - }) - } -} diff --git a/ssh/tailssh/incubator.go b/ssh/tailssh/incubator.go deleted file mode 100644 index 3ff676d519898..0000000000000 --- a/ssh/tailssh/incubator.go +++ /dev/null @@ -1,1129 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// This file contains the code for the incubator process. Tailscaled -// launches the incubator as the same user as it was launched as. The -// incubator then registers a new session with the OS, sets its UID -// and groups to the specified `--uid`, `--gid` and `--groups`, and -// then launches the requested `--cmd`. - -//go:build linux || (darwin && !ios) || freebsd || openbsd - -package tailssh - -import ( - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "log/syslog" - "os" - "os/exec" - "path/filepath" - "runtime" - "slices" - "sort" - "strconv" - "strings" - "sync/atomic" - "syscall" - - "github.com/creack/pty" - "github.com/pkg/sftp" - "github.com/u-root/u-root/pkg/termios" - gossh "golang.org/x/crypto/ssh" - "golang.org/x/sys/unix" - "tailscale.com/cmd/tailscaled/childproc" - "tailscale.com/hostinfo" - "tailscale.com/tailcfg" - "tailscale.com/tempfork/gliderlabs/ssh" - "tailscale.com/types/logger" - "tailscale.com/version/distro" -) - -func init() { - childproc.Add("ssh", beIncubator) - childproc.Add("sftp", beSFTP) -} - -var ptyName = func(f *os.File) (string, error) { - return "", fmt.Errorf("unimplemented") -} - -// maybeStartLoginSession informs the system that we are about to log someone -// in. On success, it may return a non-nil close func which must be closed to -// release the session. -// We can only do this if we are running as root. -// This is best effort to still allow running on machines where -// we don't support starting sessions, e.g. darwin. -// See maybeStartLoginSessionLinux. -var maybeStartLoginSession = func(dlogf logger.Logf, ia incubatorArgs) (close func() error) { - return nil -} - -// newIncubatorCommand returns a new exec.Cmd configured with -// `tailscaled be-child ssh` as the entrypoint. -// -// If ss.srv.tailscaledPath is empty, this method is equivalent to -// exec.CommandContext. -// -// The returned Cmd.Env is guaranteed to be nil; the caller populates it. -func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err error) { - defer func() { - if cmd.Env != nil { - panic("internal error") - } - }() - - var isSFTP, isShell bool - switch ss.Subsystem() { - case "sftp": - isSFTP = true - case "": - isShell = ss.RawCommand() == "" - default: - panic(fmt.Sprintf("unexpected subsystem: %v", ss.Subsystem())) - } - - if ss.conn.srv.tailscaledPath == "" { - if isSFTP { - // SFTP relies on the embedded Go-based SFTP server in tailscaled, - // so without tailscaled, we can't serve SFTP. - return nil, errors.New("no tailscaled found on path, can't serve SFTP") - } - - loginShell := ss.conn.localUser.LoginShell() - args := shellArgs(isShell, ss.RawCommand()) - logf("directly running %s %q", loginShell, args) - return exec.CommandContext(ss.ctx, loginShell, args...), nil - } - - lu := ss.conn.localUser - ci := ss.conn.info - groups := strings.Join(ss.conn.userGroupIDs, ",") - remoteUser := ci.uprof.LoginName - if ci.node.IsTagged() { - remoteUser = strings.Join(ci.node.Tags().AsSlice(), ",") - } - - incubatorArgs := []string{ - "be-child", - "ssh", - "--login-shell=" + lu.LoginShell(), - "--uid=" + lu.Uid, - "--gid=" + lu.Gid, - "--groups=" + groups, - "--local-user=" + lu.Username, - "--home-dir=" + lu.HomeDir, - "--remote-user=" + remoteUser, - "--remote-ip=" + ci.src.Addr().String(), - "--has-tty=false", // updated in-place by startWithPTY - "--tty-name=", // updated in-place by startWithPTY - } - - // We have to check the below outside of the incubator process, because it - // relies on the "getenforce" command being on the PATH, which it is not - // when in the incubator. - if runtime.GOOS == "linux" && hostinfo.IsSELinuxEnforcing() { - incubatorArgs = append(incubatorArgs, "--is-selinux-enforcing") - } - - nm := ss.conn.srv.lb.NetMap() - forceV1Behavior := nm.HasCap(tailcfg.NodeAttrSSHBehaviorV1) && !nm.HasCap(tailcfg.NodeAttrSSHBehaviorV2) - if forceV1Behavior { - incubatorArgs = append(incubatorArgs, "--force-v1-behavior") - } - - if debugTest.Load() { - incubatorArgs = append(incubatorArgs, "--debug-test") - } - - switch { - case isSFTP: - // Note that we include both the `--sftp` flag and a command to launch - // tailscaled as `be-child sftp`. If login or su is available, and - // we're not running with tailcfg.NodeAttrSSHBehaviorV1, this will - // result in serving SFTP within a login shell, with full PAM - // integration. Otherwise, we'll serve SFTP in the incubator process - // with no PAM integration. - incubatorArgs = append(incubatorArgs, "--sftp", fmt.Sprintf("--cmd=%s be-child sftp", ss.conn.srv.tailscaledPath)) - case isShell: - incubatorArgs = append(incubatorArgs, "--shell") - default: - incubatorArgs = append(incubatorArgs, "--cmd="+ss.RawCommand()) - } - - allowSendEnv := nm.HasCap(tailcfg.NodeAttrSSHEnvironmentVariables) - if allowSendEnv { - env, err := filterEnv(ss.conn.acceptEnv, ss.Session.Environ()) - if err != nil { - return nil, err - } - - if len(env) > 0 { - encoded, err := json.Marshal(env) - if err != nil { - return nil, fmt.Errorf("failed to encode environment: %w", err) - } - incubatorArgs = append(incubatorArgs, fmt.Sprintf("--encoded-env=%q", encoded)) - } - } - - return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...), nil -} - -var debugIncubator bool -var debugTest atomic.Bool - -type stdRWC struct{} - -func (stdRWC) Read(p []byte) (n int, err error) { - return os.Stdin.Read(p) -} - -func (stdRWC) Write(b []byte) (n int, err error) { - return os.Stdout.Write(b) -} - -func (stdRWC) Close() error { - os.Exit(0) - return nil -} - -type incubatorArgs struct { - loginShell string - uid int - gid int - gids []int - localUser string - homeDir string - remoteUser string - remoteIP string - ttyName string - hasTTY bool - cmd string - isSFTP bool - isShell bool - forceV1Behavior bool - debugTest bool - isSELinuxEnforcing bool - encodedEnv string -} - -func parseIncubatorArgs(args []string) (incubatorArgs, error) { - var ia incubatorArgs - var groups string - - flags := flag.NewFlagSet("", flag.ExitOnError) - flags.StringVar(&ia.loginShell, "login-shell", "", "path to the user's preferred login shell") - flags.IntVar(&ia.uid, "uid", 0, "the uid of local-user") - flags.IntVar(&ia.gid, "gid", 0, "the gid of local-user") - flags.StringVar(&groups, "groups", "", "comma-separated list of gids of local-user") - flags.StringVar(&ia.localUser, "local-user", "", "the user to run as") - flags.StringVar(&ia.homeDir, "home-dir", "/", "the user's home directory") - flags.StringVar(&ia.remoteUser, "remote-user", "", "the remote user/tags") - flags.StringVar(&ia.remoteIP, "remote-ip", "", "the remote Tailscale IP") - flags.StringVar(&ia.ttyName, "tty-name", "", "the tty name (pts/3)") - flags.BoolVar(&ia.hasTTY, "has-tty", false, "is the output attached to a tty") - flags.StringVar(&ia.cmd, "cmd", "", "the cmd to launch, including all arguments (ignored in sftp mode)") - flags.BoolVar(&ia.isShell, "shell", false, "is launching a shell (with no cmds)") - flags.BoolVar(&ia.isSFTP, "sftp", false, "run sftp server (cmd is ignored)") - flags.BoolVar(&ia.forceV1Behavior, "force-v1-behavior", false, "allow falling back to the su command if login is unavailable") - flags.BoolVar(&ia.debugTest, "debug-test", false, "should debug in test mode") - flags.BoolVar(&ia.isSELinuxEnforcing, "is-selinux-enforcing", false, "whether SELinux is in enforcing mode") - flags.StringVar(&ia.encodedEnv, "encoded-env", "", "JSON encoded array of environment variables in '['key=value']' format") - flags.Parse(args) - - for _, g := range strings.Split(groups, ",") { - gid, err := strconv.Atoi(g) - if err != nil { - return ia, fmt.Errorf("unable to parse group id %q: %w", g, err) - } - ia.gids = append(ia.gids, gid) - } - - return ia, nil -} - -func (ia incubatorArgs) forwadedEnviron() ([]string, string, error) { - environ := os.Environ() - // pass through SSH_AUTH_SOCK environment variable to support ssh agent forwarding - allowListKeys := "SSH_AUTH_SOCK" - - if ia.encodedEnv != "" { - unquoted, err := strconv.Unquote(ia.encodedEnv) - if err != nil { - return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err) - } - - var extraEnviron []string - - err = json.Unmarshal([]byte(unquoted), &extraEnviron) - if err != nil { - return nil, "", fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err) - } - - environ = append(environ, extraEnviron...) - - for _, v := range extraEnviron { - allowListKeys = fmt.Sprintf("%s,%s", allowListKeys, strings.Split(v, "=")[0]) - } - } - - return environ, allowListKeys, nil -} - -// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand. -// It is responsible for informing the system of a new login session for the -// user. This is sometimes necessary for mounting home directories and -// decrypting file systems. -// -// Tailscaled launches the incubator as the same user as it was launched as. -func beIncubator(args []string) error { - // To defend against issues like https://golang.org/issue/1435, - // defensively lock our current goroutine's thread to the current - // system thread before we start making any UID/GID/group changes. - // - // This shouldn't matter on Linux because syscall.AllThreadsSyscall is - // used to invoke syscalls on all OS threads, but (as of 2023-03-23) - // that function is not implemented on all platforms. - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - ia, err := parseIncubatorArgs(args) - if err != nil { - return err - } - if ia.isSFTP && ia.isShell { - return fmt.Errorf("--sftp and --shell are mutually exclusive") - } - - dlogf := logger.Discard - if debugIncubator { - // We don't own stdout or stderr, so the only place we can log is syslog. - if sl, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "tailscaled-ssh"); err == nil { - dlogf = log.New(sl, "", 0).Printf - } - } else if ia.debugTest { - // In testing, we don't always have syslog, so log to a temp file. - if logFile, err := os.OpenFile("/tmp/tailscalessh.log", os.O_APPEND|os.O_WRONLY, 0666); err == nil { - lf := log.New(logFile, "", 0) - dlogf = func(msg string, args ...any) { - lf.Printf(msg, args...) - logFile.Sync() - } - defer logFile.Close() - } - } - - if !shouldAttemptLoginShell(dlogf, ia) { - dlogf("not attempting login shell") - return handleInProcess(dlogf, ia) - } - - // First try the login command - if err := tryExecLogin(dlogf, ia); err != nil { - return err - } - - // If we got here, we weren't able to use login (because tryExecLogin - // returned without replacing the running process), maybe we can use - // su. - if handled, err := trySU(dlogf, ia); handled { - return err - } else { - dlogf("not attempting su") - return handleInProcess(dlogf, ia) - } -} - -func handleInProcess(dlogf logger.Logf, ia incubatorArgs) error { - if ia.isSFTP { - return handleSFTPInProcess(dlogf, ia) - } - return handleSSHInProcess(dlogf, ia) -} - -func handleSFTPInProcess(dlogf logger.Logf, ia incubatorArgs) error { - dlogf("handling sftp") - - sessionCloser := maybeStartLoginSession(dlogf, ia) - if sessionCloser != nil { - defer sessionCloser() - } - - if err := dropPrivileges(dlogf, ia); err != nil { - return err - } - - return serveSFTP() -} - -// beSFTP serves SFTP in-process. -func beSFTP(args []string) error { - return serveSFTP() -} - -func serveSFTP() error { - server, err := sftp.NewServer(stdRWC{}) - if err != nil { - return err - } - // TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF, - // when sftp is patched to report clean termination. - if err := server.Serve(); err != nil && err != io.EOF { - return err - } - return nil -} - -// shouldAttemptLoginShell decides whether we should attempt to get a full -// login shell with the login or su commands. We will attempt a login shell -// if all of the following conditions are met. -// -// - We are running as root -// - This is not an SELinuxEnforcing host -// -// The last condition exists because if we're running on a SELinux-enabled -// system, neiher login nor su will be able to set the correct context for the -// shell. So, we don't bother trying to run them and instead fall back to using -// the incubator to launch the shell. -// See http://github.com/tailscale/tailscale/issues/4908. -func shouldAttemptLoginShell(dlogf logger.Logf, ia incubatorArgs) bool { - if ia.forceV1Behavior && ia.isSFTP { - // v1 behavior did not run SFTP within a login shell. - dlogf("Forcing v1 behavior, won't use login shell for SFTP") - return false - } - - return runningAsRoot() && !ia.isSELinuxEnforcing -} - -func runningAsRoot() bool { - euid := os.Geteuid() - return euid == 0 -} - -// tryExecLogin attempts to handle the ssh session by creating a full login -// shell using the login command. If it never tried, it returns nil. If it -// failed to do so, it returns an error. -// -// Creating a login shell in this way allows us to register the remote IP of -// the login session, trigger PAM authentication, and get the "remote" PAM -// profile. -// -// However, login is subject to some limitations. -// -// 1. login cannot be used to execute commands except on macOS. -// 2. On Linux and BSD, login requires a TTY to keep running. -// -// In these cases, tryExecLogin returns (false, nil) to indicate that processing -// should fall through to other methods, such as using the su command. -// -// Note that this uses unix.Exec to replace the current process, so in cases -// where we actually do run login, no subsequent Go code will execute. -func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) error { - // Only the macOS version of the login command supports executing a - // command, all other versions only support launching a shell without - // taking any arguments. - if !ia.isShell && runtime.GOOS != "darwin" { - dlogf("won't use login because we're not in a shell or on macOS") - return nil - } - - switch runtime.GOOS { - case "linux", "freebsd", "openbsd": - if !ia.hasTTY { - dlogf("can't use login because of missing TTY") - // We can only use the login command if a shell was requested with - // a TTY. If there is no TTY, login exits immediately, which - // breaks things like mosh and VSCode. - return nil - } - } - - loginCmdPath, err := exec.LookPath("login") - if err != nil { - dlogf("failed to get login args: %s", err) - return nil - } - loginArgs := ia.loginArgs(loginCmdPath) - dlogf("logging in with %+v", loginArgs) - - environ, _, err := ia.forwadedEnviron() - if err != nil { - return err - } - - // If Exec works, the Go code will not proceed past this: - err = unix.Exec(loginCmdPath, loginArgs, environ) - - // If we made it here, Exec failed. - return err -} - -// trySU attempts to start a login shell using su. If su is available and -// supports the necessary arguments, this returns true, plus the result of -// executing su. Otherwise, it returns (false, nil). -// -// Creating a login shell in this way allows us to trigger PAM authentication -// and get the "login" PAM profile. -// -// Unlike login, su often does not require a TTY, so on Linux hosts that have -// an su command which accepts the right flags, we'll use su instead of login -// when no TTY is available. -func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) { - if ia.forceV1Behavior { - // v1 behavior did not use su. - dlogf("Forcing v1 behavior, won't use su") - return false, nil - } - - su := findSU(dlogf, ia) - if su == "" { - return false, nil - } - - sessionCloser := maybeStartLoginSession(dlogf, ia) - if sessionCloser != nil { - defer sessionCloser() - } - - environ, allowListEnvKeys, err := ia.forwadedEnviron() - if err != nil { - return false, err - } - - loginArgs := []string{ - su, - "-w", allowListEnvKeys, - "-l", - ia.localUser, - } - if ia.cmd != "" { - // Note - unlike the login command, su allows using both -l and -c. - loginArgs = append(loginArgs, "-c", ia.cmd) - } - - dlogf("logging in with %+v", loginArgs) - - // If Exec works, the Go code will not proceed past this: - err = unix.Exec(su, loginArgs, environ) - - // If we made it here, Exec failed. - return true, err -} - -// findSU attempts to find an su command which supports the -l and -c flags. -// This actually calls the su command, which can cause side effects like -// triggering pam_mkhomedir. If a suitable su is not available, this returns -// "". -func findSU(dlogf logger.Logf, ia incubatorArgs) string { - // Currently, we only support falling back to su on Linux. This - // potentially could work on BSDs as well, but requires testing. - if runtime.GOOS != "linux" { - return "" - } - - // gokrazy doesn't include su. And, if someone installs a breakglass/ - // debugging package on gokrazy, we don't want to use its su. - if distro.Get() == distro.Gokrazy { - return "" - } - - su, err := exec.LookPath("su") - if err != nil { - dlogf("can't find su command: %v", err) - return "" - } - - _, allowListEnvKeys, err := ia.forwadedEnviron() - if err != nil { - return "" - } - - // First try to execute su -w -l -c true - // to make sure su supports the necessary arguments. - err = exec.Command( - su, - "-w", allowListEnvKeys, - "-l", - ia.localUser, - "-c", "true", - ).Run() - if err != nil { - dlogf("su check failed: %s", err) - return "" - } - - return su -} - -// handleSSHInProcess is a last resort if we couldn't use login or su. It -// registers a new session with the OS, sets its UID, GID and groups to the -// specified values, and then launches the requested `--cmd` in the user's -// login shell. -func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error { - sessionCloser := maybeStartLoginSession(dlogf, ia) - if sessionCloser != nil { - defer sessionCloser() - } - - if err := dropPrivileges(dlogf, ia); err != nil { - return err - } - - environ, _, err := ia.forwadedEnviron() - if err != nil { - return err - } - - args := shellArgs(ia.isShell, ia.cmd) - dlogf("running %s %q", ia.loginShell, args) - cmd := newCommand(ia.hasTTY, ia.loginShell, environ, args) - err = cmd.Run() - if ee, ok := err.(*exec.ExitError); ok { - ps := ee.ProcessState - code := ps.ExitCode() - if code < 0 { - // TODO(bradfitz): do we need to also check the syscall.WaitStatus - // and make our process look like it also died by signal/same signal - // as our child process? For now we just do the exit code. - fmt.Fprintf(os.Stderr, "[tailscale-ssh: process died: %v]\n", ps.String()) - code = 1 // for now. so we don't exit with negative - } - os.Exit(code) - } - return err -} - -func newCommand(hasTTY bool, cmdPath string, cmdEnviron []string, cmdArgs []string) *exec.Cmd { - cmd := exec.Command(cmdPath, cmdArgs...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = cmdEnviron - - if hasTTY { - // If we were launched with a tty then we should mark that as the ctty - // of the child. However, as the ctty is being passed from the parent - // we set the child to foreground instead which also passes the ctty. - // However, we can not do this if never had a tty to begin with. - cmd.SysProcAttr = &syscall.SysProcAttr{ - Foreground: true, - } - } - - return cmd -} - -const ( - // This controls whether we assert that our privileges were dropped - // using geteuid/getegid; it's a const and not an envknob because the - // incubator doesn't see the parent's environment. - // - // TODO(andrew): remove this const and always do this after sufficient - // testing, e.g. the 1.40 release - assertPrivilegesWereDropped = true - - // TODO(andrew-d): verify that this works in more configurations before - // enabling by default. - assertPrivilegesWereDroppedByAttemptingToUnDrop = false -) - -// dropPrivileges calls doDropPrivileges with uid, gid, and gids from the given -// incubatorArgs. -func dropPrivileges(dlogf logger.Logf, ia incubatorArgs) error { - return doDropPrivileges(dlogf, ia.uid, ia.gid, ia.gids, ia.homeDir) -} - -// doDropPrivileges contains all the logic for dropping privileges to a different -// UID, GID, and set of supplementary groups. This function is -// security-sensitive and ordering-dependent; please be very cautious if/when -// refactoring. -// -// WARNING: if you change this function, you *MUST* run the TestDoDropPrivileges -// test in this package as root on at least Linux, FreeBSD and Darwin. This can -// be done by running: -// -// go test -c ./ssh/tailssh/ && sudo ./tailssh.test -test.v -test.run TestDoDropPrivileges -func doDropPrivileges(dlogf logger.Logf, wantUid, wantGid int, supplementaryGroups []int, homeDir string) error { - dlogf("dropping privileges") - fatalf := func(format string, args ...any) { - dlogf("[unexpected] error dropping privileges: "+format, args...) - os.Exit(1) - } - - euid := os.Geteuid() - egid := os.Getegid() - - if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { - // On FreeBSD and Darwin, the first entry returned from the - // getgroups(2) syscall is the egid, and changing it with - // setgroups(2) changes the egid of the process. This is - // technically a violation of the POSIX standard; see the - // following article for more detail: - // https://www.usenix.org/system/files/login/articles/325-tsafrir.pdf - // - // In this case, we add an entry at the beginning of the - // groupIDs list containing the expected gid if it's not - // already there, which modifies the egid and additional groups - // as one unit. - if len(supplementaryGroups) == 0 || supplementaryGroups[0] != wantGid { - supplementaryGroups = append([]int{wantGid}, supplementaryGroups...) - } - } - - if err := setGroups(supplementaryGroups); err != nil { - return err - } - if egid != wantGid { - // On FreeBSD and Darwin, we may have already called the - // equivalent of setegid(wantGid) via the call to setGroups, - // above. However, per the manpage, setgid(getegid()) is an - // allowed operation regardless of privilege level. - // - // FreeBSD: - // The setgid() system call is permitted if the specified ID - // is equal to the real group ID or the effective group ID - // of the process, or if the effective user ID is that of - // the super user. - // - // Darwin: - // The setgid() function is permitted if the effective - // user ID is that of the super user, or if the specified - // group ID is the same as the effective group ID. If - // not, but the specified group ID is the same as the real - // group ID, setgid() will set the effective group ID to - // the real group ID. - if err := syscall.Setgid(wantGid); err != nil { - fatalf("Setgid(%d): %v", wantGid, err) - } - } - if euid != wantUid { - // Switch users if required before starting the desired process. - if err := syscall.Setuid(wantUid); err != nil { - fatalf("Setuid(%d): %v", wantUid, err) - } - } - - // If we changed either the UID or GID, defensively assert that we - // cannot reset the it back to our original values, and that the - // current egid/euid are the expected values after we change - // everything; if not, we exit the process. - if assertPrivilegesWereDroppedByAttemptingToUnDrop { - if egid != wantGid { - if err := syscall.Setegid(egid); err == nil { - fatalf("able to set egid back to %d", egid) - } - } - if euid != wantUid { - if err := syscall.Seteuid(euid); err == nil { - fatalf("able to set euid back to %d", euid) - } - } - } - if assertPrivilegesWereDropped { - if got := os.Getegid(); got != wantGid { - fatalf("got egid=%d, want %d", got, wantGid) - } - if got := os.Geteuid(); got != wantUid { - fatalf("got euid=%d, want %d", got, wantUid) - } - // TODO(andrew-d): assert that our supplementary groups are correct - } - - // Prefer to run in user's homedir if possible. We ignore a failure to Chdir, - // which just leaves us at "/" where we launched in the first place. - dlogf("attempting to chdir to user's home directory %q", homeDir) - if err := os.Chdir(homeDir); err != nil { - dlogf("failed to chdir to user's home directory %q, continuing in current directory", homeDir) - } - - return nil -} - -// launchProcess launches an incubator process for the provided session. -// It is responsible for configuring the process execution environment. -// The caller can wait for the process to exit by calling cmd.Wait(). -// -// It sets ss.cmd, stdin, stdout, and stderr. -func (ss *sshSession) launchProcess() error { - var err error - ss.cmd, err = ss.newIncubatorCommand(ss.logf) - if err != nil { - return err - } - - cmd := ss.cmd - cmd.Dir = "/" - cmd.Env = envForUser(ss.conn.localUser) - for _, kv := range ss.Environ() { - if acceptEnvPair(kv) { - cmd.Env = append(cmd.Env, kv) - } - } - - ci := ss.conn.info - cmd.Env = append(cmd.Env, - fmt.Sprintf("SSH_CLIENT=%s %d %d", ci.src.Addr(), ci.src.Port(), ci.dst.Port()), - fmt.Sprintf("SSH_CONNECTION=%s %d %s %d", ci.src.Addr(), ci.src.Port(), ci.dst.Addr(), ci.dst.Port()), - ) - - if ss.agentListener != nil { - cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_AUTH_SOCK=%s", ss.agentListener.Addr())) - } - - ptyReq, winCh, isPty := ss.Pty() - if !isPty { - ss.logf("starting non-pty command: %+v", cmd.Args) - return ss.startWithStdPipes() - } - - if sshDisablePTY() { - ss.logf("pty support disabled by envknob") - return errors.New("pty support disabled by envknob") - } - - ss.ptyReq = &ptyReq - pty, tty, err := ss.startWithPTY() - if err != nil { - return err - } - - // We need to be able to close stdin and stdout separately later so make a - // dup. - ptyDup, err := syscall.Dup(int(pty.Fd())) - if err != nil { - pty.Close() - tty.Close() - return err - } - go resizeWindow(ptyDup /* arbitrary fd */, winCh) - - ss.wrStdin = pty - ss.rdStdout = os.NewFile(uintptr(ptyDup), pty.Name()) - ss.rdStderr = nil // not available for pty - ss.childPipes = []io.Closer{tty} - - return nil -} - -func resizeWindow(fd int, winCh <-chan ssh.Window) { - for win := range winCh { - unix.IoctlSetWinsize(fd, syscall.TIOCSWINSZ, &unix.Winsize{ - Row: uint16(win.Height), - Col: uint16(win.Width), - Xpixel: uint16(win.WidthPixels), - Ypixel: uint16(win.HeightPixels), - }) - } -} - -// opcodeShortName is a mapping of SSH opcode -// to mnemonic names expected by the termios package. -// These are meant to be platform independent. -var opcodeShortName = map[uint8]string{ - gossh.VINTR: "intr", - gossh.VQUIT: "quit", - gossh.VERASE: "erase", - gossh.VKILL: "kill", - gossh.VEOF: "eof", - gossh.VEOL: "eol", - gossh.VEOL2: "eol2", - gossh.VSTART: "start", - gossh.VSTOP: "stop", - gossh.VSUSP: "susp", - gossh.VDSUSP: "dsusp", - gossh.VREPRINT: "rprnt", - gossh.VWERASE: "werase", - gossh.VLNEXT: "lnext", - gossh.VFLUSH: "flush", - gossh.VSWTCH: "swtch", - gossh.VSTATUS: "status", - gossh.VDISCARD: "discard", - gossh.IGNPAR: "ignpar", - gossh.PARMRK: "parmrk", - gossh.INPCK: "inpck", - gossh.ISTRIP: "istrip", - gossh.INLCR: "inlcr", - gossh.IGNCR: "igncr", - gossh.ICRNL: "icrnl", - gossh.IUCLC: "iuclc", - gossh.IXON: "ixon", - gossh.IXANY: "ixany", - gossh.IXOFF: "ixoff", - gossh.IMAXBEL: "imaxbel", - gossh.IUTF8: "iutf8", - gossh.ISIG: "isig", - gossh.ICANON: "icanon", - gossh.XCASE: "xcase", - gossh.ECHO: "echo", - gossh.ECHOE: "echoe", - gossh.ECHOK: "echok", - gossh.ECHONL: "echonl", - gossh.NOFLSH: "noflsh", - gossh.TOSTOP: "tostop", - gossh.IEXTEN: "iexten", - gossh.ECHOCTL: "echoctl", - gossh.ECHOKE: "echoke", - gossh.PENDIN: "pendin", - gossh.OPOST: "opost", - gossh.OLCUC: "olcuc", - gossh.ONLCR: "onlcr", - gossh.OCRNL: "ocrnl", - gossh.ONOCR: "onocr", - gossh.ONLRET: "onlret", - gossh.CS7: "cs7", - gossh.CS8: "cs8", - gossh.PARENB: "parenb", - gossh.PARODD: "parodd", - gossh.TTY_OP_ISPEED: "tty_op_ispeed", - gossh.TTY_OP_OSPEED: "tty_op_ospeed", -} - -// startWithPTY starts cmd with a pseudo-terminal attached to Stdin, Stdout and Stderr. -func (ss *sshSession) startWithPTY() (ptyFile, tty *os.File, err error) { - ptyReq := ss.ptyReq - cmd := ss.cmd - if cmd == nil { - return nil, nil, errors.New("nil ss.cmd") - } - if ptyReq == nil { - return nil, nil, errors.New("nil ss.ptyReq") - } - - ptyFile, tty, err = pty.Open() - if err != nil { - err = fmt.Errorf("pty.Open: %w", err) - return - } - defer func() { - if err != nil { - ptyFile.Close() - tty.Close() - } - }() - ptyRawConn, err := tty.SyscallConn() - if err != nil { - return nil, nil, fmt.Errorf("SyscallConn: %w", err) - } - var ctlErr error - if err := ptyRawConn.Control(func(fd uintptr) { - // Load existing PTY settings to modify them & save them back. - tios, err := termios.GTTY(int(fd)) - if err != nil { - ctlErr = fmt.Errorf("GTTY: %w", err) - return - } - - // Set the rows & cols to those advertised from the ptyReq frame - // received over SSH. - tios.Row = int(ptyReq.Window.Height) - tios.Col = int(ptyReq.Window.Width) - - for c, v := range ptyReq.Modes { - if c == gossh.TTY_OP_ISPEED { - tios.Ispeed = int(v) - continue - } - if c == gossh.TTY_OP_OSPEED { - tios.Ospeed = int(v) - continue - } - k, ok := opcodeShortName[c] - if !ok { - ss.vlogf("unknown opcode: %d", c) - continue - } - if _, ok := tios.CC[k]; ok { - tios.CC[k] = uint8(v) - continue - } - if _, ok := tios.Opts[k]; ok { - tios.Opts[k] = v > 0 - continue - } - ss.vlogf("unsupported opcode: %v(%d)=%v", k, c, v) - } - - // Save PTY settings. - if _, err := tios.STTY(int(fd)); err != nil { - ctlErr = fmt.Errorf("STTY: %w", err) - return - } - }); err != nil { - return nil, nil, fmt.Errorf("ptyRawConn.Control: %w", err) - } - if ctlErr != nil { - return nil, nil, fmt.Errorf("ptyRawConn.Control func: %w", ctlErr) - } - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setctty: true, - Setsid: true, - } - updateStringInSlice(cmd.Args, "--has-tty=false", "--has-tty=true") - if ptyName, err := ptyName(ptyFile); err == nil { - updateStringInSlice(cmd.Args, "--tty-name=", "--tty-name="+ptyName) - fullPath := filepath.Join("/dev", ptyName) - cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_TTY=%s", fullPath)) - } - - if ptyReq.Term != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) - } - cmd.Stdin = tty - cmd.Stdout = tty - cmd.Stderr = tty - - ss.logf("starting pty command: %+v", cmd.Args) - if err = cmd.Start(); err != nil { - return - } - return ptyFile, tty, nil -} - -// startWithStdPipes starts cmd with os.Pipe for Stdin, Stdout and Stderr. -func (ss *sshSession) startWithStdPipes() (err error) { - var rdStdin, wrStdout, wrStderr io.ReadWriteCloser - defer func() { - if err != nil { - closeAll(rdStdin, ss.wrStdin, ss.rdStdout, wrStdout, ss.rdStderr, wrStderr) - } - }() - if ss.cmd == nil { - return errors.New("nil cmd") - } - if rdStdin, ss.wrStdin, err = os.Pipe(); err != nil { - return err - } - if ss.rdStdout, wrStdout, err = os.Pipe(); err != nil { - return err - } - if ss.rdStderr, wrStderr, err = os.Pipe(); err != nil { - return err - } - ss.cmd.Stdin = rdStdin - ss.cmd.Stdout = wrStdout - ss.cmd.Stderr = wrStderr - ss.childPipes = []io.Closer{rdStdin, wrStdout, wrStderr} - return ss.cmd.Start() -} - -func envForUser(u *userMeta) []string { - return []string{ - fmt.Sprintf("SHELL=" + u.LoginShell()), - fmt.Sprintf("USER=" + u.Username), - fmt.Sprintf("HOME=" + u.HomeDir), - fmt.Sprintf("PATH=" + defaultPathForUser(&u.User)), - } -} - -// updateStringInSlice mutates ss to change the first occurrence of a -// to b. -func updateStringInSlice(ss []string, a, b string) { - for i, s := range ss { - if s == a { - ss[i] = b - return - } - } -} - -// acceptEnvPair reports whether the environment variable key=value pair -// should be accepted from the client. It uses the same default as OpenSSH -// AcceptEnv. -func acceptEnvPair(kv string) bool { - k, _, ok := strings.Cut(kv, "=") - if !ok { - return false - } - return k == "TERM" || k == "LANG" || strings.HasPrefix(k, "LC_") -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// loginArgs returns the arguments to use to exec the login binary. -func (ia *incubatorArgs) loginArgs(loginCmdPath string) []string { - switch runtime.GOOS { - case "darwin": - args := []string{ - loginCmdPath, - "-f", // already authenticated - - // login typically discards the previous environment, but we want to - // preserve any environment variables that we currently have. - "-p", - - "-h", ia.remoteIP, // -h is "remote host" - ia.localUser, - } - if !ia.hasTTY { - args[2] = "-pq" // -q is "quiet" which suppresses the login banner - } - if ia.cmd != "" { - args = append(args, ia.loginShell, "-c", ia.cmd) - } - - return args - case "linux": - if distro.Get() == distro.Arch && !fileExists("/etc/pam.d/remote") { - // See https://github.com/tailscale/tailscale/issues/4924 - // - // Arch uses a different login binary that makes the -h flag set the PAM - // service to "remote". So if they don't have that configured, don't - // pass -h. - return []string{loginCmdPath, "-f", ia.localUser, "-p"} - } - return []string{loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"} - case "freebsd", "openbsd": - return []string{loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser} - } - panic("unimplemented") -} - -func shellArgs(isShell bool, cmd string) []string { - if isShell { - return []string{"-l"} - } else { - return []string{"-c", cmd} - } -} - -func setGroups(groupIDs []int) error { - if runtime.GOOS == "darwin" && len(groupIDs) > 16 { - // darwin returns "invalid argument" if more than 16 groups are passed to syscall.Setgroups - // some info can be found here: - // https://opensource.apple.com/source/samba/samba-187.8/patches/support-darwin-initgroups-syscall.auto.html - // this fix isn't great, as anyone reading this has probably just wasted hours figuring out why - // some permissions thing isn't working, due to some arbitrary group ordering, but it at least allows - // this to work for more things than it previously did. - groupIDs = groupIDs[:16] - } - - err := syscall.Setgroups(groupIDs) - if err != nil && os.Geteuid() != 0 && groupsMatchCurrent(groupIDs) { - // If we're not root, ignore a Setgroups failure if all groups are the same. - return nil - } - return err -} - -func groupsMatchCurrent(groupIDs []int) bool { - existing, err := syscall.Getgroups() - if err != nil { - return false - } - if len(existing) != len(groupIDs) { - return false - } - groupIDs = slices.Clone(groupIDs) - sort.Ints(groupIDs) - sort.Ints(existing) - return slices.Equal(groupIDs, existing) -} diff --git a/ssh/tailssh/incubator_linux.go b/ssh/tailssh/incubator_linux.go deleted file mode 100644 index bcbe0e240a24d..0000000000000 --- a/ssh/tailssh/incubator_linux.go +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package tailssh - -import ( - "context" - "fmt" - "os" - "syscall" - "time" - "unsafe" - - "github.com/godbus/dbus/v5" - "tailscale.com/types/logger" -) - -func init() { - ptyName = ptyNameLinux - maybeStartLoginSession = maybeStartLoginSessionLinux -} - -func ptyNameLinux(f *os.File) (string, error) { - var n uint32 - _, _, e := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))) - if e != 0 { - return "", e - } - return fmt.Sprintf("pts/%d", n), nil -} - -// callLogin1 invokes the provided method of the "login1" service over D-Bus. -// https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html -func callLogin1(method string, flags dbus.Flags, args ...any) (*dbus.Call, error) { - conn, err := dbus.SystemBus() - if err != nil { - // DBus probably not running. - return nil, err - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - name, objectPath := "org.freedesktop.login1", "/org/freedesktop/login1" - obj := conn.Object(name, dbus.ObjectPath(objectPath)) - call := obj.CallWithContext(ctx, method, flags, args...) - if call.Err != nil { - return nil, call.Err - } - return call, nil -} - -// createSessionArgs is a wrapper struct for the Login1.Manager.CreateSession args. -// The CreateSession API arguments and response types are defined here: -// https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html -type createSessionArgs struct { - uid uint32 // User ID being logged in. - pid uint32 // Process ID for the session, 0 means current process. - service string // Service creating the session. - typ string // Type of login (oneof unspecified, tty, x11). - class string // Type of session class (oneof user, greeter, lock-screen). - desktop string // the desktop environment. - seat string // the seat this session belongs to, empty otherwise. - vtnr uint32 // the virtual terminal number of the session if there is any, 0 otherwise. - tty string // the kernel TTY path of the session if this is a text login, empty otherwise. - display string // the X11 display name if this is a graphical login, empty otherwise. - remote bool // whether the session is remote. - remoteUser string // the remote user if this is a remote session, empty otherwise. - remoteHost string // the remote host if this is a remote session, empty otherwise. - properties []struct { // This is unused and exists just to make the marshaling work - S string - V dbus.Variant - } -} - -func (a createSessionArgs) args() []any { - return []any{ - a.uid, - a.pid, - a.service, - a.typ, - a.class, - a.desktop, - a.seat, - a.vtnr, - a.tty, - a.display, - a.remote, - a.remoteUser, - a.remoteHost, - a.properties, - } -} - -// createSessionResp is a wrapper struct for the Login1.Manager.CreateSession response. -// The CreateSession API arguments and response types are defined here: -// https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html -type createSessionResp struct { - sessionID string - objectPath dbus.ObjectPath - runtimePath string - fifoFD dbus.UnixFD - uid uint32 - seatID string - vtnr uint32 - existing bool // whether a new session was created. -} - -// createSession creates a tty user login session for the provided uid. -func createSession(uid uint32, remoteUser, remoteHost, tty string) (createSessionResp, error) { - a := createSessionArgs{ - uid: uid, - service: "tailscaled", - typ: "tty", - class: "user", - tty: tty, - remote: true, - remoteUser: remoteUser, - remoteHost: remoteHost, - } - - call, err := callLogin1("org.freedesktop.login1.Manager.CreateSession", 0, a.args()...) - if err != nil { - return createSessionResp{}, err - } - - return createSessionResp{ - sessionID: call.Body[0].(string), - objectPath: call.Body[1].(dbus.ObjectPath), - runtimePath: call.Body[2].(string), - fifoFD: call.Body[3].(dbus.UnixFD), - uid: call.Body[4].(uint32), - seatID: call.Body[5].(string), - vtnr: call.Body[6].(uint32), - existing: call.Body[7].(bool), - }, nil -} - -// releaseSession releases the session identified by sessionID. -func releaseSession(sessionID string) error { - // https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html - _, err := callLogin1("org.freedesktop.login1.Manager.ReleaseSession", dbus.FlagNoReplyExpected, sessionID) - return err -} - -// maybeStartLoginSessionLinux is the linux implementation of maybeStartLoginSession. -func maybeStartLoginSessionLinux(dlogf logger.Logf, ia incubatorArgs) func() error { - if os.Geteuid() != 0 { - return nil - } - dlogf("starting session for user %d", ia.uid) - // The only way we can actually start a new session is if we are - // running outside one and are root, which is typically the case - // for systemd managed tailscaled. - resp, err := createSession(uint32(ia.uid), ia.remoteUser, ia.remoteIP, ia.ttyName) - if err != nil { - // TODO(maisem): figure out if we are running in a session. - // We can look at the DBus GetSessionByPID API. - // https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html - // For now best effort is fine. - dlogf("ssh: failed to CreateSession for user %q (%d) %v", ia.localUser, ia.uid, err) - return nil - } - os.Setenv("DBUS_SESSION_BUS_ADDRESS", fmt.Sprintf("unix:path=%v/bus", resp.runtimePath)) - if !resp.existing { - return func() error { - return releaseSession(resp.sessionID) - } - } - return nil -} diff --git a/ssh/tailssh/privs_test.go b/ssh/tailssh/privs_test.go deleted file mode 100644 index 32b219a7798ca..0000000000000 --- a/ssh/tailssh/privs_test.go +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly - -package tailssh - -import ( - "encoding/json" - "errors" - "os" - "os/exec" - "os/user" - "path/filepath" - "reflect" - "regexp" - "runtime" - "slices" - "strconv" - "syscall" - "testing" - - "tailscale.com/types/logger" -) - -func TestDoDropPrivileges(t *testing.T) { - type SubprocInput struct { - UID int - GID int - AdditionalGroups []int - } - type SubprocOutput struct { - UID int - GID int - EUID int - EGID int - AdditionalGroups []int - } - - if v := os.Getenv("TS_TEST_DROP_PRIVILEGES_CHILD"); v != "" { - t.Logf("in child process") - - var input SubprocInput - if err := json.Unmarshal([]byte(v), &input); err != nil { - t.Fatal(err) - } - - // Get a handle to our provided JSON file before dropping privs. - f := os.NewFile(3, "out.json") - - // We're in our subprocess; actually drop privileges now. - doDropPrivileges(t.Logf, input.UID, input.GID, input.AdditionalGroups, "/") - - additional, _ := syscall.Getgroups() - - // Print our IDs - json.NewEncoder(f).Encode(SubprocOutput{ - UID: os.Getuid(), - GID: os.Getgid(), - EUID: os.Geteuid(), - EGID: os.Getegid(), - AdditionalGroups: additional, - }) - - // Close output file to ensure that it's flushed to disk before we exit - f.Close() - - // Always exit the process now that we have a different - // UID/GID/etc.; we don't want the Go test framework to try and - // clean anything up, since it might no longer have access. - os.Exit(0) - } - - if os.Getuid() != 0 { - t.Skip("test only works when run as root") - } - - rerunSelf := func(t *testing.T, input SubprocInput) []byte { - fpath := filepath.Join(t.TempDir(), "out.json") - outf, err := os.Create(fpath) - if err != nil { - t.Fatal(err) - } - - inputb, err := json.Marshal(input) - if err != nil { - t.Fatal(err) - } - - cmd := exec.Command(os.Args[0], "-test.v", "-test.run", "^"+regexp.QuoteMeta(t.Name())+"$") - cmd.Env = append(os.Environ(), "TS_TEST_DROP_PRIVILEGES_CHILD="+string(inputb)) - cmd.ExtraFiles = []*os.File{outf} - cmd.Stdout = logger.FuncWriter(logger.WithPrefix(t.Logf, "child: ")) - cmd.Stderr = logger.FuncWriter(logger.WithPrefix(t.Logf, "child: ")) - if err := cmd.Run(); err != nil { - t.Fatal(err) - } - outf.Close() - - jj, err := os.ReadFile(fpath) - if err != nil { - t.Fatal(err) - } - return jj - } - - // We want to ensure we're not colliding with existing users; find some - // unused UIDs and GIDs for the tests we run. - uid1 := findUnusedUID(t) - gid1 := findUnusedGID(t) - gid2 := findUnusedGID(t, gid1) - gid3 := findUnusedGID(t, gid1, gid2) - - // For some tests, we want a UID/GID pair with the same numerical - // value; this finds one. - uidgid1 := findUnusedUIDGID(t, uid1, gid1, gid2, gid3) - - t.Logf("uid1=%d gid1=%d gid2=%d gid3=%d uidgid1=%d", - uid1, gid1, gid2, gid3, uidgid1) - - testCases := []struct { - name string - uid int - gid int - additionalGroups []int - }{ - { - name: "all_different_values", - uid: uid1, - gid: gid1, - additionalGroups: []int{gid2, gid3}, - }, - { - name: "no_additional_groups", - uid: uid1, - gid: gid1, - additionalGroups: []int{}, - }, - // This is a regression test for the following bug, triggered - // on Darwin & FreeBSD: - // https://github.com/tailscale/tailscale/issues/7616 - { - name: "same_values", - uid: uidgid1, - gid: uidgid1, - additionalGroups: []int{uidgid1}, - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - subprocOut := rerunSelf(t, SubprocInput{ - UID: tt.uid, - GID: tt.gid, - AdditionalGroups: tt.additionalGroups, - }) - - var out SubprocOutput - if err := json.Unmarshal(subprocOut, &out); err != nil { - t.Logf("%s", subprocOut) - t.Fatal(err) - } - t.Logf("output: %+v", out) - - if out.UID != tt.uid { - t.Errorf("got uid %d; want %d", out.UID, tt.uid) - } - if out.GID != tt.gid { - t.Errorf("got gid %d; want %d", out.GID, tt.gid) - } - if out.EUID != tt.uid { - t.Errorf("got euid %d; want %d", out.EUID, tt.uid) - } - if out.EGID != tt.gid { - t.Errorf("got egid %d; want %d", out.EGID, tt.gid) - } - - // On FreeBSD and Darwin, the set of additional groups - // is prefixed with the egid; handle that case by - // modifying our expected set. - wantGroups := make(map[int]bool) - for _, id := range tt.additionalGroups { - wantGroups[id] = true - } - if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { - wantGroups[tt.gid] = true - } - - gotGroups := make(map[int]bool) - for _, id := range out.AdditionalGroups { - gotGroups[id] = true - } - - if !reflect.DeepEqual(gotGroups, wantGroups) { - t.Errorf("got additional groups %+v; want %+v", gotGroups, wantGroups) - } - }) - } -} - -func findUnusedUID(t *testing.T, not ...int) int { - for i := 1000; i < 65535; i++ { - // Skip UIDs that might be valid - if maybeValidUID(i) { - continue - } - - // Skip UIDs that we're avoiding - if slices.Contains(not, i) { - continue - } - - // Not a valid UID, not one we're avoiding... all good! - return i - } - - t.Fatalf("unable to find an unused UID") - return -1 -} - -func findUnusedGID(t *testing.T, not ...int) int { - for i := 1000; i < 65535; i++ { - if maybeValidGID(i) { - continue - } - - // Skip GIDs that we're avoiding - if slices.Contains(not, i) { - continue - } - - // Not a valid GID, not one we're avoiding... all good! - return i - } - - t.Fatalf("unable to find an unused GID") - return -1 -} - -func findUnusedUIDGID(t *testing.T, not ...int) int { - for i := 1000; i < 65535; i++ { - if maybeValidUID(i) || maybeValidGID(i) { - continue - } - - // Skip IDs that we're avoiding - if slices.Contains(not, i) { - continue - } - - // Not a valid ID, not one we're avoiding... all good! - return i - } - - t.Fatalf("unable to find an unused UID/GID pair") - return -1 -} - -func maybeValidUID(id int) bool { - _, err := user.LookupId(strconv.Itoa(id)) - if err == nil { - return true - } - - var u1 user.UnknownUserIdError - if errors.As(err, &u1) { - return false - } - var u2 user.UnknownUserError - if errors.As(err, &u2) { - return false - } - - // Some other error; might be valid - return true -} - -func maybeValidGID(id int) bool { - _, err := user.LookupGroupId(strconv.Itoa(id)) - if err == nil { - return true - } - - var u1 user.UnknownGroupIdError - if errors.As(err, &u1) { - return false - } - var u2 user.UnknownGroupError - if errors.As(err, &u2) { - return false - } - - // Some other error; might be valid - return true -} diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go deleted file mode 100644 index 7cb99c3813104..0000000000000 --- a/ssh/tailssh/tailssh.go +++ /dev/null @@ -1,1786 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || (darwin && !ios) || freebsd || openbsd - -// Package tailssh is an SSH server integrated into Tailscale. -package tailssh - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/netip" - "net/url" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - - gossh "github.com/tailscale/golang-x-crypto/ssh" - "tailscale.com/envknob" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/logtail/backoff" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tsdial" - "tailscale.com/sessionrecording" - "tailscale.com/tailcfg" - "tailscale.com/tempfork/gliderlabs/ssh" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/util/clientmetric" - "tailscale.com/util/httpm" - "tailscale.com/util/mak" - "tailscale.com/util/slicesx" -) - -var ( - sshVerboseLogging = envknob.RegisterBool("TS_DEBUG_SSH_VLOG") - sshDisableSFTP = envknob.RegisterBool("TS_SSH_DISABLE_SFTP") - sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING") - sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY") -) - -const ( - // forcePasswordSuffix is the suffix at the end of a username that forces - // Tailscale SSH into password authentication mode to work around buggy SSH - // clients that get confused by successful replies to auth type "none". - forcePasswordSuffix = "+password" -) - -// ipnLocalBackend is the subset of ipnlocal.LocalBackend that we use. -// It is used for testing. -type ipnLocalBackend interface { - GetSSH_HostKeys() ([]gossh.Signer, error) - ShouldRunSSH() bool - NetMap() *netmap.NetworkMap - WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) - DoNoiseRequest(req *http.Request) (*http.Response, error) - Dialer() *tsdial.Dialer - TailscaleVarRoot() string - NodeKey() key.NodePublic -} - -type server struct { - lb ipnLocalBackend - logf logger.Logf - tailscaledPath string - - pubKeyHTTPClient *http.Client // or nil for http.DefaultClient - timeNow func() time.Time // or nil for time.Now - - sessionWaitGroup sync.WaitGroup - - // mu protects the following - mu sync.Mutex - activeConns map[*conn]bool // set; value is always true - fetchPublicKeysCache map[string]pubKeyCacheEntry // by https URL - shutdownCalled bool -} - -func (srv *server) now() time.Time { - if srv != nil && srv.timeNow != nil { - return srv.timeNow() - } - return time.Now() -} - -func init() { - ipnlocal.RegisterNewSSHServer(func(logf logger.Logf, lb *ipnlocal.LocalBackend) (ipnlocal.SSHServer, error) { - tsd, err := os.Executable() - if err != nil { - return nil, err - } - srv := &server{ - lb: lb, - logf: logf, - tailscaledPath: tsd, - timeNow: func() time.Time { - return lb.ControlNow(time.Now()) - }, - } - - return srv, nil - }) -} - -// attachSessionToConnIfNotShutdown ensures that srv is not shutdown before -// attaching the session to the conn. This ensures that once Shutdown is called, -// new sessions are not allowed and existing ones are cleaned up. -// It reports whether ss was attached to the conn. -func (srv *server) attachSessionToConnIfNotShutdown(ss *sshSession) bool { - srv.mu.Lock() - defer srv.mu.Unlock() - if srv.shutdownCalled { - // Do not start any new sessions. - return false - } - ss.conn.attachSession(ss) - return true -} - -func (srv *server) trackActiveConn(c *conn, add bool) { - srv.mu.Lock() - defer srv.mu.Unlock() - if add { - mak.Set(&srv.activeConns, c, true) - return - } - delete(srv.activeConns, c) -} - -// NumActiveConns returns the number of active SSH connections. -func (srv *server) NumActiveConns() int { - srv.mu.Lock() - defer srv.mu.Unlock() - return len(srv.activeConns) -} - -// HandleSSHConn handles a Tailscale SSH connection from c. -// This is the entry point for all SSH connections. -// When this returns, the connection is closed. -func (srv *server) HandleSSHConn(nc net.Conn) error { - metricIncomingConnections.Add(1) - c, err := srv.newConn() - if err != nil { - return err - } - srv.trackActiveConn(c, true) // add - defer srv.trackActiveConn(c, false) // remove - c.HandleConn(nc) - - // Return nil to signal to netstack's interception that it doesn't need to - // log. If ss.HandleConn had problems, it can log itself (ideally on an - // sshSession.logf). - return nil -} - -// Shutdown terminates all active sessions. -func (srv *server) Shutdown() { - srv.mu.Lock() - srv.shutdownCalled = true - for c := range srv.activeConns { - c.Close() - } - srv.mu.Unlock() - srv.sessionWaitGroup.Wait() -} - -// OnPolicyChange terminates any active sessions that no longer match -// the SSH access policy. -func (srv *server) OnPolicyChange() { - srv.mu.Lock() - defer srv.mu.Unlock() - for c := range srv.activeConns { - if c.info == nil { - // c.info is nil when the connection hasn't been authenticated yet. - // In that case, the connection will be terminated when it is. - continue - } - go c.checkStillValid() - } -} - -// conn represents a single SSH connection and its associated -// ssh.Server. -// -// During the lifecycle of a connection, the following are called in order: -// Setup and discover server info -// - ServerConfigCallback -// -// Do the user auth -// - NoClientAuthHandler -// - PublicKeyHandler (only if NoClientAuthHandler returns errPubKeyRequired) -// -// Once auth is done, the conn can be multiplexed with multiple sessions and -// channels concurrently. At which point any of the following can be called -// in any order. -// - c.handleSessionPostSSHAuth -// - c.mayForwardLocalPortTo followed by ssh.DirectTCPIPHandler -type conn struct { - *ssh.Server - srv *server - - insecureSkipTailscaleAuth bool // used by tests. - - // idH is the RFC4253 sec8 hash H. It is used to identify the connection, - // and is shared among all sessions. It should not be shared outside - // process. It is confusingly referred to as SessionID by the gliderlabs/ssh - // library. - idH string - connID string // ID that's shared with control - - // anyPasswordIsOkay is whether the client is authorized but has requested - // password-based auth to work around their buggy SSH client. When set, we - // accept any password in the PasswordHandler. - anyPasswordIsOkay bool // set by NoClientAuthCallback - - action0 *tailcfg.SSHAction // set by doPolicyAuth; first matching action - currentAction *tailcfg.SSHAction // set by doPolicyAuth, updated by resolveNextAction - finalAction *tailcfg.SSHAction // set by doPolicyAuth or resolveNextAction - finalActionErr error // set by doPolicyAuth or resolveNextAction - - info *sshConnInfo // set by setInfo - localUser *userMeta // set by doPolicyAuth - userGroupIDs []string // set by doPolicyAuth - pubKey gossh.PublicKey // set by doPolicyAuth - acceptEnv []string - - // mu protects the following fields. - // - // srv.mu should be acquired prior to mu. - // It is safe to just acquire mu, but unsafe to - // acquire mu and then srv.mu. - mu sync.Mutex // protects the following - sessions []*sshSession -} - -func (c *conn) logf(format string, args ...any) { - format = fmt.Sprintf("%v: %v", c.connID, format) - c.srv.logf(format, args...) -} - -func (c *conn) vlogf(format string, args ...any) { - if sshVerboseLogging() { - c.logf(format, args...) - } -} - -// isAuthorized walks through the action chain and returns nil if the connection -// is authorized. If the connection is not authorized, it returns -// errDenied. If the action chain resolution fails, it returns the -// resolution error. -func (c *conn) isAuthorized(ctx ssh.Context) error { - action := c.currentAction - for { - if action.Accept { - if c.pubKey != nil { - metricPublicKeyAccepts.Add(1) - } - return nil - } - if action.Reject || action.HoldAndDelegate == "" { - return errDenied - } - var err error - action, err = c.resolveNextAction(ctx) - if err != nil { - return err - } - if action.Message != "" { - if err := ctx.SendAuthBanner(action.Message); err != nil { - return err - } - } - } -} - -// errDenied is returned by auth callbacks when a connection is denied by the -// policy. -var errDenied = errors.New("ssh: access denied") - -// errPubKeyRequired is returned by NoClientAuthCallback to make the client -// resort to public-key auth; not user visible. -var errPubKeyRequired = errors.New("ssh publickey required") - -// NoClientAuthCallback implements gossh.NoClientAuthCallback and is called by -// the ssh.Server when the client first connects with the "none" -// authentication method. -// -// It is responsible for continuing policy evaluation from BannerCallback (or -// starting it afresh). It returns an error if the policy evaluation fails, or -// if the decision is "reject" -// -// It either returns nil (accept) or errPubKeyRequired or errDenied -// (reject). The errors may be wrapped. -func (c *conn) NoClientAuthCallback(ctx ssh.Context) error { - if c.insecureSkipTailscaleAuth { - return nil - } - if err := c.doPolicyAuth(ctx, nil /* no pub key */); err != nil { - return err - } - if err := c.isAuthorized(ctx); err != nil { - return err - } - - // Let users specify a username ending in +password to force password auth. - // This exists for buggy SSH clients that get confused by success from - // "none" auth. - if strings.HasSuffix(ctx.User(), forcePasswordSuffix) { - c.anyPasswordIsOkay = true - return errors.New("any password please") // not shown to users - } - return nil -} - -func (c *conn) nextAuthMethodCallback(cm gossh.ConnMetadata, prevErrors []error) (nextMethod []string) { - switch { - case c.anyPasswordIsOkay: - nextMethod = append(nextMethod, "password") - case slicesx.LastEqual(prevErrors, errPubKeyRequired): - nextMethod = append(nextMethod, "publickey") - } - - // The fake "tailscale" method is always appended to next so OpenSSH renders - // that in parens as the final failure. (It also shows up in "ssh -v", etc) - nextMethod = append(nextMethod, "tailscale") - return -} - -// fakePasswordHandler is our implementation of the PasswordHandler hook that -// checks whether the user's password is correct. But we don't actually use -// passwords. This exists only for when the user's username ends in "+password" -// to signal that their SSH client is buggy and gets confused by auth type -// "none" succeeding and they want our SSH server to require a dummy password -// prompt instead. We then accept any password since we've already authenticated -// & authorized them. -func (c *conn) fakePasswordHandler(ctx ssh.Context, password string) bool { - return c.anyPasswordIsOkay -} - -// PublicKeyHandler implements ssh.PublicKeyHandler is called by the -// ssh.Server when the client presents a public key. -func (c *conn) PublicKeyHandler(ctx ssh.Context, pubKey ssh.PublicKey) error { - if err := c.doPolicyAuth(ctx, pubKey); err != nil { - // TODO(maisem/bradfitz): surface the error here. - c.logf("rejecting SSH public key %s: %v", bytes.TrimSpace(gossh.MarshalAuthorizedKey(pubKey)), err) - return err - } - if err := c.isAuthorized(ctx); err != nil { - return err - } - c.logf("accepting SSH public key %s", bytes.TrimSpace(gossh.MarshalAuthorizedKey(pubKey))) - return nil -} - -// doPolicyAuth verifies that conn can proceed with the specified (optional) -// pubKey. It returns nil if the matching policy action is Accept or -// HoldAndDelegate. If pubKey is nil, there was no policy match but there is a -// policy that might match a public key it returns errPubKeyRequired. Otherwise, -// it returns errDenied. -func (c *conn) doPolicyAuth(ctx ssh.Context, pubKey ssh.PublicKey) error { - if err := c.setInfo(ctx); err != nil { - c.logf("failed to get conninfo: %v", err) - return errDenied - } - a, localUser, acceptEnv, err := c.evaluatePolicy(pubKey) - if err != nil { - if pubKey == nil && c.havePubKeyPolicy() { - return errPubKeyRequired - } - return fmt.Errorf("%w: %v", errDenied, err) - } - c.action0 = a - c.currentAction = a - c.pubKey = pubKey - c.acceptEnv = acceptEnv - if a.Message != "" { - if err := ctx.SendAuthBanner(a.Message); err != nil { - return fmt.Errorf("SendBanner: %w", err) - } - } - if a.Accept || a.HoldAndDelegate != "" { - if a.Accept { - c.finalAction = a - } - lu, err := userLookup(localUser) - if err != nil { - c.logf("failed to look up %v: %v", localUser, err) - ctx.SendAuthBanner(fmt.Sprintf("failed to look up %v\r\n", localUser)) - return err - } - gids, err := lu.GroupIds() - if err != nil { - c.logf("failed to look up local user's group IDs: %v", err) - return err - } - c.userGroupIDs = gids - c.localUser = lu - return nil - } - if a.Reject { - c.finalAction = a - return errDenied - } - // Shouldn't get here, but: - return errDenied -} - -// ServerConfig implements ssh.ServerConfigCallback. -func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig { - return &gossh.ServerConfig{ - NoClientAuth: true, // required for the NoClientAuthCallback to run - NextAuthMethodCallback: c.nextAuthMethodCallback, - } -} - -func (srv *server) newConn() (*conn, error) { - srv.mu.Lock() - if srv.shutdownCalled { - srv.mu.Unlock() - // Stop accepting new connections. - // Connections in the auth phase are handled in handleConnPostSSHAuth. - // Existing sessions are terminated by Shutdown. - return nil, errDenied - } - srv.mu.Unlock() - c := &conn{srv: srv} - now := srv.now() - c.connID = fmt.Sprintf("ssh-conn-%s-%02x", now.UTC().Format("20060102T150405"), randBytes(5)) - fwdHandler := &ssh.ForwardedTCPHandler{} - c.Server = &ssh.Server{ - Version: "Tailscale", - ServerConfigCallback: c.ServerConfig, - - NoClientAuthHandler: c.NoClientAuthCallback, - PublicKeyHandler: c.PublicKeyHandler, - PasswordHandler: c.fakePasswordHandler, - - Handler: c.handleSessionPostSSHAuth, - LocalPortForwardingCallback: c.mayForwardLocalPortTo, - ReversePortForwardingCallback: c.mayReversePortForwardTo, - SubsystemHandlers: map[string]ssh.SubsystemHandler{ - "sftp": c.handleSessionPostSSHAuth, - }, - // Note: the direct-tcpip channel handler and LocalPortForwardingCallback - // only adds support for forwarding ports from the local machine. - // TODO(maisem/bradfitz): add remote port forwarding support. - ChannelHandlers: map[string]ssh.ChannelHandler{ - "direct-tcpip": ssh.DirectTCPIPHandler, - }, - RequestHandlers: map[string]ssh.RequestHandler{ - "tcpip-forward": fwdHandler.HandleSSHRequest, - "cancel-tcpip-forward": fwdHandler.HandleSSHRequest, - }, - } - ss := c.Server - for k, v := range ssh.DefaultRequestHandlers { - ss.RequestHandlers[k] = v - } - for k, v := range ssh.DefaultChannelHandlers { - ss.ChannelHandlers[k] = v - } - for k, v := range ssh.DefaultSubsystemHandlers { - ss.SubsystemHandlers[k] = v - } - keys, err := srv.lb.GetSSH_HostKeys() - if err != nil { - return nil, err - } - for _, signer := range keys { - ss.AddHostKey(signer) - } - return c, nil -} - -// mayReversePortPortForwardTo reports whether the ctx should be allowed to port forward -// to the specified host and port. -// TODO(bradfitz/maisem): should we have more checks on host/port? -func (c *conn) mayReversePortForwardTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { - if sshDisableForwarding() { - return false - } - if c.finalAction != nil && c.finalAction.AllowRemotePortForwarding { - metricRemotePortForward.Add(1) - return true - } - return false -} - -// mayForwardLocalPortTo reports whether the ctx should be allowed to port forward -// to the specified host and port. -// TODO(bradfitz/maisem): should we have more checks on host/port? -func (c *conn) mayForwardLocalPortTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { - if sshDisableForwarding() { - return false - } - if c.finalAction != nil && c.finalAction.AllowLocalPortForwarding { - metricLocalPortForward.Add(1) - return true - } - return false -} - -// havePubKeyPolicy reports whether any policy rule may provide access by means -// of a ssh.PublicKey. -func (c *conn) havePubKeyPolicy() bool { - if c.info == nil { - panic("havePubKeyPolicy called before setInfo") - } - // Is there any rule that looks like it'd require a public key for this - // sshUser? - pol, ok := c.sshPolicy() - if !ok { - return false - } - for _, r := range pol.Rules { - if c.ruleExpired(r) { - continue - } - if mapLocalUser(r.SSHUsers, c.info.sshUser) == "" { - continue - } - for _, p := range r.Principals { - if len(p.PubKeys) > 0 && c.principalMatchesTailscaleIdentity(p) { - return true - } - } - } - return false -} - -// sshPolicy returns the SSHPolicy for current node. -// If there is no SSHPolicy in the netmap, it returns a debugPolicy -// if one is defined. -func (c *conn) sshPolicy() (_ *tailcfg.SSHPolicy, ok bool) { - lb := c.srv.lb - if !lb.ShouldRunSSH() { - return nil, false - } - nm := lb.NetMap() - if nm == nil { - return nil, false - } - if pol := nm.SSHPolicy; pol != nil && !envknob.SSHIgnoreTailnetPolicy() { - return pol, true - } - debugPolicyFile := envknob.SSHPolicyFile() - if debugPolicyFile != "" { - c.logf("reading debug SSH policy file: %v", debugPolicyFile) - f, err := os.ReadFile(debugPolicyFile) - if err != nil { - c.logf("error reading debug SSH policy file: %v", err) - return nil, false - } - p := new(tailcfg.SSHPolicy) - if err := json.Unmarshal(f, p); err != nil { - c.logf("invalid JSON in %v: %v", debugPolicyFile, err) - return nil, false - } - return p, true - } - return nil, false -} - -func toIPPort(a net.Addr) (ipp netip.AddrPort) { - ta, ok := a.(*net.TCPAddr) - if !ok { - return - } - tanetaddr, ok := netip.AddrFromSlice(ta.IP) - if !ok { - return - } - return netip.AddrPortFrom(tanetaddr.Unmap(), uint16(ta.Port)) -} - -// connInfo returns a populated sshConnInfo from the provided arguments, -// validating only that they represent a known Tailscale identity. -func (c *conn) setInfo(ctx ssh.Context) error { - if c.info != nil { - return nil - } - ci := &sshConnInfo{ - sshUser: strings.TrimSuffix(ctx.User(), forcePasswordSuffix), - src: toIPPort(ctx.RemoteAddr()), - dst: toIPPort(ctx.LocalAddr()), - } - if !tsaddr.IsTailscaleIP(ci.dst.Addr()) { - return fmt.Errorf("tailssh: rejecting non-Tailscale local address %v", ci.dst) - } - if !tsaddr.IsTailscaleIP(ci.src.Addr()) { - return fmt.Errorf("tailssh: rejecting non-Tailscale remote address %v", ci.src) - } - node, uprof, ok := c.srv.lb.WhoIs("tcp", ci.src) - if !ok { - return fmt.Errorf("unknown Tailscale identity from src %v", ci.src) - } - ci.node = node - ci.uprof = uprof - - c.idH = ctx.SessionID() - c.info = ci - c.logf("handling conn: %v", ci.String()) - return nil -} - -// evaluatePolicy returns the SSHAction and localUser after evaluating -// the SSHPolicy for this conn. The pubKey may be nil for "none" auth. -func (c *conn) evaluatePolicy(pubKey gossh.PublicKey) (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, _ error) { - pol, ok := c.sshPolicy() - if !ok { - return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no SSH policy") - } - a, localUser, acceptEnv, ok := c.evalSSHPolicy(pol, pubKey) - if !ok { - return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no matching policy") - } - return a, localUser, acceptEnv, nil -} - -// pubKeyCacheEntry is the cache value for an HTTPS URL of public keys (like -// "https://github.com/foo.keys") -type pubKeyCacheEntry struct { - lines []string - etag string // if sent by server - at time.Time -} - -const ( - pubKeyCacheDuration = time.Minute // how long to cache non-empty public keys - pubKeyCacheEmptyDuration = 15 * time.Second // how long to cache empty responses -) - -func (srv *server) fetchPublicKeysURLCached(url string) (ce pubKeyCacheEntry, ok bool) { - srv.mu.Lock() - defer srv.mu.Unlock() - // Mostly don't care about the size of this cache. Clean rarely. - if m := srv.fetchPublicKeysCache; len(m) > 50 { - tooOld := srv.now().Add(pubKeyCacheDuration * 10) - for k, ce := range m { - if ce.at.Before(tooOld) { - delete(m, k) - } - } - } - ce, ok = srv.fetchPublicKeysCache[url] - if !ok { - return ce, false - } - maxAge := pubKeyCacheDuration - if len(ce.lines) == 0 { - maxAge = pubKeyCacheEmptyDuration - } - return ce, srv.now().Sub(ce.at) < maxAge -} - -func (srv *server) pubKeyClient() *http.Client { - if srv.pubKeyHTTPClient != nil { - return srv.pubKeyHTTPClient - } - return http.DefaultClient -} - -// fetchPublicKeysURL fetches the public keys from a URL. The strings are in the -// the typical public key "type base64-string [comment]" format seen at e.g. -// https://github.com/USER.keys -func (srv *server) fetchPublicKeysURL(url string) ([]string, error) { - if !strings.HasPrefix(url, "https://") { - return nil, errors.New("invalid URL scheme") - } - - ce, ok := srv.fetchPublicKeysURLCached(url) - if ok { - return ce.lines, nil - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - if ce.etag != "" { - req.Header.Add("If-None-Match", ce.etag) - } - res, err := srv.pubKeyClient().Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - var lines []string - var etag string - switch res.StatusCode { - default: - err = fmt.Errorf("unexpected status %v", res.Status) - srv.logf("fetching public keys from %s: %v", url, err) - case http.StatusNotModified: - lines = ce.lines - etag = ce.etag - case http.StatusOK: - var all []byte - all, err = io.ReadAll(io.LimitReader(res.Body, 4<<10)) - if s := strings.TrimSpace(string(all)); s != "" { - lines = strings.Split(s, "\n") - } - etag = res.Header.Get("Etag") - } - - srv.mu.Lock() - defer srv.mu.Unlock() - mak.Set(&srv.fetchPublicKeysCache, url, pubKeyCacheEntry{ - at: srv.now(), - lines: lines, - etag: etag, - }) - return lines, err -} - -// handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication, -// but not necessarily before all the Tailscale-level extra verification has -// completed. It also handles SFTP requests. -func (c *conn) handleSessionPostSSHAuth(s ssh.Session) { - // Do this check after auth, but before starting the session. - switch s.Subsystem() { - case "sftp": - if sshDisableSFTP() { - fmt.Fprintf(s.Stderr(), "sftp disabled\r\n") - s.Exit(1) - return - } - metricSFTP.Add(1) - case "": - // Regular SSH session. - default: - fmt.Fprintf(s.Stderr(), "Unsupported subsystem %q\r\n", s.Subsystem()) - s.Exit(1) - return - } - - ss := c.newSSHSession(s) - ss.logf("handling new SSH connection from %v (%v) to ssh-user %q", c.info.uprof.LoginName, c.info.src.Addr(), c.localUser.Username) - ss.logf("access granted to %v as ssh-user %q", c.info.uprof.LoginName, c.localUser.Username) - ss.run() -} - -// resolveNextAction starts at c.currentAction and makes it way through the -// action chain one step at a time. An action without a HoldAndDelegate is -// considered the final action. Once a final action is reached, this function -// will keep returning that action. It updates c.currentAction to the next -// action in the chain. When the final action is reached, it also sets -// c.finalAction to the final action. -func (c *conn) resolveNextAction(sctx ssh.Context) (action *tailcfg.SSHAction, err error) { - if c.finalAction != nil || c.finalActionErr != nil { - return c.finalAction, c.finalActionErr - } - - defer func() { - if action != nil { - c.currentAction = action - if action.Accept || action.Reject { - c.finalAction = action - } - } - if err != nil { - c.finalActionErr = err - } - }() - - ctx, cancel := context.WithCancel(sctx) - defer cancel() - - // Loop processing/fetching Actions until one reaches a - // terminal state (Accept, Reject, or invalid Action), or - // until fetchSSHAction times out due to the context being - // done (client disconnect) or its 30 minute timeout passes. - // (Which is a long time for somebody to see login - // instructions and go to a URL to do something.) - action = c.currentAction - if action.Accept || action.Reject { - if action.Reject { - metricTerminalReject.Add(1) - } else { - metricTerminalAccept.Add(1) - } - return action, nil - } - url := action.HoldAndDelegate - if url == "" { - metricTerminalMalformed.Add(1) - return nil, errors.New("reached Action that lacked Accept, Reject, and HoldAndDelegate") - } - metricHolds.Add(1) - url = c.expandDelegateURLLocked(url) - nextAction, err := c.fetchSSHAction(ctx, url) - if err != nil { - metricTerminalFetchError.Add(1) - return nil, fmt.Errorf("fetching SSHAction from %s: %w", url, err) - } - return nextAction, nil -} - -func (c *conn) expandDelegateURLLocked(actionURL string) string { - nm := c.srv.lb.NetMap() - ci := c.info - lu := c.localUser - var dstNodeID string - if nm != nil { - dstNodeID = fmt.Sprint(int64(nm.SelfNode.ID())) - } - return strings.NewReplacer( - "$SRC_NODE_IP", url.QueryEscape(ci.src.Addr().String()), - "$SRC_NODE_ID", fmt.Sprint(int64(ci.node.ID())), - "$DST_NODE_IP", url.QueryEscape(ci.dst.Addr().String()), - "$DST_NODE_ID", dstNodeID, - "$SSH_USER", url.QueryEscape(ci.sshUser), - "$LOCAL_USER", url.QueryEscape(lu.Username), - ).Replace(actionURL) -} - -func (c *conn) expandPublicKeyURL(pubKeyURL string) string { - if !strings.Contains(pubKeyURL, "$") { - return pubKeyURL - } - loginName := c.info.uprof.LoginName - localPart, _, _ := strings.Cut(loginName, "@") - return strings.NewReplacer( - "$LOGINNAME_EMAIL", loginName, - "$LOGINNAME_LOCALPART", localPart, - ).Replace(pubKeyURL) -} - -// sshSession is an accepted Tailscale SSH session. -type sshSession struct { - ssh.Session - sharedID string // ID that's shared with control - logf logger.Logf - - ctx context.Context - cancelCtx context.CancelCauseFunc - conn *conn - agentListener net.Listener // non-nil if agent-forwarding requested+allowed - - // initialized by launchProcess: - cmd *exec.Cmd - wrStdin io.WriteCloser - rdStdout io.ReadCloser - rdStderr io.ReadCloser // rdStderr is nil for pty sessions - ptyReq *ssh.Pty // non-nil for pty sessions - - // childPipes is a list of pipes that need to be closed when the process exits. - // For pty sessions, this is the tty fd. - // For non-pty sessions, this is the stdin, stdout, stderr fds. - childPipes []io.Closer - - // We use this sync.Once to ensure that we only terminate the process once, - // either it exits itself or is terminated - exitOnce sync.Once -} - -func (ss *sshSession) vlogf(format string, args ...any) { - if sshVerboseLogging() { - ss.logf(format, args...) - } -} - -func (c *conn) newSSHSession(s ssh.Session) *sshSession { - sharedID := fmt.Sprintf("sess-%s-%02x", c.srv.now().UTC().Format("20060102T150405"), randBytes(5)) - c.logf("starting session: %v", sharedID) - ctx, cancel := context.WithCancelCause(s.Context()) - return &sshSession{ - Session: s, - sharedID: sharedID, - ctx: ctx, - cancelCtx: cancel, - conn: c, - logf: logger.WithPrefix(c.srv.logf, "ssh-session("+sharedID+"): "), - } -} - -// isStillValid reports whether the conn is still valid. -func (c *conn) isStillValid() bool { - a, localUser, _, err := c.evaluatePolicy(c.pubKey) - c.vlogf("stillValid: %+v %v %v", a, localUser, err) - if err != nil { - return false - } - if !a.Accept && a.HoldAndDelegate == "" { - return false - } - return c.localUser.Username == localUser -} - -// checkStillValid checks that the conn is still valid per the latest SSHPolicy. -// If not, it terminates all sessions associated with the conn. -func (c *conn) checkStillValid() { - if c.isStillValid() { - return - } - metricPolicyChangeKick.Add(1) - c.logf("session no longer valid per new SSH policy; closing") - c.mu.Lock() - defer c.mu.Unlock() - for _, s := range c.sessions { - s.cancelCtx(userVisibleError{ - fmt.Sprintf("Access revoked.\r\n"), - context.Canceled, - }) - } -} - -func (c *conn) fetchSSHAction(ctx context.Context, url string) (*tailcfg.SSHAction, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) - defer cancel() - bo := backoff.NewBackoff("fetch-ssh-action", c.logf, 10*time.Second) - for { - if err := ctx.Err(); err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - res, err := c.srv.lb.DoNoiseRequest(req) - if err != nil { - bo.BackOff(ctx, err) - continue - } - if res.StatusCode != 200 { - body, _ := io.ReadAll(res.Body) - res.Body.Close() - if len(body) > 1<<10 { - body = body[:1<<10] - } - c.logf("fetch of %v: %s, %s", url, res.Status, body) - bo.BackOff(ctx, fmt.Errorf("unexpected status: %v", res.Status)) - continue - } - a := new(tailcfg.SSHAction) - err = json.NewDecoder(res.Body).Decode(a) - res.Body.Close() - if err != nil { - c.logf("invalid next SSHAction JSON from %v: %v", url, err) - bo.BackOff(ctx, err) - continue - } - return a, nil - } -} - -// killProcessOnContextDone waits for ss.ctx to be done and kills the process, -// unless the process has already exited. -func (ss *sshSession) killProcessOnContextDone() { - <-ss.ctx.Done() - // Either the process has already exited, in which case this does nothing. - // Or, the process is still running in which case this will kill it. - ss.exitOnce.Do(func() { - err := context.Cause(ss.ctx) - if serr, ok := err.(SSHTerminationError); ok { - msg := serr.SSHTerminationMessage() - if msg != "" { - io.WriteString(ss.Stderr(), "\r\n\r\n"+msg+"\r\n\r\n") - } - } - ss.logf("terminating SSH session from %v: %v", ss.conn.info.src.Addr(), err) - // We don't need to Process.Wait here, sshSession.run() does - // the waiting regardless of termination reason. - - // TODO(maisem): should this be a SIGTERM followed by a SIGKILL? - ss.cmd.Process.Kill() - }) -} - -// attachSession registers ss as an active session. -func (c *conn) attachSession(ss *sshSession) { - c.srv.sessionWaitGroup.Add(1) - if ss.sharedID == "" { - panic("empty sharedID") - } - c.mu.Lock() - defer c.mu.Unlock() - c.sessions = append(c.sessions, ss) -} - -// detachSession unregisters s from the list of active sessions. -func (c *conn) detachSession(ss *sshSession) { - defer c.srv.sessionWaitGroup.Done() - c.mu.Lock() - defer c.mu.Unlock() - for i, s := range c.sessions { - if s == ss { - c.sessions = append(c.sessions[:i], c.sessions[i+1:]...) - break - } - } -} - -var errSessionDone = errors.New("session is done") - -// handleSSHAgentForwarding starts a Unix socket listener and in the background -// forwards agent connections between the listener and the ssh.Session. -// On success, it assigns ss.agentListener. -func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *userMeta) error { - if !ssh.AgentRequested(ss) || !ss.conn.finalAction.AllowAgentForwarding { - return nil - } - if sshDisableForwarding() { - // TODO(bradfitz): or do we want to return an error here instead so the user - // gets an error if they ran with ssh -A? But for now we just silently - // don't work, like the condition above. - return nil - } - ss.logf("ssh: agent forwarding requested") - ln, err := ssh.NewAgentListener() - if err != nil { - return err - } - defer func() { - if err != nil && ln != nil { - ln.Close() - } - }() - - uid, err := strconv.ParseUint(lu.Uid, 10, 32) - if err != nil { - return err - } - gid, err := strconv.ParseUint(lu.Gid, 10, 32) - if err != nil { - return err - } - socket := ln.Addr().String() - dir := filepath.Dir(socket) - // Make sure the socket is accessible only by the user. - if err := os.Chmod(socket, 0600); err != nil { - return err - } - if err := os.Chown(socket, int(uid), int(gid)); err != nil { - return err - } - // Make sure the dir is also accessible. - if err := os.Chmod(dir, 0755); err != nil { - return err - } - - go ssh.ForwardAgentConnections(ln, s) - ss.agentListener = ln - return nil -} - -// run is the entrypoint for a newly accepted SSH session. -// -// It handles ss once it's been accepted and determined -// that it should run. -func (ss *sshSession) run() { - metricActiveSessions.Add(1) - defer metricActiveSessions.Add(-1) - defer ss.cancelCtx(errSessionDone) - - if attached := ss.conn.srv.attachSessionToConnIfNotShutdown(ss); !attached { - fmt.Fprintf(ss, "Tailscale SSH is shutting down\r\n") - ss.Exit(1) - return - } - defer ss.conn.detachSession(ss) - - lu := ss.conn.localUser - logf := ss.logf - - if ss.conn.finalAction.SessionDuration != 0 { - t := time.AfterFunc(ss.conn.finalAction.SessionDuration, func() { - ss.cancelCtx(userVisibleError{ - fmt.Sprintf("Session timeout of %v elapsed.", ss.conn.finalAction.SessionDuration), - context.DeadlineExceeded, - }) - }) - defer t.Stop() - } - - if euid := os.Geteuid(); euid != 0 { - if lu.Uid != fmt.Sprint(euid) { - ss.logf("can't switch to user %q from process euid %v", lu.Username, euid) - fmt.Fprintf(ss, "can't switch user\r\n") - ss.Exit(1) - return - } - } - - // Take control of the PTY so that we can configure it below. - // See https://github.com/tailscale/tailscale/issues/4146 - ss.DisablePTYEmulation() - - var rec *recording // or nil if disabled - if ss.Subsystem() != "sftp" { - if err := ss.handleSSHAgentForwarding(ss, lu); err != nil { - ss.logf("agent forwarding failed: %v", err) - } else if ss.agentListener != nil { - // TODO(maisem/bradfitz): add a way to close all session resources - defer ss.agentListener.Close() - } - - if ss.shouldRecord() { - var err error - rec, err = ss.startNewRecording() - if err != nil { - var uve userVisibleError - if errors.As(err, &uve) { - fmt.Fprintf(ss, "%s\r\n", uve.SSHTerminationMessage()) - } else { - fmt.Fprintf(ss, "can't start new recording\r\n") - } - ss.logf("startNewRecording: %v", err) - ss.Exit(1) - return - } - ss.logf("startNewRecording: ") - if rec != nil { - defer rec.Close() - } - } - } - - err := ss.launchProcess() - if err != nil { - logf("start failed: %v", err.Error()) - if errors.Is(err, context.Canceled) { - err := context.Cause(ss.ctx) - var uve userVisibleError - if errors.As(err, &uve) { - fmt.Fprintf(ss, "%s\r\n", uve) - } - } - ss.Exit(1) - return - } - go ss.killProcessOnContextDone() - - var processDone atomic.Bool - go func() { - defer ss.wrStdin.Close() - if _, err := io.Copy(rec.writer("i", ss.wrStdin), ss); err != nil { - logf("stdin copy: %v", err) - ss.cancelCtx(err) - } - }() - outputDone := make(chan struct{}) - var openOutputStreams atomic.Int32 - if ss.rdStderr != nil { - openOutputStreams.Store(2) - } else { - openOutputStreams.Store(1) - } - go func() { - defer ss.rdStdout.Close() - _, err := io.Copy(rec.writer("o", ss), ss.rdStdout) - if err != nil && !errors.Is(err, io.EOF) { - isErrBecauseProcessExited := processDone.Load() && errors.Is(err, syscall.EIO) - if !isErrBecauseProcessExited { - logf("stdout copy: %v", err) - ss.cancelCtx(err) - } - } - if openOutputStreams.Add(-1) == 0 { - ss.CloseWrite() - close(outputDone) - } - }() - // rdStderr is nil for ptys. - if ss.rdStderr != nil { - go func() { - defer ss.rdStderr.Close() - _, err := io.Copy(ss.Stderr(), ss.rdStderr) - if err != nil { - logf("stderr copy: %v", err) - } - if openOutputStreams.Add(-1) == 0 { - ss.CloseWrite() - close(outputDone) - } - }() - } - - err = ss.cmd.Wait() - processDone.Store(true) - - // This will either make the SSH Termination goroutine be a no-op, - // or itself will be a no-op because the process was killed by the - // aforementioned goroutine. - ss.exitOnce.Do(func() {}) - - // Close the process-side of all pipes to signal the asynchronous - // io.Copy routines reading/writing from the pipes to terminate. - // Block for the io.Copy to finish before calling ss.Exit below. - closeAll(ss.childPipes...) - select { - case <-outputDone: - case <-ss.ctx.Done(): - } - - if err == nil { - ss.logf("Session complete") - ss.Exit(0) - return - } - if ee, ok := err.(*exec.ExitError); ok { - code := ee.ProcessState.ExitCode() - ss.logf("Wait: code=%v", code) - ss.Exit(code) - return - } - - ss.logf("Wait: %v", err) - ss.Exit(1) - return -} - -// recordSSHToLocalDisk is a deprecated dev knob to allow recording SSH sessions -// to local storage. It is only used if there is no recording configured by the -// coordination server. This will be removed in the future. -var recordSSHToLocalDisk = envknob.RegisterBool("TS_DEBUG_LOG_SSH") - -// recorders returns the list of recorders to use for this session. -// If the final action has a non-empty list of recorders, that list is -// returned. Otherwise, the list of recorders from the initial action -// is returned. -func (ss *sshSession) recorders() ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) { - if len(ss.conn.finalAction.Recorders) > 0 { - return ss.conn.finalAction.Recorders, ss.conn.finalAction.OnRecordingFailure - } - return ss.conn.action0.Recorders, ss.conn.action0.OnRecordingFailure -} - -func (ss *sshSession) shouldRecord() bool { - recs, _ := ss.recorders() - return len(recs) > 0 || recordSSHToLocalDisk() -} - -type sshConnInfo struct { - // sshUser is the requested local SSH username ("root", "alice", etc). - sshUser string - - // src is the Tailscale IP and port that the connection came from. - src netip.AddrPort - - // dst is the Tailscale IP and port that the connection came for. - dst netip.AddrPort - - // node is srcIP's node. - node tailcfg.NodeView - - // uprof is node's UserProfile. - uprof tailcfg.UserProfile -} - -func (ci *sshConnInfo) String() string { - return fmt.Sprintf("%v->%v@%v", ci.src, ci.sshUser, ci.dst) -} - -func (c *conn) ruleExpired(r *tailcfg.SSHRule) bool { - if r.RuleExpires == nil { - return false - } - return r.RuleExpires.Before(c.srv.now()) -} - -func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) { - for _, r := range pol.Rules { - if a, localUser, acceptEnv, err := c.matchRule(r, pubKey); err == nil { - return a, localUser, acceptEnv, true - } - } - return nil, "", nil, false -} - -// internal errors for testing; they don't escape to callers or logs. -var ( - errNilRule = errors.New("nil rule") - errNilAction = errors.New("nil action") - errRuleExpired = errors.New("rule expired") - errPrincipalMatch = errors.New("principal didn't match") - errUserMatch = errors.New("user didn't match") - errInvalidConn = errors.New("invalid connection state") -) - -func (c *conn) matchRule(r *tailcfg.SSHRule, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) { - defer func() { - c.vlogf("matchRule(%+v): %v", r, err) - }() - - if c == nil { - return nil, "", nil, errInvalidConn - } - if c.info == nil { - c.logf("invalid connection state") - return nil, "", nil, errInvalidConn - } - if r == nil { - return nil, "", nil, errNilRule - } - if r.Action == nil { - return nil, "", nil, errNilAction - } - if c.ruleExpired(r) { - return nil, "", nil, errRuleExpired - } - if !r.Action.Reject { - // For all but Reject rules, SSHUsers is required. - // If SSHUsers is nil or empty, mapLocalUser will return an - // empty string anyway. - localUser = mapLocalUser(r.SSHUsers, c.info.sshUser) - if localUser == "" { - return nil, "", nil, errUserMatch - } - } - if ok, err := c.anyPrincipalMatches(r.Principals, pubKey); err != nil { - return nil, "", nil, err - } else if !ok { - return nil, "", nil, errPrincipalMatch - } - return r.Action, localUser, r.AcceptEnv, nil -} - -func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string) (localUser string) { - v, ok := ruleSSHUsers[reqSSHUser] - if !ok { - v = ruleSSHUsers["*"] - } - if v == "=" { - return reqSSHUser - } - return v -} - -func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal, pubKey gossh.PublicKey) (bool, error) { - for _, p := range ps { - if p == nil { - continue - } - if ok, err := c.principalMatches(p, pubKey); err != nil { - return false, err - } else if ok { - return true, nil - } - } - return false, nil -} - -func (c *conn) principalMatches(p *tailcfg.SSHPrincipal, pubKey gossh.PublicKey) (bool, error) { - if !c.principalMatchesTailscaleIdentity(p) { - return false, nil - } - return c.principalMatchesPubKey(p, pubKey) -} - -// principalMatchesTailscaleIdentity reports whether one of p's four fields -// that match the Tailscale identity match (Node, NodeIP, UserLogin, Any). -// This function does not consider PubKeys. -func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool { - ci := c.info - if p.Any { - return true - } - if !p.Node.IsZero() && ci.node.Valid() && p.Node == ci.node.StableID() { - return true - } - if p.NodeIP != "" { - if ip, _ := netip.ParseAddr(p.NodeIP); ip == ci.src.Addr() { - return true - } - } - if p.UserLogin != "" && ci.uprof.LoginName == p.UserLogin { - return true - } - return false -} - -func (c *conn) principalMatchesPubKey(p *tailcfg.SSHPrincipal, clientPubKey gossh.PublicKey) (bool, error) { - if len(p.PubKeys) == 0 { - return true, nil - } - if clientPubKey == nil { - return false, nil - } - knownKeys := p.PubKeys - if len(knownKeys) == 1 && strings.HasPrefix(knownKeys[0], "https://") { - var err error - knownKeys, err = c.srv.fetchPublicKeysURL(c.expandPublicKeyURL(knownKeys[0])) - if err != nil { - return false, err - } - } - for _, knownKey := range knownKeys { - if pubKeyMatchesAuthorizedKey(clientPubKey, knownKey) { - return true, nil - } - } - return false, nil -} - -func pubKeyMatchesAuthorizedKey(pubKey ssh.PublicKey, wantKey string) bool { - wantKeyType, rest, ok := strings.Cut(wantKey, " ") - if !ok { - return false - } - if pubKey.Type() != wantKeyType { - return false - } - wantKeyB64, _, _ := strings.Cut(rest, " ") - wantKeyData, _ := base64.StdEncoding.DecodeString(wantKeyB64) - return len(wantKeyData) > 0 && bytes.Equal(pubKey.Marshal(), wantKeyData) -} - -func randBytes(n int) []byte { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - panic(err) - } - return b -} - -func (ss *sshSession) openFileForRecording(now time.Time) (_ io.WriteCloser, err error) { - varRoot := ss.conn.srv.lb.TailscaleVarRoot() - if varRoot == "" { - return nil, errors.New("no var root for recording storage") - } - dir := filepath.Join(varRoot, "ssh-sessions") - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err - } - f, err := os.CreateTemp(dir, fmt.Sprintf("ssh-session-%v-*.cast", now.UnixNano())) - if err != nil { - return nil, err - } - return f, nil -} - -// startNewRecording starts a new SSH session recording. -// It may return a nil recording if recording is not available. -func (ss *sshSession) startNewRecording() (_ *recording, err error) { - // We store the node key as soon as possible when creating - // a new recording incase of FUS. - nodeKey := ss.conn.srv.lb.NodeKey() - if nodeKey.IsZero() { - return nil, errors.New("ssh server is unavailable: no node key") - } - - recorders, onFailure := ss.recorders() - var localRecording bool - if len(recorders) == 0 { - if recordSSHToLocalDisk() { - localRecording = true - } else { - return nil, errors.New("no recorders configured") - } - } - - var w ssh.Window - if ptyReq, _, isPtyReq := ss.Pty(); isPtyReq { - w = ptyReq.Window - } - - term := envValFromList(ss.Environ(), "TERM") - if term == "" { - term = "xterm-256color" // something non-empty - } - - now := time.Now() - rec := &recording{ - ss: ss, - start: now, - failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "", - } - - // We want to use a background context for uploading and not ss.ctx. - // ss.ctx is closed when the session closes, but we don't want to break the upload at that time. - // Instead we want to wait for the session to close the writer when it finishes. - ctx := context.Background() - if localRecording { - rec.out, err = ss.openFileForRecording(now) - if err != nil { - return nil, err - } - } else { - var errChan <-chan error - var attempts []*tailcfg.SSHRecordingAttempt - rec.out, attempts, errChan, err = sessionrecording.ConnectToRecorder(ctx, recorders, ss.conn.srv.lb.Dialer().UserDial) - if err != nil { - if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 { - eventType := tailcfg.SSHSessionRecordingFailed - if onFailure.RejectSessionWithMessage != "" { - eventType = tailcfg.SSHSessionRecordingRejected - } - ss.notifyControl(ctx, nodeKey, eventType, attempts, onFailure.NotifyURL) - } - - if onFailure != nil && onFailure.RejectSessionWithMessage != "" { - ss.logf("recording: error starting recording (rejecting session): %v", err) - return nil, userVisibleError{ - error: err, - msg: onFailure.RejectSessionWithMessage, - } - } - ss.logf("recording: error starting recording (failing open): %v", err) - return nil, nil - } - go func() { - err := <-errChan - if err == nil { - select { - case <-ss.ctx.Done(): - // Success. - ss.logf("recording: finished uploading recording") - return - default: - err = errors.New("recording upload ended before the SSH session") - } - } - if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 { - lastAttempt := attempts[len(attempts)-1] - lastAttempt.FailureMessage = err.Error() - - eventType := tailcfg.SSHSessionRecordingFailed - if onFailure.TerminateSessionWithMessage != "" { - eventType = tailcfg.SSHSessionRecordingTerminated - } - - ss.notifyControl(ctx, nodeKey, eventType, attempts, onFailure.NotifyURL) - } - if onFailure != nil && onFailure.TerminateSessionWithMessage != "" { - ss.logf("recording: error uploading recording (closing session): %v", err) - ss.cancelCtx(userVisibleError{ - error: err, - msg: onFailure.TerminateSessionWithMessage, - }) - return - } - ss.logf("recording: error uploading recording (failing open): %v", err) - }() - } - - ch := sessionrecording.CastHeader{ - Version: 2, - Width: w.Width, - Height: w.Height, - Timestamp: now.Unix(), - Command: strings.Join(ss.Command(), " "), - Env: map[string]string{ - "TERM": term, - // TODO(bradfitz): anything else important? - // including all seems noisey, but maybe we should - // for auditing. But first need to break - // launchProcess's startWithStdPipes and - // startWithPTY up so that they first return the cmd - // without starting it, and then a step that starts - // it. Then we can (1) make the cmd, (2) start the - // recording, (3) start the process. - }, - SSHUser: ss.conn.info.sshUser, - LocalUser: ss.conn.localUser.Username, - SrcNode: strings.TrimSuffix(ss.conn.info.node.Name(), "."), - SrcNodeID: ss.conn.info.node.StableID(), - ConnectionID: ss.conn.connID, - } - if !ss.conn.info.node.IsTagged() { - ch.SrcNodeUser = ss.conn.info.uprof.LoginName - ch.SrcNodeUserID = ss.conn.info.node.User() - } else { - ch.SrcNodeTags = ss.conn.info.node.Tags().AsSlice() - } - j, err := json.Marshal(ch) - if err != nil { - return nil, err - } - j = append(j, '\n') - if _, err := rec.out.Write(j); err != nil { - if errors.Is(err, io.ErrClosedPipe) && ss.ctx.Err() != nil { - // If we got an io.ErrClosedPipe, it's likely because - // the recording server closed the connection on us. Return - // the original context error instead. - return nil, context.Cause(ss.ctx) - } - return nil, err - } - return rec, nil -} - -// notifyControl sends a SSHEventNotifyRequest to control over noise. -// A SSHEventNotifyRequest is sent when an action or state reached during -// an SSH session is a defined EventType. -func (ss *sshSession) notifyControl(ctx context.Context, nodeKey key.NodePublic, notifyType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, url string) { - re := tailcfg.SSHEventNotifyRequest{ - EventType: notifyType, - ConnectionID: ss.conn.connID, - CapVersion: tailcfg.CurrentCapabilityVersion, - NodeKey: nodeKey, - SrcNode: ss.conn.info.node.ID(), - SSHUser: ss.conn.info.sshUser, - LocalUser: ss.conn.localUser.Username, - RecordingAttempts: attempts, - } - - body, err := json.Marshal(re) - if err != nil { - ss.logf("notifyControl: unable to marshal SSHNotifyRequest:", err) - return - } - - req, err := http.NewRequestWithContext(ctx, httpm.POST, url, bytes.NewReader(body)) - if err != nil { - ss.logf("notifyControl: unable to create request:", err) - return - } - - resp, err := ss.conn.srv.lb.DoNoiseRequest(req) - if err != nil { - ss.logf("notifyControl: unable to send noise request:", err) - return - } - - if resp.StatusCode != http.StatusCreated { - ss.logf("notifyControl: noise request returned status code %v", resp.StatusCode) - return - } -} - -// recording is the state for an SSH session recording. -type recording struct { - ss *sshSession - start time.Time - - // failOpen specifies whether the session should be allowed to - // continue if writing to the recording fails. - failOpen bool - - mu sync.Mutex // guards writes to, close of out - out io.WriteCloser -} - -func (r *recording) Close() error { - r.mu.Lock() - defer r.mu.Unlock() - if r.out == nil { - return nil - } - err := r.out.Close() - r.out = nil - return err -} - -// writer returns an io.Writer around w that first records the write. -// -// The dir should be "i" for input or "o" for output. -// -// If r is nil, it returns w unchanged. -// -// Currently (2023-03-21) we only record output, not input. -func (r *recording) writer(dir string, w io.Writer) io.Writer { - if r == nil { - return w - } - if dir == "i" { - // TODO: record input? Maybe not, since it might contain - // passwords. - return w - } - return &loggingWriter{r: r, dir: dir, w: w} -} - -// loggingWriter is an io.Writer wrapper that writes first an -// asciinema JSON cast format recording line, and then writes to w. -type loggingWriter struct { - r *recording - dir string // "i" or "o" (input or output) - w io.Writer // underlying Writer, after writing to r.out - - // recordingFailedOpen specifies whether we've failed to write to - // r.out and should stop trying. It is set to true if we fail to write - // to r.out and r.failOpen is set. - recordingFailedOpen bool -} - -func (w *loggingWriter) Write(p []byte) (n int, err error) { - if !w.recordingFailedOpen { - j, err := json.Marshal([]any{ - time.Since(w.r.start).Seconds(), - w.dir, - string(p), - }) - if err != nil { - return 0, err - } - j = append(j, '\n') - if err := w.writeCastLine(j); err != nil { - if !w.r.failOpen { - return 0, err - } - w.recordingFailedOpen = true - } - } - return w.w.Write(p) -} - -func (w loggingWriter) writeCastLine(j []byte) error { - w.r.mu.Lock() - defer w.r.mu.Unlock() - if w.r.out == nil { - return errors.New("logger closed") - } - _, err := w.r.out.Write(j) - if err != nil { - return fmt.Errorf("logger Write: %w", err) - } - return nil -} - -func envValFromList(env []string, wantKey string) (v string) { - for _, kv := range env { - if thisKey, v, ok := strings.Cut(kv, "="); ok && envEq(thisKey, wantKey) { - return v - } - } - return "" -} - -// envEq reports whether environment variable a == b for the current -// operating system. -func envEq(a, b string) bool { - //lint:ignore SA4032 in case this func moves elsewhere, permit the GOOS check - if runtime.GOOS == "windows" { - return strings.EqualFold(a, b) - } - return a == b -} - -var ( - metricActiveSessions = clientmetric.NewGauge("ssh_active_sessions") - metricIncomingConnections = clientmetric.NewCounter("ssh_incoming_connections") - metricPublicKeyAccepts = clientmetric.NewCounter("ssh_publickey_accepts") // accepted subset of ssh_publickey_connections - metricTerminalAccept = clientmetric.NewCounter("ssh_terminalaction_accept") - metricTerminalReject = clientmetric.NewCounter("ssh_terminalaction_reject") - metricTerminalMalformed = clientmetric.NewCounter("ssh_terminalaction_malformed") - metricTerminalFetchError = clientmetric.NewCounter("ssh_terminalaction_fetch_error") - metricHolds = clientmetric.NewCounter("ssh_holds") - metricPolicyChangeKick = clientmetric.NewCounter("ssh_policy_change_kick") - metricSFTP = clientmetric.NewCounter("ssh_sftp_sessions") - metricLocalPortForward = clientmetric.NewCounter("ssh_local_port_forward_requests") - metricRemotePortForward = clientmetric.NewCounter("ssh_remote_port_forward_requests") -) - -// userVisibleError is a wrapper around an error that implements -// SSHTerminationError, so msg is written to their session. -type userVisibleError struct { - msg string - error -} - -func (ue userVisibleError) SSHTerminationMessage() string { return ue.msg } - -// SSHTerminationError is implemented by errors that terminate an SSH -// session and should be written to user's sessions. -type SSHTerminationError interface { - error - SSHTerminationMessage() string -} - -func closeAll(cs ...io.Closer) { - for _, c := range cs { - if c != nil { - c.Close() - } - } -} diff --git a/ssh/tailssh/tailssh_integration_test.go b/ssh/tailssh/tailssh_integration_test.go deleted file mode 100644 index 1799d340019cb..0000000000000 --- a/ssh/tailssh/tailssh_integration_test.go +++ /dev/null @@ -1,680 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build integrationtest -// +build integrationtest - -package tailssh - -import ( - "bufio" - "bytes" - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "errors" - "fmt" - "io" - "log" - "net" - "net/http" - "net/netip" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/bramvdbogaerde/go-scp" - "github.com/google/go-cmp/cmp" - "github.com/pkg/sftp" - gossh "github.com/tailscale/golang-x-crypto/ssh" - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/agent" - "tailscale.com/net/tsdial" - "tailscale.com/tailcfg" - glider "tailscale.com/tempfork/gliderlabs/ssh" - "tailscale.com/types/key" - "tailscale.com/types/netmap" - "tailscale.com/util/set" -) - -// This file contains integration tests of the SSH functionality. These tests -// exercise everything except for the authentication logic. -// -// The tests make the following assumptions about the environment: -// -// - OS is one of MacOS or Linux -// - Test is being run as root (e.g. go test -tags integrationtest -c . && sudo ./tailssh.test -test.run TestIntegration) -// - TAILSCALED_PATH environment variable points at tailscaled binary -// - User "testuser" exists -// - "testuser" is in groups "groupone" and "grouptwo" - -func TestMain(m *testing.M) { - // Create our log file. - file, err := os.OpenFile("/tmp/tailscalessh.log", os.O_CREATE|os.O_WRONLY, 0666) - if err != nil { - log.Fatal(err) - } - file.Close() - - // Tail our log file. - cmd := exec.Command("tail", "-F", "/tmp/tailscalessh.log") - - r, err := cmd.StdoutPipe() - if err != nil { - return - } - - scanner := bufio.NewScanner(r) - go func() { - for scanner.Scan() { - line := scanner.Text() - log.Println(line) - } - }() - - err = cmd.Start() - if err != nil { - return - } - defer func() { - // tail -f has a default sleep interval of 1 second, so it takes a - // moment for it to finish reading our log file after we've terminated. - // So, wait a bit to let it catch up. - time.Sleep(2 * time.Second) - }() - - m.Run() -} - -func TestIntegrationSSH(t *testing.T) { - debugTest.Store(true) - t.Cleanup(func() { - debugTest.Store(false) - }) - - homeDir := "/home/testuser" - if runtime.GOOS == "darwin" { - homeDir = "/Users/testuser" - } - - tests := []struct { - cmd string - want []string - forceV1Behavior bool - skip bool - allowSendEnv bool - }{ - { - cmd: "id", - want: []string{"testuser", "groupone", "grouptwo"}, - forceV1Behavior: false, - }, - { - cmd: "id", - want: []string{"testuser", "groupone", "grouptwo"}, - forceV1Behavior: true, - }, - { - cmd: "pwd", - want: []string{homeDir}, - skip: os.Getenv("SKIP_FILE_OPS") == "1" || !fallbackToSUAvailable(), - forceV1Behavior: false, - }, - { - cmd: "echo 'hello'", - want: []string{"hello"}, - skip: os.Getenv("SKIP_FILE_OPS") == "1" || !fallbackToSUAvailable(), - forceV1Behavior: false, - }, - { - cmd: `echo "${GIT_ENV_VAR:-unset1} ${EXACT_MATCH:-unset2} ${TESTING:-unset3} ${NOT_ALLOWED:-unset4}"`, - want: []string{"working1 working2 working3 unset4"}, - forceV1Behavior: false, - allowSendEnv: true, - }, - { - cmd: `echo "${GIT_ENV_VAR:-unset1} ${EXACT_MATCH:-unset2} ${TESTING:-unset3} ${NOT_ALLOWED:-unset4}"`, - want: []string{"unset1 unset2 unset3 unset4"}, - forceV1Behavior: false, - allowSendEnv: false, - }, - } - - for _, test := range tests { - if test.skip { - continue - } - - // run every test both without and with a shell - for _, shell := range []bool{false, true} { - shellQualifier := "no_shell" - if shell { - shellQualifier = "shell" - } - - versionQualifier := "v2" - if test.forceV1Behavior { - versionQualifier = "v1" - } - - t.Run(fmt.Sprintf("%s_%s_%s", test.cmd, shellQualifier, versionQualifier), func(t *testing.T) { - sendEnv := map[string]string{ - "GIT_ENV_VAR": "working1", - "EXACT_MATCH": "working2", - "TESTING": "working3", - "NOT_ALLOWED": "working4", - } - s := testSession(t, test.forceV1Behavior, test.allowSendEnv, sendEnv) - - if shell { - err := s.RequestPty("xterm", 40, 80, ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - }) - if err != nil { - t.Fatalf("unable to request PTY: %s", err) - } - - err = s.Shell() - if err != nil { - t.Fatalf("unable to request shell: %s", err) - } - - // Read the shell prompt - s.read() - } - - got := s.run(t, test.cmd, shell) - for _, want := range test.want { - if !strings.Contains(got, want) { - t.Errorf("%q does not contain %q", got, want) - } - } - }) - } - } -} - -func TestIntegrationSFTP(t *testing.T) { - debugTest.Store(true) - t.Cleanup(func() { - debugTest.Store(false) - }) - - for _, forceV1Behavior := range []bool{false, true} { - name := "v2" - if forceV1Behavior { - name = "v1" - } - t.Run(name, func(t *testing.T) { - filePath := "/home/testuser/sftptest.dat" - if forceV1Behavior || !fallbackToSUAvailable() { - filePath = "/tmp/sftptest.dat" - } - wantText := "hello world" - - cl := testClient(t, forceV1Behavior, false) - scl, err := sftp.NewClient(cl) - if err != nil { - t.Fatalf("can't get sftp client: %s", err) - } - - file, err := scl.Create(filePath) - if err != nil { - t.Fatalf("can't create file: %s", err) - } - _, err = file.Write([]byte(wantText)) - if err != nil { - t.Fatalf("can't write to file: %s", err) - } - err = file.Close() - if err != nil { - t.Fatalf("can't close file: %s", err) - } - - file, err = scl.OpenFile(filePath, os.O_RDONLY) - if err != nil { - t.Fatalf("can't open file: %s", err) - } - defer file.Close() - gotText, err := io.ReadAll(file) - if err != nil { - t.Fatalf("can't read file: %s", err) - } - if diff := cmp.Diff(string(gotText), wantText); diff != "" { - t.Fatalf("unexpected file contents (-got +want):\n%s", diff) - } - - s := testSessionFor(t, cl, nil) - got := s.run(t, "ls -l "+filePath, false) - if !strings.Contains(got, "testuser") { - t.Fatalf("unexpected file owner user: %s", got) - } else if !strings.Contains(got, "testuser") { - t.Fatalf("unexpected file owner group: %s", got) - } - }) - } -} - -func TestIntegrationSCP(t *testing.T) { - debugTest.Store(true) - t.Cleanup(func() { - debugTest.Store(false) - }) - - for _, forceV1Behavior := range []bool{false, true} { - name := "v2" - if forceV1Behavior { - name = "v1" - } - t.Run(name, func(t *testing.T) { - filePath := "/home/testuser/scptest.dat" - if !fallbackToSUAvailable() { - filePath = "/tmp/scptest.dat" - } - wantText := "hello world" - - cl := testClient(t, forceV1Behavior, false) - scl, err := scp.NewClientBySSH(cl) - if err != nil { - t.Fatalf("can't get sftp client: %s", err) - } - - err = scl.Copy(context.Background(), strings.NewReader(wantText), filePath, "0644", int64(len(wantText))) - if err != nil { - t.Fatalf("can't create file: %s", err) - } - - outfile, err := os.CreateTemp("", "") - if err != nil { - t.Fatalf("can't create temp file: %s", err) - } - err = scl.CopyFromRemote(context.Background(), outfile, filePath) - if err != nil { - t.Fatalf("can't copy file from remote: %s", err) - } - outfile.Close() - - gotText, err := os.ReadFile(outfile.Name()) - if err != nil { - t.Fatalf("can't read file: %s", err) - } - if diff := cmp.Diff(string(gotText), wantText); diff != "" { - t.Fatalf("unexpected file contents (-got +want):\n%s", diff) - } - - s := testSessionFor(t, cl, nil) - got := s.run(t, "ls -l "+filePath, false) - if !strings.Contains(got, "testuser") { - t.Fatalf("unexpected file owner user: %s", got) - } else if !strings.Contains(got, "testuser") { - t.Fatalf("unexpected file owner group: %s", got) - } - }) - } -} - -func TestSSHAgentForwarding(t *testing.T) { - debugTest.Store(true) - t.Cleanup(func() { - debugTest.Store(false) - }) - - // Create a client SSH key - tmpDir, err := os.MkdirTemp("", "") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = os.RemoveAll(tmpDir) - }) - pkFile := filepath.Join(tmpDir, "pk") - clientKey, clientKeyRSA := generateClientKey(t, pkFile) - - // Start upstream SSH server - l, err := net.Listen("tcp", "127.0.0.1:") - if err != nil { - t.Fatalf("unable to listen for SSH: %s", err) - } - t.Cleanup(func() { - _ = l.Close() - }) - - // Run an SSH server that accepts connections from that client SSH key. - gs := glider.Server{ - Handler: func(s glider.Session) { - io.WriteString(s, "Hello world\n") - }, - PublicKeyHandler: func(ctx glider.Context, key glider.PublicKey) error { - // Note - this is not meant to be cryptographically secure, it's - // just checking that SSH agent forwarding is forwarding the right - // key. - a := key.Marshal() - b := clientKey.PublicKey().Marshal() - if !bytes.Equal(a, b) { - return errors.New("key mismatch") - } - return nil - }, - } - go gs.Serve(l) - - // Run tailscale SSH server and connect to it - username := "testuser" - tailscaleAddr := testServer(t, username, false, false) - tcl, err := ssh.Dial("tcp", tailscaleAddr, &ssh.ClientConfig{ - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { tcl.Close() }) - - s, err := tcl.NewSession() - if err != nil { - t.Fatal(err) - } - - // Set up SSH agent forwarding on the client - err = agent.RequestAgentForwarding(s) - if err != nil { - t.Fatal(err) - } - - keyring := agent.NewKeyring() - keyring.Add(agent.AddedKey{ - PrivateKey: clientKeyRSA, - }) - err = agent.ForwardToAgent(tcl, keyring) - if err != nil { - t.Fatal(err) - } - - // Attempt to SSH to the upstream test server using the forwarded SSH key - // and run the "true" command. - upstreamHost, upstreamPort, err := net.SplitHostPort(l.Addr().String()) - if err != nil { - t.Fatal(err) - } - - o, err := s.CombinedOutput(fmt.Sprintf(`ssh -T -o StrictHostKeyChecking=no -p %s upstreamuser@%s "true"`, upstreamPort, upstreamHost)) - if err != nil { - t.Fatalf("unable to call true command: %s\n%s\n-------------------------", err, o) - } -} - -func fallbackToSUAvailable() bool { - if runtime.GOOS != "linux" { - return false - } - - _, err := exec.LookPath("su") - if err != nil { - return false - } - - // Some operating systems like Fedora seem to require login to be present - // in order for su to work. - _, err = exec.LookPath("login") - return err == nil -} - -type session struct { - *ssh.Session - - stdin io.WriteCloser - stdout io.ReadCloser - stderr io.ReadCloser -} - -func (s *session) run(t *testing.T, cmdString string, shell bool) string { - t.Helper() - - if shell { - _, err := s.stdin.Write([]byte(fmt.Sprintf("%s\n", cmdString))) - if err != nil { - t.Fatalf("unable to send command to shell: %s", err) - } - } else { - err := s.Start(cmdString) - if err != nil { - t.Fatalf("unable to start command: %s", err) - } - } - - return s.read() -} - -func (s *session) read() string { - ch := make(chan []byte) - go func() { - for { - b := make([]byte, 1) - n, err := s.stdout.Read(b) - if n > 0 { - ch <- b - } - if err == io.EOF { - return - } - } - }() - - // Read first byte in blocking fashion. - _got := <-ch - - // Read subsequent bytes in non-blocking fashion. -readLoop: - for { - select { - case b := <-ch: - _got = append(_got, b...) - case <-time.After(1 * time.Second): - break readLoop - } - } - - return string(_got) -} - -func testClient(t *testing.T, forceV1Behavior bool, allowSendEnv bool, authMethods ...ssh.AuthMethod) *ssh.Client { - t.Helper() - - username := "testuser" - addr := testServer(t, username, forceV1Behavior, allowSendEnv) - - cl, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{ - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Auth: authMethods, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { cl.Close() }) - - return cl -} - -func testServer(t *testing.T, username string, forceV1Behavior bool, allowSendEnv bool) string { - srv := &server{ - lb: &testBackend{localUser: username, forceV1Behavior: forceV1Behavior, allowSendEnv: allowSendEnv}, - logf: log.Printf, - tailscaledPath: os.Getenv("TAILSCALED_PATH"), - timeNow: time.Now, - } - - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { l.Close() }) - - go func() { - for { - conn, err := l.Accept() - if err == nil { - go srv.HandleSSHConn(&addressFakingConn{conn}) - } - } - }() - - return l.Addr().String() -} - -func testSession(t *testing.T, forceV1Behavior bool, allowSendEnv bool, sendEnv map[string]string) *session { - cl := testClient(t, forceV1Behavior, allowSendEnv) - return testSessionFor(t, cl, sendEnv) -} - -func testSessionFor(t *testing.T, cl *ssh.Client, sendEnv map[string]string) *session { - s, err := cl.NewSession() - if err != nil { - t.Fatal(err) - } - for k, v := range sendEnv { - s.Setenv(k, v) - } - - t.Cleanup(func() { s.Close() }) - - stdinReader, stdinWriter := io.Pipe() - stdoutReader, stdoutWriter := io.Pipe() - stderrReader, stderrWriter := io.Pipe() - s.Stdin = stdinReader - s.Stdout = io.MultiWriter(stdoutWriter, os.Stdout) - s.Stderr = io.MultiWriter(stderrWriter, os.Stderr) - return &session{ - Session: s, - stdin: stdinWriter, - stdout: stdoutReader, - stderr: stderrReader, - } -} - -func generateClientKey(t *testing.T, privateKeyFile string) (ssh.Signer, *rsa.PrivateKey) { - t.Helper() - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - mk, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - t.Fatal(err) - } - privateKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) - if privateKey == nil { - t.Fatal("failed to encoded private key") - } - err = os.WriteFile(privateKeyFile, privateKey, 0600) - if err != nil { - t.Fatal(err) - } - signer, err := ssh.ParsePrivateKey(privateKey) - if err != nil { - t.Fatal(err) - } - return signer, priv -} - -// testBackend implements ipnLocalBackend -type testBackend struct { - localUser string - forceV1Behavior bool - allowSendEnv bool -} - -func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) { - var result []gossh.Signer - var priv any - var err error - const keySize = 2048 - priv, err = rsa.GenerateKey(rand.Reader, keySize) - if err != nil { - return nil, err - } - mk, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - return nil, err - } - hostKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) - signer, err := gossh.ParsePrivateKey(hostKey) - if err != nil { - return nil, err - } - result = append(result, signer) - return result, nil -} - -func (tb *testBackend) ShouldRunSSH() bool { - return true -} - -func (tb *testBackend) NetMap() *netmap.NetworkMap { - capMap := make(set.Set[tailcfg.NodeCapability]) - if tb.forceV1Behavior { - capMap[tailcfg.NodeAttrSSHBehaviorV1] = struct{}{} - } - if tb.allowSendEnv { - capMap[tailcfg.NodeAttrSSHEnvironmentVariables] = struct{}{} - } - return &netmap.NetworkMap{ - SSHPolicy: &tailcfg.SSHPolicy{ - Rules: []*tailcfg.SSHRule{ - { - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true}, - SSHUsers: map[string]string{"*": tb.localUser}, - AcceptEnv: []string{"GIT_*", "EXACT_MATCH", "TEST?NG"}, - }, - }, - }, - AllCaps: capMap, - } -} - -func (tb *testBackend) WhoIs(_ string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { - return (&tailcfg.Node{}).View(), tailcfg.UserProfile{ - LoginName: tb.localUser + "@example.com", - }, true -} - -func (tb *testBackend) DoNoiseRequest(req *http.Request) (*http.Response, error) { - return nil, nil -} - -func (tb *testBackend) Dialer() *tsdial.Dialer { - return nil -} - -func (tb *testBackend) TailscaleVarRoot() string { - return "" -} - -func (tb *testBackend) NodeKey() key.NodePublic { - return key.NodePublic{} -} - -type addressFakingConn struct { - net.Conn -} - -func (conn *addressFakingConn) LocalAddr() net.Addr { - return &net.TCPAddr{ - IP: net.ParseIP("100.100.100.101"), - Port: 22, - } -} - -func (conn *addressFakingConn) RemoteAddr() net.Addr { - return &net.TCPAddr{ - IP: net.ParseIP("100.100.100.102"), - Port: 10002, - } -} diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go deleted file mode 100644 index ad9cb1e57b53d..0000000000000 --- a/ssh/tailssh/tailssh_test.go +++ /dev/null @@ -1,1318 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || darwin - -package tailssh - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "os" - "os/exec" - "os/user" - "reflect" - "runtime" - "slices" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - gossh "github.com/tailscale/golang-x-crypto/ssh" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/store/mem" - "tailscale.com/net/memnet" - "tailscale.com/net/tsdial" - "tailscale.com/sessionrecording" - "tailscale.com/tailcfg" - "tailscale.com/tempfork/gliderlabs/ssh" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/netmap" - "tailscale.com/types/ptr" - "tailscale.com/util/cibuild" - "tailscale.com/util/lineiter" - "tailscale.com/util/must" - "tailscale.com/version/distro" - "tailscale.com/wgengine" -) - -func TestMatchRule(t *testing.T) { - someAction := new(tailcfg.SSHAction) - tests := []struct { - name string - rule *tailcfg.SSHRule - ci *sshConnInfo - wantErr error - wantUser string - wantAcceptEnv []string - }{ - { - name: "invalid-conn", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "*": "ubuntu", - }, - }, - wantErr: errInvalidConn, - }, - { - name: "nil-rule", - ci: &sshConnInfo{}, - rule: nil, - wantErr: errNilRule, - }, - { - name: "nil-action", - ci: &sshConnInfo{}, - rule: &tailcfg.SSHRule{}, - wantErr: errNilAction, - }, - { - name: "expired", - rule: &tailcfg.SSHRule{ - Action: someAction, - RuleExpires: ptr.To(time.Unix(100, 0)), - }, - ci: &sshConnInfo{}, - wantErr: errRuleExpired, - }, - { - name: "no-principal", - rule: &tailcfg.SSHRule{ - Action: someAction, - SSHUsers: map[string]string{ - "*": "ubuntu", - }}, - ci: &sshConnInfo{}, - wantErr: errPrincipalMatch, - }, - { - name: "no-user-match", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantErr: errUserMatch, - }, - { - name: "ok-wildcard", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "*": "ubuntu", - }, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantUser: "ubuntu", - }, - { - name: "ok-wildcard-and-nil-principal", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{ - nil, // don't crash on this - {Any: true}, - }, - SSHUsers: map[string]string{ - "*": "ubuntu", - }, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantUser: "ubuntu", - }, - { - name: "ok-exact", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "*": "ubuntu", - "alice": "thealice", - }, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantUser: "thealice", - }, - { - name: "ok-with-accept-env", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "*": "ubuntu", - "alice": "thealice", - }, - AcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"}, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantUser: "thealice", - wantAcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"}, - }, - { - name: "no-users-for-reject", - rule: &tailcfg.SSHRule{ - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - Action: &tailcfg.SSHAction{Reject: true}, - }, - ci: &sshConnInfo{sshUser: "alice"}, - }, - { - name: "match-principal-node-ip", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{NodeIP: "1.2.3.4"}}, - SSHUsers: map[string]string{"*": "ubuntu"}, - }, - ci: &sshConnInfo{src: netip.MustParseAddrPort("1.2.3.4:30343")}, - wantUser: "ubuntu", - }, - { - name: "match-principal-node-id", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Node: "some-node-ID"}}, - SSHUsers: map[string]string{"*": "ubuntu"}, - }, - ci: &sshConnInfo{node: (&tailcfg.Node{StableID: "some-node-ID"}).View()}, - wantUser: "ubuntu", - }, - { - name: "match-principal-userlogin", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{UserLogin: "foo@bar.com"}}, - SSHUsers: map[string]string{"*": "ubuntu"}, - }, - ci: &sshConnInfo{uprof: tailcfg.UserProfile{LoginName: "foo@bar.com"}}, - wantUser: "ubuntu", - }, - { - name: "ssh-user-equal", - rule: &tailcfg.SSHRule{ - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "*": "=", - }, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantUser: "alice", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &conn{ - info: tt.ci, - srv: &server{logf: t.Logf}, - } - got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule, nil) - if err != tt.wantErr { - t.Errorf("err = %v; want %v", err, tt.wantErr) - } - if gotUser != tt.wantUser { - t.Errorf("user = %q; want %q", gotUser, tt.wantUser) - } - if err == nil && got == nil { - t.Errorf("expected non-nil action on success") - } - if !slices.Equal(gotAcceptEnv, tt.wantAcceptEnv) { - t.Errorf("acceptEnv = %v; want %v", gotAcceptEnv, tt.wantAcceptEnv) - } - }) - } -} - -func TestEvalSSHPolicy(t *testing.T) { - someAction := new(tailcfg.SSHAction) - tests := []struct { - name string - policy *tailcfg.SSHPolicy - ci *sshConnInfo - wantMatch bool - wantUser string - wantAcceptEnv []string - }{ - { - name: "multiple-matches-picks-first-match", - policy: &tailcfg.SSHPolicy{ - Rules: []*tailcfg.SSHRule{ - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "other": "other1", - }, - }, - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "*": "ubuntu", - "alice": "thealice", - }, - AcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"}, - }, - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "other2": "other3", - }, - }, - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "*": "ubuntu", - "alice": "thealice", - "mark": "markthe", - }, - AcceptEnv: []string{"*"}, - }, - }, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantUser: "thealice", - wantAcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"}, - wantMatch: true, - }, - { - name: "no-matches-returns-failure", - policy: &tailcfg.SSHPolicy{ - Rules: []*tailcfg.SSHRule{ - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "other": "other1", - }, - }, - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "fedora": "ubuntu", - }, - AcceptEnv: []string{"EXAMPLE", "?_?", "TEST_*"}, - }, - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "other2": "other3", - }, - }, - { - Action: someAction, - Principals: []*tailcfg.SSHPrincipal{{Any: true}}, - SSHUsers: map[string]string{ - "mark": "markthe", - }, - AcceptEnv: []string{"*"}, - }, - }, - }, - ci: &sshConnInfo{sshUser: "alice"}, - wantUser: "", - wantAcceptEnv: nil, - wantMatch: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &conn{ - info: tt.ci, - srv: &server{logf: t.Logf}, - } - got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy, nil) - if match != tt.wantMatch { - t.Errorf("match = %v; want %v", match, tt.wantMatch) - } - if gotUser != tt.wantUser { - t.Errorf("user = %q; want %q", gotUser, tt.wantUser) - } - if tt.wantMatch == true && got == nil { - t.Errorf("expected non-nil action on success") - } - if !slices.Equal(gotAcceptEnv, tt.wantAcceptEnv) { - t.Errorf("acceptEnv = %v; want %v", gotAcceptEnv, tt.wantAcceptEnv) - } - }) - } -} - -// localState implements ipnLocalBackend for testing. -type localState struct { - sshEnabled bool - matchingRule *tailcfg.SSHRule - - // serverActions is a map of the action name to the action. - // It is served for paths like https://unused/ssh-action/. - // The action name is the last part of the action URL. - serverActions map[string]*tailcfg.SSHAction -} - -var ( - currentUser = os.Getenv("USER") // Use the current user for the test. - testSigner gossh.Signer - testSignerOnce sync.Once -) - -func (ts *localState) Dialer() *tsdial.Dialer { - return &tsdial.Dialer{} -} - -func (ts *localState) GetSSH_HostKeys() ([]gossh.Signer, error) { - testSignerOnce.Do(func() { - _, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - panic(err) - } - s, err := gossh.NewSignerFromSigner(priv) - if err != nil { - panic(err) - } - testSigner = s - }) - return []gossh.Signer{testSigner}, nil -} - -func (ts *localState) ShouldRunSSH() bool { - return ts.sshEnabled -} - -func (ts *localState) NetMap() *netmap.NetworkMap { - var policy *tailcfg.SSHPolicy - if ts.matchingRule != nil { - policy = &tailcfg.SSHPolicy{ - Rules: []*tailcfg.SSHRule{ - ts.matchingRule, - }, - } - } - - return &netmap.NetworkMap{ - SelfNode: (&tailcfg.Node{ - ID: 1, - }).View(), - SSHPolicy: policy, - } -} - -func (ts *localState) WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) { - if proto != "tcp" { - return tailcfg.NodeView{}, tailcfg.UserProfile{}, false - } - - return (&tailcfg.Node{ - ID: 2, - StableID: "peer-id", - }).View(), tailcfg.UserProfile{ - LoginName: "peer", - }, true - -} - -func (ts *localState) DoNoiseRequest(req *http.Request) (*http.Response, error) { - rec := httptest.NewRecorder() - k, ok := strings.CutPrefix(req.URL.Path, "/ssh-action/") - if !ok { - rec.WriteHeader(http.StatusNotFound) - } - a, ok := ts.serverActions[k] - if !ok { - rec.WriteHeader(http.StatusNotFound) - return rec.Result(), nil - } - rec.WriteHeader(http.StatusOK) - if err := json.NewEncoder(rec).Encode(a); err != nil { - return nil, err - } - return rec.Result(), nil -} - -func (ts *localState) TailscaleVarRoot() string { - return "" -} - -func (ts *localState) NodeKey() key.NodePublic { - return key.NewNode().Public() -} - -func newSSHRule(action *tailcfg.SSHAction) *tailcfg.SSHRule { - return &tailcfg.SSHRule{ - SSHUsers: map[string]string{ - "*": currentUser, - }, - Action: action, - Principals: []*tailcfg.SSHPrincipal{ - { - Any: true, - }, - }, - } -} - -func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) { - if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS) - } - - var handler http.HandlerFunc - recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) { - handler(w, r) - }) - - s := &server{ - logf: t.Logf, - lb: &localState{ - sshEnabled: true, - matchingRule: newSSHRule( - &tailcfg.SSHAction{ - Accept: true, - Recorders: []netip.AddrPort{ - netip.MustParseAddrPort(recordingServer.Listener.Addr().String()), - }, - OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{ - RejectSessionWithMessage: "session rejected", - TerminateSessionWithMessage: "session terminated", - }, - }, - ), - }, - } - defer s.Shutdown() - - const sshUser = "alice" - cfg := &gossh.ClientConfig{ - User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - } - - tests := []struct { - name string - handler func(w http.ResponseWriter, r *http.Request) - sshCommand string - wantClientOutput string - - clientOutputMustNotContain []string - }{ - { - name: "upload-denied", - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - }, - sshCommand: "echo hello", - wantClientOutput: "session rejected\r\n", - - clientOutputMustNotContain: []string{"hello"}, - }, - { - name: "upload-fails-after-starting", - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - r.Body.Read(make([]byte, 1)) - time.Sleep(100 * time.Millisecond) - }, - sshCommand: "echo hello && sleep 1 && echo world", - wantClientOutput: "\r\n\r\nsession terminated\r\n\r\n", - - clientOutputMustNotContain: []string{"world"}, - }, - } - - src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22")) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s.logf = t.Logf - tstest.Replace(t, &handler, tt.handler) - sc, dc := memnet.NewTCPConn(src, dst, 1024) - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) - if err != nil { - t.Errorf("client: %v", err) - return - } - client := gossh.NewClient(c, chans, reqs) - defer client.Close() - session, err := client.NewSession() - if err != nil { - t.Errorf("client: %v", err) - return - } - defer session.Close() - t.Logf("client established session") - got, err := session.CombinedOutput(tt.sshCommand) - if err != nil { - t.Logf("client got: %q: %v", got, err) - } else { - t.Errorf("client did not get kicked out: %q", got) - } - gotStr := string(got) - if !strings.HasSuffix(gotStr, tt.wantClientOutput) { - t.Errorf("client got %q, want %q", got, tt.wantClientOutput) - } - for _, x := range tt.clientOutputMustNotContain { - if strings.Contains(gotStr, x) { - t.Errorf("client output must not contain %q", x) - } - } - }() - if err := s.HandleSSHConn(dc); err != nil { - t.Errorf("unexpected error: %v", err) - } - wg.Wait() - }) - } -} - -func TestMultipleRecorders(t *testing.T) { - if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS) - } - done := make(chan struct{}) - recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) { - defer close(done) - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - io.ReadAll(r.Body) - }) - badRecorder, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatal(err) - } - badRecorderAddr := badRecorder.Addr().String() - badRecorder.Close() - - badRecordingServer500 := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - - s := &server{ - logf: t.Logf, - lb: &localState{ - sshEnabled: true, - matchingRule: newSSHRule( - &tailcfg.SSHAction{ - Accept: true, - Recorders: []netip.AddrPort{ - netip.MustParseAddrPort(badRecorderAddr), - netip.MustParseAddrPort(badRecordingServer500.Listener.Addr().String()), - netip.MustParseAddrPort(recordingServer.Listener.Addr().String()), - }, - OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{ - RejectSessionWithMessage: "session rejected", - TerminateSessionWithMessage: "session terminated", - }, - }, - ), - }, - } - defer s.Shutdown() - - src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22")) - sc, dc := memnet.NewTCPConn(src, dst, 1024) - - const sshUser = "alice" - cfg := &gossh.ClientConfig{ - User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - } - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) - if err != nil { - t.Errorf("client: %v", err) - return - } - client := gossh.NewClient(c, chans, reqs) - defer client.Close() - session, err := client.NewSession() - if err != nil { - t.Errorf("client: %v", err) - return - } - defer session.Close() - t.Logf("client established session") - out, err := session.CombinedOutput("echo Ran echo!") - if err != nil { - t.Errorf("client: %v", err) - } - if string(out) != "Ran echo!\n" { - t.Errorf("client: unexpected output: %q", out) - } - }() - if err := s.HandleSSHConn(dc); err != nil { - t.Errorf("unexpected error: %v", err) - } - wg.Wait() - select { - case <-done: - case <-time.After(1 * time.Second): - t.Fatal("timed out waiting for recording") - } -} - -// TestSSHRecordingNonInteractive tests that the SSH server records the SSH session -// when the client is not interactive (i.e. no PTY). -// It starts a local SSH server and a recording server. The recording server -// records the SSH session and returns it to the test. -// The test then verifies that the recording has a valid CastHeader, it does not -// validate the contents of the recording. -func TestSSHRecordingNonInteractive(t *testing.T) { - if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS) - } - var recording []byte - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - recordingServer := mockRecordingServer(t, func(w http.ResponseWriter, r *http.Request) { - defer cancel() - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - - var err error - recording, err = io.ReadAll(r.Body) - if err != nil { - t.Error(err) - return - } - }) - - s := &server{ - logf: t.Logf, - lb: &localState{ - sshEnabled: true, - matchingRule: newSSHRule( - &tailcfg.SSHAction{ - Accept: true, - Recorders: []netip.AddrPort{ - must.Get(netip.ParseAddrPort(recordingServer.Listener.Addr().String())), - }, - OnRecordingFailure: &tailcfg.SSHRecorderFailureAction{ - RejectSessionWithMessage: "session rejected", - TerminateSessionWithMessage: "session terminated", - }, - }, - ), - }, - } - defer s.Shutdown() - - src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22")) - sc, dc := memnet.NewTCPConn(src, dst, 1024) - - const sshUser = "alice" - cfg := &gossh.ClientConfig{ - User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - } - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) - if err != nil { - t.Errorf("client: %v", err) - return - } - client := gossh.NewClient(c, chans, reqs) - defer client.Close() - session, err := client.NewSession() - if err != nil { - t.Errorf("client: %v", err) - return - } - defer session.Close() - t.Logf("client established session") - _, err = session.CombinedOutput("echo Ran echo!") - if err != nil { - t.Errorf("client: %v", err) - } - }() - if err := s.HandleSSHConn(dc); err != nil { - t.Errorf("unexpected error: %v", err) - } - wg.Wait() - - <-ctx.Done() // wait for recording to finish - var ch sessionrecording.CastHeader - if err := json.NewDecoder(bytes.NewReader(recording)).Decode(&ch); err != nil { - t.Fatal(err) - } - if ch.SSHUser != sshUser { - t.Errorf("SSHUser = %q; want %q", ch.SSHUser, sshUser) - } - if ch.Command != "echo Ran echo!" { - t.Errorf("Command = %q; want %q", ch.Command, "echo Ran echo!") - } -} - -func TestSSHAuthFlow(t *testing.T) { - if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS) - } - acceptRule := newSSHRule(&tailcfg.SSHAction{ - Accept: true, - Message: "Welcome to Tailscale SSH!", - }) - rejectRule := newSSHRule(&tailcfg.SSHAction{ - Reject: true, - Message: "Go Away!", - }) - - tests := []struct { - name string - sshUser string // defaults to alice - state *localState - wantBanners []string - usesPassword bool - authErr bool - }{ - { - name: "no-policy", - state: &localState{ - sshEnabled: true, - }, - authErr: true, - }, - { - name: "accept", - state: &localState{ - sshEnabled: true, - matchingRule: acceptRule, - }, - wantBanners: []string{"Welcome to Tailscale SSH!"}, - }, - { - name: "reject", - state: &localState{ - sshEnabled: true, - matchingRule: rejectRule, - }, - wantBanners: []string{"Go Away!"}, - authErr: true, - }, - { - name: "simple-check", - state: &localState{ - sshEnabled: true, - matchingRule: newSSHRule(&tailcfg.SSHAction{ - HoldAndDelegate: "https://unused/ssh-action/accept", - }), - serverActions: map[string]*tailcfg.SSHAction{ - "accept": acceptRule.Action, - }, - }, - wantBanners: []string{"Welcome to Tailscale SSH!"}, - }, - { - name: "multi-check", - state: &localState{ - sshEnabled: true, - matchingRule: newSSHRule(&tailcfg.SSHAction{ - Message: "First", - HoldAndDelegate: "https://unused/ssh-action/check1", - }), - serverActions: map[string]*tailcfg.SSHAction{ - "check1": { - Message: "url-here", - HoldAndDelegate: "https://unused/ssh-action/check2", - }, - "check2": acceptRule.Action, - }, - }, - wantBanners: []string{"First", "url-here", "Welcome to Tailscale SSH!"}, - }, - { - name: "check-reject", - state: &localState{ - sshEnabled: true, - matchingRule: newSSHRule(&tailcfg.SSHAction{ - Message: "First", - HoldAndDelegate: "https://unused/ssh-action/reject", - }), - serverActions: map[string]*tailcfg.SSHAction{ - "reject": rejectRule.Action, - }, - }, - wantBanners: []string{"First", "Go Away!"}, - authErr: true, - }, - { - name: "force-password-auth", - sshUser: "alice+password", - state: &localState{ - sshEnabled: true, - matchingRule: acceptRule, - }, - usesPassword: true, - wantBanners: []string{"Welcome to Tailscale SSH!"}, - }, - } - s := &server{ - logf: logger.Discard, - } - defer s.Shutdown() - src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22")) - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - sc, dc := memnet.NewTCPConn(src, dst, 1024) - s.lb = tc.state - sshUser := "alice" - if tc.sshUser != "" { - sshUser = tc.sshUser - } - var passwordUsed atomic.Bool - cfg := &gossh.ClientConfig{ - User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - Auth: []gossh.AuthMethod{ - gossh.PasswordCallback(func() (secret string, err error) { - if !tc.usesPassword { - t.Error("unexpected use of PasswordCallback") - return "", errors.New("unexpected use of PasswordCallback") - } - passwordUsed.Store(true) - return "any-pass", nil - }), - }, - BannerCallback: func(message string) error { - if len(tc.wantBanners) == 0 { - t.Errorf("unexpected banner: %q", message) - } else if message != tc.wantBanners[0] { - t.Errorf("banner = %q; want %q", message, tc.wantBanners[0]) - } else { - t.Logf("banner = %q", message) - tc.wantBanners = tc.wantBanners[1:] - } - return nil - }, - } - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) - if err != nil { - if !tc.authErr { - t.Errorf("client: %v", err) - } - return - } else if tc.authErr { - c.Close() - t.Errorf("client: expected error, got nil") - return - } - client := gossh.NewClient(c, chans, reqs) - defer client.Close() - session, err := client.NewSession() - if err != nil { - t.Errorf("client: %v", err) - return - } - defer session.Close() - _, err = session.CombinedOutput("echo Ran echo!") - if err != nil { - t.Errorf("client: %v", err) - } - }() - if err := s.HandleSSHConn(dc); err != nil { - t.Errorf("unexpected error: %v", err) - } - wg.Wait() - if len(tc.wantBanners) > 0 { - t.Errorf("missing banners: %v", tc.wantBanners) - } - }) - } -} - -func TestSSH(t *testing.T) { - var logf logger.Logf = t.Logf - sys := &tsd.System{} - eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker(), sys.UserMetricsRegistry()) - if err != nil { - t.Fatal(err) - } - sys.Set(eng) - sys.Set(new(mem.Store)) - lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatal(err) - } - defer lb.Shutdown() - dir := t.TempDir() - lb.SetVarRoot(dir) - - srv := &server{ - lb: lb, - logf: logf, - } - sc, err := srv.newConn() - if err != nil { - t.Fatal(err) - } - // Remove the auth checks for the test - sc.insecureSkipTailscaleAuth = true - - u, err := user.Current() - if err != nil { - t.Fatal(err) - } - um, err := userLookup(u.Username) - if err != nil { - t.Fatal(err) - } - sc.localUser = um - sc.info = &sshConnInfo{ - sshUser: "test", - src: netip.MustParseAddrPort("1.2.3.4:32342"), - dst: netip.MustParseAddrPort("1.2.3.5:22"), - node: (&tailcfg.Node{}).View(), - uprof: tailcfg.UserProfile{}, - } - sc.action0 = &tailcfg.SSHAction{Accept: true} - sc.finalAction = sc.action0 - - sc.Handler = func(s ssh.Session) { - sc.newSSHSession(s).run() - } - - ln, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - port := ln.Addr().(*net.TCPAddr).Port - - go func() { - for { - c, err := ln.Accept() - if err != nil { - if !errors.Is(err, net.ErrClosed) { - t.Errorf("Accept: %v", err) - } - return - } - go sc.HandleConn(c) - } - }() - - execSSH := func(args ...string) *exec.Cmd { - cmd := exec.Command("ssh", - "-F", - "none", - "-v", - "-p", fmt.Sprint(port), - "-o", "StrictHostKeyChecking=no", - "user@127.0.0.1") - cmd.Args = append(cmd.Args, args...) - return cmd - } - - t.Run("env", func(t *testing.T) { - if cibuild.On() { - t.Skip("Skipping for now; see https://github.com/tailscale/tailscale/issues/4051") - } - cmd := execSSH("LANG=foo env") - cmd.Env = append(os.Environ(), "LOCAL_ENV=bar") - got, err := cmd.CombinedOutput() - if err != nil { - t.Fatal(err, string(got)) - } - m := parseEnv(got) - if got := m["USER"]; got == "" || got != u.Username { - t.Errorf("USER = %q; want %q", got, u.Username) - } - if got := m["HOME"]; got == "" || got != u.HomeDir { - t.Errorf("HOME = %q; want %q", got, u.HomeDir) - } - if got := m["PWD"]; got == "" || got != u.HomeDir { - t.Errorf("PWD = %q; want %q", got, u.HomeDir) - } - if got := m["SHELL"]; got == "" { - t.Errorf("no SHELL") - } - if got, want := m["LANG"], "foo"; got != want { - t.Errorf("LANG = %q; want %q", got, want) - } - if got := m["LOCAL_ENV"]; got != "" { - t.Errorf("LOCAL_ENV leaked over ssh: %v", got) - } - t.Logf("got: %+v", m) - }) - - t.Run("stdout_stderr", func(t *testing.T) { - cmd := execSSH("sh", "-c", "echo foo; echo bar >&2") - var outBuf, errBuf bytes.Buffer - cmd.Stdout = &outBuf - cmd.Stderr = &errBuf - if err := cmd.Run(); err != nil { - t.Fatal(err) - } - t.Logf("Got: %q and %q", outBuf.Bytes(), errBuf.Bytes()) - // TODO: figure out why these aren't right. should be - // "foo\n" and "bar\n", not "\n" and "bar\n". - }) - - t.Run("large_file", func(t *testing.T) { - const wantSize = 1e6 - var outBuf bytes.Buffer - cmd := execSSH("head", "-c", strconv.Itoa(wantSize), "/dev/zero") - cmd.Stdout = &outBuf - if err := cmd.Run(); err != nil { - t.Fatal(err) - } - if gotSize := outBuf.Len(); gotSize != wantSize { - t.Fatalf("got %d, want %d", gotSize, int(wantSize)) - } - }) - - t.Run("stdin", func(t *testing.T) { - if cibuild.On() { - t.Skip("Skipping for now; see https://github.com/tailscale/tailscale/issues/4051") - } - cmd := execSSH("cat") - var outBuf bytes.Buffer - cmd.Stdout = &outBuf - const str = "foo\nbar\n" - cmd.Stdin = strings.NewReader(str) - if err := cmd.Run(); err != nil { - t.Fatal(err) - } - if got := outBuf.String(); got != str { - t.Errorf("got %q; want %q", got, str) - } - }) -} - -func parseEnv(out []byte) map[string]string { - e := map[string]string{} - for line := range lineiter.Bytes(out) { - if i := bytes.IndexByte(line, '='); i != -1 { - e[string(line[:i])] = string(line[i+1:]) - } - } - return e -} - -func TestPublicKeyFetching(t *testing.T) { - var reqsTotal, reqsIfNoneMatchHit, reqsIfNoneMatchMiss int32 - ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32((&reqsTotal), 1) - etag := fmt.Sprintf("W/%q", sha256.Sum256([]byte(r.URL.Path))) - w.Header().Set("Etag", etag) - if v := r.Header.Get("If-None-Match"); v != "" { - if v == etag { - atomic.AddInt32(&reqsIfNoneMatchHit, 1) - w.WriteHeader(304) - return - } - atomic.AddInt32(&reqsIfNoneMatchMiss, 1) - } - io.WriteString(w, "foo\nbar\n"+string(r.URL.Path)+"\n") - })) - ts.StartTLS() - defer ts.Close() - keys := ts.URL - - clock := &tstest.Clock{} - srv := &server{ - pubKeyHTTPClient: ts.Client(), - timeNow: clock.Now, - } - for range 2 { - got, err := srv.fetchPublicKeysURL(keys + "/alice.keys") - if err != nil { - t.Fatal(err) - } - if want := []string{"foo", "bar", "/alice.keys"}; !reflect.DeepEqual(got, want) { - t.Errorf("got %q; want %q", got, want) - } - } - if got, want := atomic.LoadInt32(&reqsTotal), int32(1); got != want { - t.Errorf("got %d requests; want %d", got, want) - } - if got, want := atomic.LoadInt32(&reqsIfNoneMatchHit), int32(0); got != want { - t.Errorf("got %d etag hits; want %d", got, want) - } - clock.Advance(5 * time.Minute) - got, err := srv.fetchPublicKeysURL(keys + "/alice.keys") - if err != nil { - t.Fatal(err) - } - if want := []string{"foo", "bar", "/alice.keys"}; !reflect.DeepEqual(got, want) { - t.Errorf("got %q; want %q", got, want) - } - if got, want := atomic.LoadInt32(&reqsTotal), int32(2); got != want { - t.Errorf("got %d requests; want %d", got, want) - } - if got, want := atomic.LoadInt32(&reqsIfNoneMatchHit), int32(1); got != want { - t.Errorf("got %d etag hits; want %d", got, want) - } - if got, want := atomic.LoadInt32(&reqsIfNoneMatchMiss), int32(0); got != want { - t.Errorf("got %d etag misses; want %d", got, want) - } - -} - -func TestExpandPublicKeyURL(t *testing.T) { - c := &conn{ - info: &sshConnInfo{ - uprof: tailcfg.UserProfile{ - LoginName: "bar@baz.tld", - }, - }, - } - if got, want := c.expandPublicKeyURL("foo"), "foo"; got != want { - t.Errorf("basic: got %q; want %q", got, want) - } - if got, want := c.expandPublicKeyURL("https://example.com/$LOGINNAME_LOCALPART.keys"), "https://example.com/bar.keys"; got != want { - t.Errorf("localpart: got %q; want %q", got, want) - } - if got, want := c.expandPublicKeyURL("https://example.com/keys?email=$LOGINNAME_EMAIL"), "https://example.com/keys?email=bar@baz.tld"; got != want { - t.Errorf("email: got %q; want %q", got, want) - } - c.info = new(sshConnInfo) - if got, want := c.expandPublicKeyURL("https://example.com/keys?email=$LOGINNAME_EMAIL"), "https://example.com/keys?email="; got != want { - t.Errorf("on empty: got %q; want %q", got, want) - } -} - -func TestAcceptEnvPair(t *testing.T) { - tests := []struct { - in string - want bool - }{ - {"TERM=x", true}, - {"term=x", false}, - {"TERM", false}, - {"LC_FOO=x", true}, - {"LD_PRELOAD=naah", false}, - {"TERM=screen-256color", true}, - } - for _, tt := range tests { - if got := acceptEnvPair(tt.in); got != tt.want { - t.Errorf("for %q, got %v; want %v", tt.in, got, tt.want) - } - } -} - -func TestPathFromPAMEnvLine(t *testing.T) { - u := &user.User{Username: "foo", HomeDir: "/Homes/Foo"} - tests := []struct { - line string - u *user.User - want string - }{ - {"", u, ""}, - {`PATH DEFAULT="/run/wrappers/bin:@{HOME}/.nix-profile/bin:/etc/profiles/per-user/@{PAM_USER}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"`, - u, "/run/wrappers/bin:/Homes/Foo/.nix-profile/bin:/etc/profiles/per-user/foo/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"}, - {`PATH DEFAULT="@{SOMETHING_ELSE}:nope:@{HOME}"`, - u, ""}, - } - for i, tt := range tests { - got := pathFromPAMEnvLine([]byte(tt.line), tt.u) - if got != tt.want { - t.Errorf("%d. got %q; want %q", i, got, tt.want) - } - } -} - -func TestExpandDefaultPathTmpl(t *testing.T) { - u := &user.User{Username: "foo", HomeDir: "/Homes/Foo"} - tests := []struct { - t string - u *user.User - want string - }{ - {"", u, ""}, - {`/run/wrappers/bin:@{HOME}/.nix-profile/bin:/etc/profiles/per-user/@{PAM_USER}/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin`, - u, "/run/wrappers/bin:/Homes/Foo/.nix-profile/bin:/etc/profiles/per-user/foo/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin"}, - {`@{SOMETHING_ELSE}:nope:@{HOME}`, u, ""}, - } - for i, tt := range tests { - got := expandDefaultPathTmpl(tt.t, tt.u) - if got != tt.want { - t.Errorf("%d. got %q; want %q", i, got, tt.want) - } - } -} - -func TestPathFromPAMEnvLineOnNixOS(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("skipping on non-linux") - } - if distro.Get() != distro.NixOS { - t.Skip("skipping on non-NixOS") - } - u, err := user.Current() - if err != nil { - t.Fatal(err) - } - got := defaultPathForUserOnNixOS(u) - if got == "" { - x, err := os.ReadFile("/etc/pam/environment") - t.Fatalf("no result. file was: err=%v, contents=%s", err, x) - } - t.Logf("success; got=%q", got) -} - -func TestStdOsUserUserAssumptions(t *testing.T) { - v := reflect.TypeFor[user.User]() - if got, want := v.NumField(), 5; got != want { - t.Errorf("os/user.User has %v fields; this package assumes %v", got, want) - } -} - -func mockRecordingServer(t *testing.T, handleRecord http.HandlerFunc) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - mux.HandleFunc("POST /record", func(http.ResponseWriter, *http.Request) { - t.Errorf("v1 recording endpoint called") - }) - mux.HandleFunc("HEAD /v2/record", func(http.ResponseWriter, *http.Request) {}) - mux.HandleFunc("POST /v2/record", handleRecord) - - h2s := &http2.Server{} - srv := httptest.NewUnstartedServer(h2c.NewHandler(mux, h2s)) - if err := http2.ConfigureServer(srv.Config, h2s); err != nil { - t.Errorf("configuring HTTP/2 support in recording server: %v", err) - } - srv.Start() - t.Cleanup(srv.Close) - return srv -} diff --git a/ssh/tailssh/testcontainers/Dockerfile b/ssh/tailssh/testcontainers/Dockerfile deleted file mode 100644 index c94c961d37c61..0000000000000 --- a/ssh/tailssh/testcontainers/Dockerfile +++ /dev/null @@ -1,82 +0,0 @@ -ARG BASE -FROM ${BASE} - -ARG BASE - -RUN echo "Install openssh, needed for scp." -RUN if echo "$BASE" | grep "ubuntu:"; then apt-get update -y && apt-get install -y openssh-client; fi -RUN if echo "$BASE" | grep "alpine:"; then apk add openssh; fi - -# Note - on Ubuntu, we do not create the user's home directory, pam_mkhomedir will do that -# for us, and we want to test that PAM gets triggered by Tailscale SSH. -RUN if echo "$BASE" | grep "ubuntu:"; then groupadd -g 10000 groupone && groupadd -g 10001 grouptwo && useradd -g 10000 -G 10001 -u 10002 testuser; fi -# On Alpine, we can't configure pam_mkhomdir, so go ahead and create home directory. -RUN if echo "$BASE" | grep "alpine:"; then addgroup -g 10000 groupone && addgroup -g 10001 grouptwo && adduser -u 10002 -D testuser && addgroup testuser groupone && addgroup testuser grouptwo; fi - -RUN if echo "$BASE" | grep "ubuntu:"; then \ - echo "Set up pam_mkhomedir." && \ - sed -i -e 's/Default: no/Default: yes/g' /usr/share/pam-configs/mkhomedir && \ - cat /usr/share/pam-configs/mkhomedir && \ - pam-auth-update --enable mkhomedir \ - ; fi - -COPY tailscaled . -COPY tailssh.test . - -RUN chmod 755 tailscaled - -RUN echo "First run tests normally." -RUN eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding -RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP -RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSCP -RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH - -RUN echo "Then run tests as non-root user testuser and make sure tests still pass." -RUN touch /tmp/tailscalessh.log -RUN chown testuser:groupone /tmp/tailscalessh.log -RUN TAILSCALED_PATH=`pwd`tailscaled eval `su -m testuser -c ssh-agent -s` && su -m testuser -c "./tailssh.test -test.v -test.run TestSSHAgentForwarding" -RUN TAILSCALED_PATH=`pwd`tailscaled su -m testuser -c "./tailssh.test -test.v -test.run TestIntegration TestDoDropPrivileges" -RUN echo "Also, deny everyone access to the user's home directory and make sure non file-related tests still pass." -RUN mkdir -p /home/testuser && chown testuser:groupone /home/testuser && chmod 0000 /home/testuser -RUN TAILSCALED_PATH=`pwd`tailscaled SKIP_FILE_OPS=1 su -m testuser -c "./tailssh.test -test.v -test.run TestIntegrationSSH" -RUN chmod 0755 /home/testuser -RUN chown root:root /tmp/tailscalessh.log - -RUN if echo "$BASE" | grep "ubuntu:"; then \ - echo "Then run tests in a system that's pretending to be SELinux in enforcing mode" && \ - # Remove execute permissions for /usr/bin/login so that it fails. - mv /usr/bin/login /tmp/login_orig && \ - # Use nonsense for /usr/bin/login so that it fails. - # It's not the same failure mode as in SELinux, but failure is good enough for test. - echo "adsfasdfasdf" > /usr/bin/login && \ - chmod 755 /usr/bin/login && \ - # Simulate getenforce command - printf "#!/bin/bash\necho 'Enforcing'" > /usr/bin/getenforce && \ - chmod 755 /usr/bin/getenforce && \ - eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding && \ - TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegration && \ - mv /tmp/login_orig /usr/bin/login && \ - rm /usr/bin/getenforce \ - ; fi - -RUN echo "Then remove the login command and make sure tests still pass." -RUN rm `which login` -RUN eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding -RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSFTP -RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSCP -RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH - -RUN echo "Then remove the su command and make sure tests still pass." -RUN chown root:root /tmp/tailscalessh.log -RUN rm `which su` -RUN eval `ssh-agent -s` && TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestSSHAgentForwarding -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegration - -RUN echo "Test doDropPrivileges" -RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestDoDropPrivileges diff --git a/ssh/tailssh/user.go b/ssh/tailssh/user.go deleted file mode 100644 index 15191813bdca6..0000000000000 --- a/ssh/tailssh/user.go +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || (darwin && !ios) || freebsd || openbsd - -package tailssh - -import ( - "os" - "os/exec" - "os/user" - "path/filepath" - "runtime" - "strconv" - "strings" - - "go4.org/mem" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/util/lineiter" - "tailscale.com/util/osuser" - "tailscale.com/version/distro" -) - -// userMeta is a wrapper around *user.User with extra fields. -type userMeta struct { - user.User - - // loginShellCached is the user's login shell, if known - // at the time of userLookup. - loginShellCached string -} - -// GroupIds returns the list of group IDs that the user is a member of. -func (u *userMeta) GroupIds() ([]string, error) { - return osuser.GetGroupIds(&u.User) -} - -// userLookup is like os/user.Lookup but it returns a *userMeta wrapper -// around a *user.User with extra fields. -func userLookup(username string) (*userMeta, error) { - u, s, err := osuser.LookupByUsernameWithShell(username) - if err != nil { - return nil, err - } - - return &userMeta{User: *u, loginShellCached: s}, nil -} - -func (u *userMeta) LoginShell() string { - if u.loginShellCached != "" { - // This field should be populated on Linux, at least, because - // func userLookup on Linux uses "getent" to look up the user - // and that populates it. - return u.loginShellCached - } - switch runtime.GOOS { - case "darwin": - // Note: /Users/username is key, and not the same as u.HomeDir. - out, _ := exec.Command("dscl", ".", "-read", filepath.Join("/Users", u.Username), "UserShell").Output() - // out is "UserShell: /bin/bash" - s, ok := strings.CutPrefix(string(out), "UserShell: ") - if ok { - return strings.TrimSpace(s) - } - } - if e := os.Getenv("SHELL"); e != "" { - return e - } - return "/bin/sh" -} - -// defaultPathTmpl specifies the default PATH template to use for new sessions. -// -// If empty, a default value is used based on the OS & distro to match OpenSSH's -// usually-hardcoded behavior. (see -// https://github.com/tailscale/tailscale/issues/5285 for background). -// -// The template may contain @{HOME} or @{PAM_USER} which expand to the user's -// home directory and username, respectively. (PAM is not used, despite the -// name) -var defaultPathTmpl = envknob.RegisterString("TAILSCALE_SSH_DEFAULT_PATH") - -func defaultPathForUser(u *user.User) string { - if s := defaultPathTmpl(); s != "" { - return expandDefaultPathTmpl(s, u) - } - isRoot := u.Uid == "0" - switch distro.Get() { - case distro.Debian: - hi := hostinfo.New() - if hi.Distro == "ubuntu" { - // distro.Get's Debian includes Ubuntu. But see if it's actually Ubuntu. - // Ubuntu doesn't empirically seem to distinguish between root and non-root for the default. - // And it includes /snap/bin. - return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin" - } - if isRoot { - return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - } - return "/usr/local/bin:/usr/bin:/bin:/usr/bn/games" - case distro.NixOS: - return defaultPathForUserOnNixOS(u) - } - if isRoot { - return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - } - return "/usr/local/bin:/usr/bin:/bin" -} - -func defaultPathForUserOnNixOS(u *user.User) string { - for lr := range lineiter.File("/etc/pam/environment") { - lineb, err := lr.Value() - if err != nil { - return "" - } - if v := pathFromPAMEnvLine(lineb, u); v != "" { - return v - } - } - return "" -} - -func pathFromPAMEnvLine(line []byte, u *user.User) (path string) { - if !mem.HasPrefix(mem.B(line), mem.S("PATH")) { - return "" - } - rest := strings.TrimSpace(strings.TrimPrefix(string(line), "PATH")) - if quoted, ok := strings.CutPrefix(rest, "DEFAULT="); ok { - if path, err := strconv.Unquote(quoted); err == nil { - return expandDefaultPathTmpl(path, u) - } - } - return "" -} - -func expandDefaultPathTmpl(t string, u *user.User) string { - p := strings.NewReplacer( - "@{HOME}", u.HomeDir, - "@{PAM_USER}", u.Username, - ).Replace(t) - if strings.Contains(p, "@{") { - // If there are unknown expansions, conservatively fail closed. - return "" - } - return p -} diff --git a/syncs/locked_test.go b/syncs/locked_test.go deleted file mode 100644 index 90b36e8321d82..0000000000000 --- a/syncs/locked_test.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build go1.13 && !go1.19 - -package syncs - -import ( - "sync" - "testing" - "time" -) - -func wantPanic(t *testing.T, fn func()) { - t.Helper() - defer func() { - recover() - }() - fn() - t.Fatal("failed to panic") -} - -func TestAssertLocked(t *testing.T) { - m := new(sync.Mutex) - wantPanic(t, func() { AssertLocked(m) }) - m.Lock() - AssertLocked(m) - m.Unlock() - wantPanic(t, func() { AssertLocked(m) }) - // Test correct handling of mutex with waiter. - m.Lock() - AssertLocked(m) - go func() { - m.Lock() - m.Unlock() - }() - // Give the goroutine above a few moments to get started. - // The test will pass whether or not we win the race, - // but we want to run sometimes, to get the test coverage. - time.Sleep(10 * time.Millisecond) - AssertLocked(m) -} - -func TestAssertWLocked(t *testing.T) { - m := new(sync.RWMutex) - wantPanic(t, func() { AssertWLocked(m) }) - m.Lock() - AssertWLocked(m) - m.Unlock() - wantPanic(t, func() { AssertWLocked(m) }) - // Test correct handling of mutex with waiter. - m.Lock() - AssertWLocked(m) - go func() { - m.Lock() - m.Unlock() - }() - // Give the goroutine above a few moments to get started. - // The test will pass whether or not we win the race, - // but we want to run sometimes, to get the test coverage. - time.Sleep(10 * time.Millisecond) - AssertWLocked(m) -} - -func TestAssertRLocked(t *testing.T) { - m := new(sync.RWMutex) - wantPanic(t, func() { AssertRLocked(m) }) - - m.Lock() - AssertRLocked(m) - m.Unlock() - - m.RLock() - AssertRLocked(m) - m.RUnlock() - - wantPanic(t, func() { AssertRLocked(m) }) - - // Test correct handling of mutex with waiter. - m.RLock() - AssertRLocked(m) - go func() { - m.RLock() - m.RUnlock() - }() - // Give the goroutine above a few moments to get started. - // The test will pass whether or not we win the race, - // but we want to run sometimes, to get the test coverage. - time.Sleep(10 * time.Millisecond) - AssertRLocked(m) - m.RUnlock() - - // Test correct handling of rlock with write waiter. - m.RLock() - AssertRLocked(m) - go func() { - m.Lock() - m.Unlock() - }() - // Give the goroutine above a few moments to get started. - // The test will pass whether or not we win the race, - // but we want to run sometimes, to get the test coverage. - time.Sleep(10 * time.Millisecond) - AssertRLocked(m) - m.RUnlock() - - // Test correct handling of rlock with other rlocks. - // This is a bit racy, but losing the race hurts nothing, - // and winning the race means correct test coverage. - m.RLock() - AssertRLocked(m) - go func() { - m.RLock() - time.Sleep(10 * time.Millisecond) - m.RUnlock() - }() - time.Sleep(5 * time.Millisecond) - AssertRLocked(m) - m.RUnlock() -} diff --git a/syncs/pool_test.go b/syncs/pool_test.go deleted file mode 100644 index 798b18cbabfd8..0000000000000 --- a/syncs/pool_test.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package syncs - -import "testing" - -func TestPool(t *testing.T) { - var pool Pool[string] - s := pool.Get() // should not panic - if s != "" { - t.Fatalf("got %q, want %q", s, "") - } - pool.New = func() string { return "new" } - s = pool.Get() - if s != "new" { - t.Fatalf("got %q, want %q", s, "new") - } - var found bool - for range 1000 { - pool.Put("something") - found = pool.Get() == "something" - if found { - break - } - } - if !found { - t.Fatalf("unable to get any value put in the pool") - } -} diff --git a/syncs/shardedmap_test.go b/syncs/shardedmap_test.go deleted file mode 100644 index 993ffdff875c2..0000000000000 --- a/syncs/shardedmap_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package syncs - -import "testing" - -func TestShardedMap(t *testing.T) { - m := NewShardedMap[int, string](16, func(i int) int { return i % 16 }) - - if m.Contains(1) { - t.Errorf("got contains; want !contains") - } - if !m.Set(1, "one") { - t.Errorf("got !set; want set") - } - if m.Set(1, "one") { - t.Errorf("got set; want !set") - } - if !m.Contains(1) { - t.Errorf("got !contains; want contains") - } - if g, w := m.Get(1), "one"; g != w { - t.Errorf("got %q; want %q", g, w) - } - if _, ok := m.GetOk(1); !ok { - t.Errorf("got ok; want !ok") - } - if _, ok := m.GetOk(2); ok { - t.Errorf("got ok; want !ok") - } - if g, w := m.Len(), 1; g != w { - t.Errorf("got Len %v; want %v", g, w) - } - if m.Delete(2) { - t.Errorf("got deleted; want !deleted") - } - if !m.Delete(1) { - t.Errorf("got !deleted; want deleted") - } - if g, w := m.Len(), 0; g != w { - t.Errorf("got Len %v; want %v", g, w) - } - - // Mutation adding an entry. - if v := m.Mutate(1, func(was string, ok bool) (string, bool) { - if ok { - t.Fatal("was okay") - } - return "ONE", true - }); v != 1 { - t.Errorf("Mutate = %v; want 1", v) - } - if g, w := m.Get(1), "ONE"; g != w { - t.Errorf("got %q; want %q", g, w) - } - // Mutation changing an entry. - if v := m.Mutate(1, func(was string, ok bool) (string, bool) { - if !ok { - t.Fatal("wasn't okay") - } - return was + "-" + was, true - }); v != 0 { - t.Errorf("Mutate = %v; want 0", v) - } - if g, w := m.Get(1), "ONE-ONE"; g != w { - t.Errorf("got %q; want %q", g, w) - } - // Mutation removing an entry. - if v := m.Mutate(1, func(was string, ok bool) (string, bool) { - if !ok { - t.Fatal("wasn't okay") - } - return "", false - }); v != -1 { - t.Errorf("Mutate = %v; want -1", v) - } - if g, w := m.Get(1), ""; g != w { - t.Errorf("got %q; want %q", g, w) - } -} diff --git a/syncs/syncs.go b/syncs/syncs.go index acc0c88f2991e..8f9b03e9fc23c 100644 --- a/syncs/syncs.go +++ b/syncs/syncs.go @@ -10,7 +10,7 @@ import ( "sync" "sync/atomic" - "tailscale.com/util/mak" + "github.com/sagernet/tailscale/util/mak" ) // ClosedChan returns a channel that's already closed. diff --git a/syncs/syncs_test.go b/syncs/syncs_test.go deleted file mode 100644 index ee3711e76587b..0000000000000 --- a/syncs/syncs_test.go +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package syncs - -import ( - "context" - "io" - "os" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestAtomicValue(t *testing.T) { - { - // Always wrapping should not allocate for simple values - // because wrappedValue[T] has the same memory layout as T. - var v AtomicValue[bool] - bools := []bool{true, false} - if n := int(testing.AllocsPerRun(1000, func() { - for _, b := range bools { - v.Store(b) - } - })); n != 0 { - t.Errorf("AllocsPerRun = %d, want 0", n) - } - } - - { - var v AtomicValue[int] - got, gotOk := v.LoadOk() - if got != 0 || gotOk { - t.Fatalf("LoadOk = (%v, %v), want (0, false)", got, gotOk) - } - v.Store(1) - got, gotOk = v.LoadOk() - if got != 1 || !gotOk { - t.Fatalf("LoadOk = (%v, %v), want (1, true)", got, gotOk) - } - } - - { - var v AtomicValue[error] - got, gotOk := v.LoadOk() - if got != nil || gotOk { - t.Fatalf("LoadOk = (%v, %v), want (nil, false)", got, gotOk) - } - v.Store(io.EOF) - got, gotOk = v.LoadOk() - if got != io.EOF || !gotOk { - t.Fatalf("LoadOk = (%v, %v), want (EOF, true)", got, gotOk) - } - err := &os.PathError{} - v.Store(err) - got, gotOk = v.LoadOk() - if got != err || !gotOk { - t.Fatalf("LoadOk = (%v, %v), want (%v, true)", got, gotOk, err) - } - v.Store(nil) - got, gotOk = v.LoadOk() - if got != nil || !gotOk { - t.Fatalf("LoadOk = (%v, %v), want (nil, true)", got, gotOk) - } - } -} - -func TestWaitGroupChan(t *testing.T) { - wg := NewWaitGroupChan() - - wantNotDone := func() { - t.Helper() - select { - case <-wg.DoneChan(): - t.Fatal("done too early") - default: - } - } - - wantDone := func() { - t.Helper() - select { - case <-wg.DoneChan(): - default: - t.Fatal("expected to be done") - } - } - - wg.Add(2) - wantNotDone() - - wg.Decr() - wantNotDone() - - wg.Decr() - wantDone() - wantDone() -} - -func TestClosedChan(t *testing.T) { - ch := ClosedChan() - for range 2 { - select { - case <-ch: - default: - t.Fatal("not closed") - } - } -} - -func TestSemaphore(t *testing.T) { - s := NewSemaphore(2) - s.Acquire() - if !s.TryAcquire() { - t.Fatal("want true") - } - if s.TryAcquire() { - t.Fatal("want false") - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if s.AcquireContext(ctx) { - t.Fatal("want false") - } - s.Release() - if !s.AcquireContext(context.Background()) { - t.Fatal("want true") - } - s.Release() - s.Release() -} - -func TestMap(t *testing.T) { - var m Map[string, int] - if v, ok := m.Load("noexist"); v != 0 || ok { - t.Errorf(`Load("noexist") = (%v, %v), want (0, false)`, v, ok) - } - m.LoadFunc("noexist", func(v int, ok bool) { - if v != 0 || ok { - t.Errorf(`LoadFunc("noexist") = (%v, %v), want (0, false)`, v, ok) - } - }) - m.Store("one", 1) - if v, ok := m.LoadOrStore("one", -1); v != 1 || !ok { - t.Errorf(`LoadOrStore("one", 1) = (%v, %v), want (1, true)`, v, ok) - } - if v, ok := m.Load("one"); v != 1 || !ok { - t.Errorf(`Load("one") = (%v, %v), want (1, true)`, v, ok) - } - m.LoadFunc("one", func(v int, ok bool) { - if v != 1 || !ok { - t.Errorf(`LoadFunc("one") = (%v, %v), want (1, true)`, v, ok) - } - }) - if v, ok := m.LoadOrStore("two", 2); v != 2 || ok { - t.Errorf(`LoadOrStore("two", 2) = (%v, %v), want (2, false)`, v, ok) - } - if v, ok := m.LoadOrInit("three", func() int { return 3 }); v != 3 || ok { - t.Errorf(`LoadOrInit("three", 3) = (%v, %v), want (3, true)`, v, ok) - } - got := map[string]int{} - want := map[string]int{"one": 1, "two": 2, "three": 3} - for k, v := range m.All() { - got[k] = v - } - if d := cmp.Diff(got, want); d != "" { - t.Errorf("Range mismatch (-got +want):\n%s", d) - } - if v, ok := m.LoadAndDelete("two"); v != 2 || !ok { - t.Errorf(`LoadAndDelete("two) = (%v, %v), want (2, true)`, v, ok) - } - if v, ok := m.LoadAndDelete("two"); v != 0 || ok { - t.Errorf(`LoadAndDelete("two) = (%v, %v), want (0, false)`, v, ok) - } - m.Delete("three") - m.Delete("one") - m.Delete("noexist") - got = map[string]int{} - want = map[string]int{} - for k, v := range m.All() { - got[k] = v - } - if d := cmp.Diff(got, want); d != "" { - t.Errorf("Range mismatch (-got +want):\n%s", d) - } - - t.Run("LoadOrStore", func(t *testing.T) { - var m Map[string, string] - var wg WaitGroup - var ok1, ok2 bool - wg.Go(func() { _, ok1 = m.LoadOrStore("", "") }) - wg.Go(func() { _, ok2 = m.LoadOrStore("", "") }) - wg.Wait() - if ok1 == ok2 { - t.Errorf("exactly one LoadOrStore should load") - } - }) - - t.Run("Clear", func(t *testing.T) { - var m Map[string, string] - _, _ = m.LoadOrStore("a", "1") - _, _ = m.LoadOrStore("b", "2") - _, _ = m.LoadOrStore("c", "3") - _, _ = m.LoadOrStore("d", "4") - _, _ = m.LoadOrStore("e", "5") - - if m.Len() != 5 { - t.Errorf("Len after loading want=5 got=%d", m.Len()) - } - - m.Clear() - if m.Len() != 0 { - t.Errorf("Len after Clear want=0 got=%d", m.Len()) - } - }) - - t.Run("Swap", func(t *testing.T) { - var m Map[string, string] - m.Store("hello", "world") - if got, want := m.Swap("hello", "world2"), "world"; got != want { - t.Errorf("got old value %q, want %q", got, want) - } - if got := m.Swap("empty", "foo"); got != "" { - t.Errorf("got old value %q, want empty string", got) - } - }) -} diff --git a/tailcfg/derpmap.go b/tailcfg/derpmap.go index 056152157fede..8a13ede225d43 100644 --- a/tailcfg/derpmap.go +++ b/tailcfg/derpmap.go @@ -7,7 +7,7 @@ import ( "net/netip" "sort" - "tailscale.com/types/key" + "github.com/sagernet/tailscale/types/key" ) // DERPMap describes the set of DERP packet relay servers that are available. diff --git a/tailcfg/proto_port_range.go b/tailcfg/proto_port_range.go index f65c58804d44d..f429eef7fe87d 100644 --- a/tailcfg/proto_port_range.go +++ b/tailcfg/proto_port_range.go @@ -9,8 +9,8 @@ import ( "strconv" "strings" - "tailscale.com/types/ipproto" - "tailscale.com/util/vizerror" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/util/vizerror" ) var ( diff --git a/tailcfg/proto_port_range_test.go b/tailcfg/proto_port_range_test.go deleted file mode 100644 index 59ccc9be4a1a8..0000000000000 --- a/tailcfg/proto_port_range_test.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tailcfg - -import ( - "encoding" - "testing" - - "tailscale.com/types/ipproto" - "tailscale.com/util/vizerror" -) - -var _ encoding.TextUnmarshaler = (*ProtoPortRange)(nil) - -func TestProtoPortRangeParsing(t *testing.T) { - pr := func(s, e uint16) PortRange { - return PortRange{First: s, Last: e} - } - tests := []struct { - in string - out ProtoPortRange - err error - }{ - {in: "tcp:80", out: ProtoPortRange{Proto: int(ipproto.TCP), Ports: pr(80, 80)}}, - {in: "80", out: ProtoPortRange{Ports: pr(80, 80)}}, - {in: "*", out: ProtoPortRange{Ports: PortRangeAny}}, - {in: "*:*", out: ProtoPortRange{Ports: PortRangeAny}}, - {in: "tcp:*", out: ProtoPortRange{Proto: int(ipproto.TCP), Ports: PortRangeAny}}, - { - in: "tcp:", - err: vizerror.Errorf("invalid port list: %#v", ""), - }, - { - in: ":80", - err: errEmptyProtocol, - }, - { - in: "", - err: errEmptyString, - }, - } - - for _, tc := range tests { - t.Run(tc.in, func(t *testing.T) { - var ppr ProtoPortRange - err := ppr.UnmarshalText([]byte(tc.in)) - if tc.err != err { - if err == nil || tc.err.Error() != err.Error() { - t.Fatalf("want err=%v, got %v", tc.err, err) - } - } - if ppr != tc.out { - t.Fatalf("got %v; want %v", ppr, tc.out) - } - }) - } -} - -func TestProtoPortRangeString(t *testing.T) { - tests := []struct { - input ProtoPortRange - want string - }{ - {ProtoPortRange{}, "0"}, - - // Zero protocol. - {ProtoPortRange{Ports: PortRangeAny}, "*"}, - {ProtoPortRange{Ports: PortRange{23, 23}}, "23"}, - {ProtoPortRange{Ports: PortRange{80, 120}}, "80-120"}, - - // Non-zero unnamed protocol. - {ProtoPortRange{Proto: 100, Ports: PortRange{80, 80}}, "100:80"}, - {ProtoPortRange{Proto: 200, Ports: PortRange{101, 105}}, "200:101-105"}, - - // Non-zero named protocol. - {ProtoPortRange{Proto: 1, Ports: PortRangeAny}, "icmp:*"}, - {ProtoPortRange{Proto: 2, Ports: PortRangeAny}, "igmp:*"}, - {ProtoPortRange{Proto: 6, Ports: PortRange{10, 13}}, "tcp:10-13"}, - {ProtoPortRange{Proto: 17, Ports: PortRangeAny}, "udp:*"}, - {ProtoPortRange{Proto: 0x84, Ports: PortRange{999, 999}}, "sctp:999"}, - {ProtoPortRange{Proto: 0x3a, Ports: PortRangeAny}, "ipv6-icmp:*"}, - {ProtoPortRange{Proto: 0x21, Ports: PortRangeAny}, "dccp:*"}, - {ProtoPortRange{Proto: 0x2f, Ports: PortRangeAny}, "gre:*"}, - } - for _, tc := range tests { - if got := tc.input.String(); got != tc.want { - t.Errorf("String for %v: got %q, want %q", tc.input, got, tc.want) - } - } -} - -func TestProtoPortRangeRoundTrip(t *testing.T) { - tests := []struct { - input ProtoPortRange - text string - }{ - {ProtoPortRange{Ports: PortRangeAny}, "*"}, - {ProtoPortRange{Ports: PortRange{23, 23}}, "23"}, - {ProtoPortRange{Ports: PortRange{80, 120}}, "80-120"}, - {ProtoPortRange{Proto: 100, Ports: PortRange{80, 80}}, "100:80"}, - {ProtoPortRange{Proto: 200, Ports: PortRange{101, 105}}, "200:101-105"}, - {ProtoPortRange{Proto: 1, Ports: PortRangeAny}, "icmp:*"}, - {ProtoPortRange{Proto: 2, Ports: PortRangeAny}, "igmp:*"}, - {ProtoPortRange{Proto: 6, Ports: PortRange{10, 13}}, "tcp:10-13"}, - {ProtoPortRange{Proto: 17, Ports: PortRangeAny}, "udp:*"}, - {ProtoPortRange{Proto: 0x84, Ports: PortRange{999, 999}}, "sctp:999"}, - {ProtoPortRange{Proto: 0x3a, Ports: PortRangeAny}, "ipv6-icmp:*"}, - {ProtoPortRange{Proto: 0x21, Ports: PortRangeAny}, "dccp:*"}, - {ProtoPortRange{Proto: 0x2f, Ports: PortRangeAny}, "gre:*"}, - } - - for _, tc := range tests { - out, err := tc.input.MarshalText() - if err != nil { - t.Errorf("MarshalText for %v: %v", tc.input, err) - continue - } - if got := string(out); got != tc.text { - t.Errorf("MarshalText for %#v: got %q, want %q", tc.input, got, tc.text) - } - var ppr ProtoPortRange - if err := ppr.UnmarshalText(out); err != nil { - t.Errorf("UnmarshalText for %q: err=%v", tc.text, err) - continue - } - if ppr != tc.input { - t.Errorf("round trip error for %q: got %v, want %#v", tc.text, ppr, tc.input) - } - } -} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 897e8d27f7f7b..8ecadd5e43456 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -20,13 +20,13 @@ import ( "strings" "time" - "tailscale.com/types/dnstype" - "tailscale.com/types/key" - "tailscale.com/types/opt" - "tailscale.com/types/structs" - "tailscale.com/types/tkatype" - "tailscale.com/util/dnsname" - "tailscale.com/util/slicesx" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/structs" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/util/dnsname" + "github.com/sagernet/tailscale/util/slicesx" ) // CapabilityVersion represents the client's capability level. That diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index f4f02c01721dc..936afacfe124a 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -10,12 +10,12 @@ import ( "net/netip" "time" - "tailscale.com/types/dnstype" - "tailscale.com/types/key" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/types/structs" - "tailscale.com/types/tkatype" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/structs" + "github.com/sagernet/tailscale/types/tkatype" ) // Clone makes a deep copy of User. diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go deleted file mode 100644 index 9f8c418a1ccf9..0000000000000 --- a/tailcfg/tailcfg_test.go +++ /dev/null @@ -1,953 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tailcfg_test - -import ( - "encoding/json" - "net/netip" - "os" - "reflect" - "regexp" - "strconv" - "strings" - "testing" - "time" - - . "tailscale.com/tailcfg" - "tailscale.com/tstest/deptest" - "tailscale.com/types/key" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/util/must" -) - -func fieldsOf(t reflect.Type) (fields []string) { - for i := range t.NumField() { - fields = append(fields, t.Field(i).Name) - } - return -} - -func TestHostinfoEqual(t *testing.T) { - hiHandles := []string{ - "IPNVersion", - "FrontendLogID", - "BackendLogID", - "OS", - "OSVersion", - "Container", - "Env", - "Distro", - "DistroVersion", - "DistroCodeName", - "App", - "Desktop", - "Package", - "DeviceModel", - "PushDeviceToken", - "Hostname", - "ShieldsUp", - "ShareeNode", - "NoLogsNoSupport", - "WireIngress", - "AllowsUpdate", - "Machine", - "GoArch", - "GoArchVar", - "GoVersion", - "RoutableIPs", - "RequestTags", - "WoLMACs", - "Services", - "NetInfo", - "SSH_HostKeys", - "Cloud", - "Userspace", - "UserspaceRouter", - "AppConnector", - "ServicesHash", - "Location", - } - if have := fieldsOf(reflect.TypeFor[Hostinfo]()); !reflect.DeepEqual(have, hiHandles) { - t.Errorf("Hostinfo.Equal check might be out of sync\nfields: %q\nhandled: %q\n", - have, hiHandles) - } - - nets := func(strs ...string) (ns []netip.Prefix) { - for _, s := range strs { - n, err := netip.ParsePrefix(s) - if err != nil { - panic(err) - } - ns = append(ns, n) - } - return ns - } - tests := []struct { - a, b *Hostinfo - want bool - }{ - { - nil, - nil, - true, - }, - { - &Hostinfo{}, - nil, - false, - }, - { - nil, - &Hostinfo{}, - false, - }, - { - &Hostinfo{}, - &Hostinfo{}, - true, - }, - - { - &Hostinfo{IPNVersion: "1"}, - &Hostinfo{IPNVersion: "2"}, - false, - }, - { - &Hostinfo{IPNVersion: "2"}, - &Hostinfo{IPNVersion: "2"}, - true, - }, - - { - &Hostinfo{FrontendLogID: "1"}, - &Hostinfo{FrontendLogID: "2"}, - false, - }, - { - &Hostinfo{FrontendLogID: "2"}, - &Hostinfo{FrontendLogID: "2"}, - true, - }, - - { - &Hostinfo{BackendLogID: "1"}, - &Hostinfo{BackendLogID: "2"}, - false, - }, - { - &Hostinfo{BackendLogID: "2"}, - &Hostinfo{BackendLogID: "2"}, - true, - }, - - { - &Hostinfo{OS: "windows"}, - &Hostinfo{OS: "linux"}, - false, - }, - { - &Hostinfo{OS: "windows"}, - &Hostinfo{OS: "windows"}, - true, - }, - - { - &Hostinfo{Hostname: "vega"}, - &Hostinfo{Hostname: "iris"}, - false, - }, - { - &Hostinfo{Hostname: "vega"}, - &Hostinfo{Hostname: "vega"}, - true, - }, - - { - &Hostinfo{RoutableIPs: nil}, - &Hostinfo{RoutableIPs: nets("10.0.0.0/16")}, - false, - }, - { - &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, - &Hostinfo{RoutableIPs: nets("10.2.0.0/16", "192.168.2.0/24")}, - false, - }, - { - &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, - &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.2.0/24")}, - false, - }, - { - &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, - &Hostinfo{RoutableIPs: nets("10.1.0.0/16", "192.168.1.0/24")}, - true, - }, - - { - &Hostinfo{RequestTags: []string{"abc", "def"}}, - &Hostinfo{RequestTags: []string{"abc", "def"}}, - true, - }, - { - &Hostinfo{RequestTags: []string{"abc", "def"}}, - &Hostinfo{RequestTags: []string{"abc", "123"}}, - false, - }, - { - &Hostinfo{RequestTags: []string{}}, - &Hostinfo{RequestTags: []string{"abc"}}, - false, - }, - - { - &Hostinfo{Services: []Service{{Proto: TCP, Port: 1234, Description: "foo"}}}, - &Hostinfo{Services: []Service{{Proto: UDP, Port: 2345, Description: "bar"}}}, - false, - }, - { - &Hostinfo{Services: []Service{{Proto: TCP, Port: 1234, Description: "foo"}}}, - &Hostinfo{Services: []Service{{Proto: TCP, Port: 1234, Description: "foo"}}}, - true, - }, - { - &Hostinfo{ShareeNode: true}, - &Hostinfo{}, - false, - }, - { - &Hostinfo{SSH_HostKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO.... root@bar"}}, - &Hostinfo{}, - false, - }, - { - &Hostinfo{App: "golink"}, - &Hostinfo{App: "abc"}, - false, - }, - { - &Hostinfo{App: "golink"}, - &Hostinfo{App: "golink"}, - true, - }, - { - &Hostinfo{AppConnector: opt.Bool("true")}, - &Hostinfo{AppConnector: opt.Bool("true")}, - true, - }, - { - &Hostinfo{AppConnector: opt.Bool("true")}, - &Hostinfo{AppConnector: opt.Bool("false")}, - false, - }, - { - &Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"}, - &Hostinfo{ServicesHash: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"}, - true, - }, - { - &Hostinfo{ServicesHash: "084c799cd551dd1d8d5c5f9a5d593b2e931f5e36122ee5c793c1d08a19839cc0"}, - &Hostinfo{}, - false, - }, - } - for i, tt := range tests { - got := tt.a.Equal(tt.b) - if got != tt.want { - t.Errorf("%d. Equal = %v; want %v", i, got, tt.want) - } - } -} - -func TestHostinfoHowEqual(t *testing.T) { - tests := []struct { - a, b *Hostinfo - want []string - }{ - { - a: nil, - b: nil, - want: nil, - }, - { - a: new(Hostinfo), - b: nil, - want: []string{"nil"}, - }, - { - a: nil, - b: new(Hostinfo), - want: []string{"nil"}, - }, - { - a: new(Hostinfo), - b: new(Hostinfo), - want: nil, - }, - { - a: &Hostinfo{ - IPNVersion: "1", - ShieldsUp: false, - RoutableIPs: []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}, - }, - b: &Hostinfo{ - IPNVersion: "2", - ShieldsUp: true, - RoutableIPs: []netip.Prefix{netip.MustParsePrefix("1.2.3.0/25")}, - }, - want: []string{"IPNVersion", "ShieldsUp", "RoutableIPs"}, - }, - { - a: &Hostinfo{ - IPNVersion: "1", - }, - b: &Hostinfo{ - IPNVersion: "2", - NetInfo: new(NetInfo), - }, - want: []string{"IPNVersion", "NetInfo.nil"}, - }, - { - a: &Hostinfo{ - IPNVersion: "1", - NetInfo: &NetInfo{ - WorkingIPv6: "true", - HavePortMap: true, - LinkType: "foo", - PreferredDERP: 123, - DERPLatency: map[string]float64{ - "foo": 1.0, - }, - }, - }, - b: &Hostinfo{ - IPNVersion: "2", - NetInfo: &NetInfo{}, - }, - want: []string{"IPNVersion", "NetInfo.WorkingIPv6", "NetInfo.HavePortMap", "NetInfo.PreferredDERP", "NetInfo.LinkType", "NetInfo.DERPLatency"}, - }, - } - for i, tt := range tests { - got := tt.a.HowUnequal(tt.b) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("%d. got %q; want %q", i, got, tt.want) - } - } -} - -func TestHostinfoTailscaleSSHEnabled(t *testing.T) { - tests := []struct { - hi *Hostinfo - want bool - }{ - { - nil, - false, - }, - { - &Hostinfo{}, - false, - }, - { - &Hostinfo{SSH_HostKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO.... root@bar"}}, - true, - }, - } - - for i, tt := range tests { - got := tt.hi.TailscaleSSHEnabled() - if got != tt.want { - t.Errorf("%d. got %v; want %v", i, got, tt.want) - } - } -} - -func TestNodeEqual(t *testing.T) { - nodeHandles := []string{ - "ID", "StableID", "Name", "User", "Sharer", - "Key", "KeyExpiry", "KeySignature", "Machine", "DiscoKey", - "Addresses", "AllowedIPs", "Endpoints", "DERP", "Hostinfo", - "Created", "Cap", "Tags", "PrimaryRoutes", - "LastSeen", "Online", "MachineAuthorized", - "Capabilities", "CapMap", - "UnsignedPeerAPIOnly", - "ComputedName", "computedHostIfDifferent", "ComputedNameWithHost", - "DataPlaneAuditLogID", "Expired", "SelfNodeV4MasqAddrForThisPeer", - "SelfNodeV6MasqAddrForThisPeer", "IsWireGuardOnly", "IsJailed", "ExitNodeDNSResolvers", - } - if have := fieldsOf(reflect.TypeFor[Node]()); !reflect.DeepEqual(have, nodeHandles) { - t.Errorf("Node.Equal check might be out of sync\nfields: %q\nhandled: %q\n", - have, nodeHandles) - } - - n1 := key.NewNode().Public() - m1 := key.NewMachine().Public() - now := time.Now() - - tests := []struct { - a, b *Node - want bool - }{ - { - &Node{}, - nil, - false, - }, - { - nil, - &Node{}, - false, - }, - { - &Node{}, - &Node{}, - true, - }, - { - &Node{}, - &Node{}, - true, - }, - { - &Node{ID: 1}, - &Node{}, - false, - }, - { - &Node{ID: 1}, - &Node{ID: 1}, - true, - }, - { - &Node{StableID: "node-abcd"}, - &Node{}, - false, - }, - { - &Node{StableID: "node-abcd"}, - &Node{StableID: "node-abcd"}, - true, - }, - { - &Node{User: 0}, - &Node{User: 1}, - false, - }, - { - &Node{User: 1}, - &Node{User: 1}, - true, - }, - { - &Node{Key: n1}, - &Node{Key: key.NewNode().Public()}, - false, - }, - { - &Node{Key: n1}, - &Node{Key: n1}, - true, - }, - { - &Node{KeyExpiry: now}, - &Node{KeyExpiry: now.Add(60 * time.Second)}, - false, - }, - { - &Node{KeyExpiry: now}, - &Node{KeyExpiry: now}, - true, - }, - { - &Node{Machine: m1}, - &Node{Machine: key.NewMachine().Public()}, - false, - }, - { - &Node{Machine: m1}, - &Node{Machine: m1}, - true, - }, - { - &Node{Addresses: []netip.Prefix{}}, - &Node{Addresses: nil}, - false, - }, - { - &Node{Addresses: []netip.Prefix{}}, - &Node{Addresses: []netip.Prefix{}}, - true, - }, - { - &Node{AllowedIPs: []netip.Prefix{}}, - &Node{AllowedIPs: nil}, - false, - }, - { - &Node{Addresses: []netip.Prefix{}}, - &Node{Addresses: []netip.Prefix{}}, - true, - }, - { - &Node{Endpoints: []netip.AddrPort{}}, - &Node{Endpoints: nil}, - false, - }, - { - &Node{Endpoints: []netip.AddrPort{}}, - &Node{Endpoints: []netip.AddrPort{}}, - true, - }, - { - &Node{Hostinfo: (&Hostinfo{Hostname: "alice"}).View()}, - &Node{Hostinfo: (&Hostinfo{Hostname: "bob"}).View()}, - false, - }, - { - &Node{Hostinfo: (&Hostinfo{}).View()}, - &Node{Hostinfo: (&Hostinfo{}).View()}, - true, - }, - { - &Node{Created: now}, - &Node{Created: now.Add(60 * time.Second)}, - false, - }, - { - &Node{Created: now}, - &Node{Created: now}, - true, - }, - { - &Node{LastSeen: &now}, - &Node{LastSeen: nil}, - false, - }, - { - &Node{LastSeen: &now}, - &Node{LastSeen: &now}, - true, - }, - { - &Node{DERP: "foo"}, - &Node{DERP: "bar"}, - false, - }, - { - &Node{Tags: []string{"tag:foo"}}, - &Node{Tags: []string{"tag:foo"}}, - true, - }, - { - &Node{Tags: []string{"tag:foo", "tag:bar"}}, - &Node{Tags: []string{"tag:bar"}}, - false, - }, - { - &Node{Tags: []string{"tag:foo"}}, - &Node{Tags: []string{"tag:bar"}}, - false, - }, - { - &Node{Tags: []string{"tag:foo"}}, - &Node{}, - false, - }, - { - &Node{Expired: true}, - &Node{}, - false, - }, - { - &Node{}, - &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, - false, - }, - { - &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, - &Node{SelfNodeV4MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("100.64.0.1"))}, - true, - }, - { - &Node{}, - &Node{SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))}, - false, - }, - { - &Node{SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))}, - &Node{SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))}, - true, - }, - { - &Node{ - CapMap: NodeCapMap{ - "foo": []RawMessage{`"foo"`}, - }, - }, - &Node{ - CapMap: NodeCapMap{ - "foo": []RawMessage{`"foo"`}, - }, - }, - true, - }, - { - &Node{ - CapMap: NodeCapMap{ - "bar": []RawMessage{`"foo"`}, - }, - }, - &Node{ - CapMap: NodeCapMap{ - "foo": []RawMessage{`"bar"`}, - }, - }, - false, - }, - { - &Node{ - CapMap: NodeCapMap{ - "foo": nil, - }, - }, - &Node{ - CapMap: NodeCapMap{ - "foo": []RawMessage{`"bar"`}, - }, - }, - false, - }, - { - &Node{IsJailed: true}, - &Node{IsJailed: true}, - true, - }, - { - &Node{IsJailed: false}, - &Node{IsJailed: true}, - false, - }, - } - for i, tt := range tests { - got := tt.a.Equal(tt.b) - if got != tt.want { - t.Errorf("%d. Equal = %v; want %v", i, got, tt.want) - } - } -} - -func TestNetInfoFields(t *testing.T) { - handled := []string{ - "MappingVariesByDestIP", - "HairPinning", - "WorkingIPv6", - "OSHasIPv6", - "WorkingUDP", - "WorkingICMPv4", - "HavePortMap", - "UPnP", - "PMP", - "PCP", - "PreferredDERP", - "LinkType", - "DERPLatency", - "FirewallMode", - } - if have := fieldsOf(reflect.TypeFor[NetInfo]()); !reflect.DeepEqual(have, handled) { - t.Errorf("NetInfo.Clone/BasicallyEqually check might be out of sync\nfields: %q\nhandled: %q\n", - have, handled) - } -} - -func TestCloneUser(t *testing.T) { - tests := []struct { - name string - u *User - }{ - {"nil_logins", &User{}}, - {"zero_logins", &User{Logins: make([]LoginID, 0)}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - u2 := tt.u.Clone() - if !reflect.DeepEqual(tt.u, u2) { - t.Errorf("not equal") - } - }) - } -} - -func TestCloneNode(t *testing.T) { - tests := []struct { - name string - v *Node - }{ - {"nil_fields", &Node{}}, - {"zero_fields", &Node{ - Addresses: make([]netip.Prefix, 0), - AllowedIPs: make([]netip.Prefix, 0), - Endpoints: make([]netip.AddrPort, 0), - }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - v2 := tt.v.Clone() - if !reflect.DeepEqual(tt.v, v2) { - t.Errorf("not equal") - } - }) - } -} - -func TestUserProfileJSONMarshalForMac(t *testing.T) { - // Old macOS clients had a bug where they required - // UserProfile.Roles to be non-null. Lock that in - // 1.0.x/1.2.x clients are gone in the wild. - // See mac commit 0242c08a2ca496958027db1208f44251bff8488b (Sep 30). - // It was fixed in at least 1.4.x, and perhaps 1.2.x. - j, err := json.Marshal(UserProfile{}) - if err != nil { - t.Fatal(err) - } - const wantSub = `"Roles":[]` - if !strings.Contains(string(j), wantSub) { - t.Fatalf("didn't contain %#q; got: %s", wantSub, j) - } - - // And back: - var up UserProfile - if err := json.Unmarshal(j, &up); err != nil { - t.Fatalf("Unmarshal: %v", err) - } -} - -func TestEndpointTypeMarshal(t *testing.T) { - eps := []EndpointType{ - EndpointUnknownType, - EndpointLocal, - EndpointSTUN, - EndpointPortmapped, - EndpointSTUN4LocalPort, - } - got, err := json.Marshal(eps) - if err != nil { - t.Fatal(err) - } - const want = `[0,1,2,3,4]` - if string(got) != want { - t.Errorf("got %s; want %s", got, want) - } -} - -func TestRegisterRequestNilClone(t *testing.T) { - var nilReq *RegisterRequest - got := nilReq.Clone() - if got != nil { - t.Errorf("got = %v; want nil", got) - } -} - -// Tests that CurrentCapabilityVersion is bumped when the comment block above it gets bumped. -// We've screwed this up several times. -func TestCurrentCapabilityVersion(t *testing.T) { - f := must.Get(os.ReadFile("tailcfg.go")) - matches := regexp.MustCompile(`(?m)^//[\s-]+(\d+): \d\d\d\d-\d\d-\d\d: `).FindAllStringSubmatch(string(f), -1) - max := 0 - for _, m := range matches { - n := must.Get(strconv.Atoi(m[1])) - if n > max { - max = n - } - } - if CapabilityVersion(max) != CurrentCapabilityVersion { - t.Errorf("CurrentCapabilityVersion = %d; want %d", CurrentCapabilityVersion, max) - } -} - -func TestUnmarshalHealth(t *testing.T) { - tests := []struct { - in string // MapResponse JSON - want []string // MapResponse.Health wanted value post-unmarshal - }{ - {in: `{}`}, - {in: `{"Health":null}`}, - {in: `{"Health":[]}`, want: []string{}}, - {in: `{"Health":["bad"]}`, want: []string{"bad"}}, - } - for _, tt := range tests { - var mr MapResponse - if err := json.Unmarshal([]byte(tt.in), &mr); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(mr.Health, tt.want) { - t.Errorf("for %#q: got %v; want %v", tt.in, mr.Health, tt.want) - } - } -} - -func TestRawMessage(t *testing.T) { - // Create a few types of json.RawMessages and then marshal them back and - // forth to make sure they round-trip. - - type rule struct { - Ports []int `json:",omitempty"` - } - tests := []struct { - name string - val map[string][]rule - wire map[string][]RawMessage - }{ - { - name: "nil", - val: nil, - wire: nil, - }, - { - name: "empty", - val: map[string][]rule{}, - wire: map[string][]RawMessage{}, - }, - { - name: "one", - val: map[string][]rule{ - "foo": {{Ports: []int{1, 2, 3}}}, - }, - wire: map[string][]RawMessage{ - "foo": { - `{"Ports":[1,2,3]}`, - }, - }, - }, - { - name: "many", - val: map[string][]rule{ - "foo": {{Ports: []int{1, 2, 3}}}, - "bar": {{Ports: []int{4, 5, 6}}, {Ports: []int{7, 8, 9}}}, - "baz": nil, - "abc": {}, - "def": {{}}, - }, - wire: map[string][]RawMessage{ - "foo": { - `{"Ports":[1,2,3]}`, - }, - "bar": { - `{"Ports":[4,5,6]}`, - `{"Ports":[7,8,9]}`, - }, - "baz": nil, - "abc": {}, - "def": {"{}"}, - }, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - j := must.Get(json.Marshal(tc.val)) - var gotWire map[string][]RawMessage - if err := json.Unmarshal(j, &gotWire); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if !reflect.DeepEqual(gotWire, tc.wire) { - t.Errorf("got %#v; want %#v", gotWire, tc.wire) - } - - j = must.Get(json.Marshal(tc.wire)) - var gotVal map[string][]rule - if err := json.Unmarshal(j, &gotVal); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if !reflect.DeepEqual(gotVal, tc.val) { - t.Errorf("got %#v; want %#v", gotVal, tc.val) - } - }) - } -} - -func TestMarshalToRawMessageAndBack(t *testing.T) { - type inner struct { - Groups []string `json:"groups,omitempty"` - } - testip := netip.MustParseAddrPort("1.2.3.4:80") - type testRule struct { - Ports []int `json:"ports,omitempty"` - ToggleOn bool `json:"toggleOn,omitempty"` - Name string `json:"name,omitempty"` - Groups inner `json:"groups,omitempty"` - Addrs []netip.AddrPort `json:"addrs"` - } - tests := []struct { - name string - capType PeerCapability - val testRule - }{ - { - name: "empty", - val: testRule{}, - capType: PeerCapability("foo"), - }, - { - name: "some values", - val: testRule{Ports: []int{80, 443}, Name: "foo"}, - capType: PeerCapability("foo"), - }, - { - name: "all values", - val: testRule{Ports: []int{80, 443}, Name: "foo", ToggleOn: true, Groups: inner{Groups: []string{"foo", "bar"}}, Addrs: []netip.AddrPort{testip}}, - capType: PeerCapability("foo"), - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - raw, err := MarshalCapJSON(tc.val) - if err != nil { - t.Fatalf("unexpected error marshalling raw message: %v", err) - } - cap := PeerCapMap{tc.capType: []RawMessage{raw}} - after, err := UnmarshalCapJSON[testRule](cap, tc.capType) - if err != nil { - t.Fatalf("unexpected error unmarshaling raw message: %v", err) - } - if !reflect.DeepEqual([]testRule{tc.val}, after) { - t.Errorf("got %#v; want %#v", after, []testRule{tc.val}) - } - }) - } -} - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - BadDeps: map[string]string{ - // Make sure we don't again accidentally bring in a dependency on - // drive or its transitive dependencies - "testing": "do not use testing package in production code", - "tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631", - "github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631", - }, - }.Check(t) -} - -func TestCheckTag(t *testing.T) { - tests := []struct { - name string - tag string - want bool - }{ - {"empty", "", false}, - {"good", "tag:foo", true}, - {"bad", "tag:", false}, - {"no_leading_num", "tag:1foo", false}, - {"no_punctuation", "tag:foa@bar", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := CheckTag(tt.tag) - if err == nil && !tt.want { - t.Errorf("got nil; want error") - } else if err != nil && tt.want { - t.Errorf("got %v; want nil", err) - } - }) - } -} diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index f275a6a9da5f2..4925bd9e40726 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -11,12 +11,12 @@ import ( "net/netip" "time" - "tailscale.com/types/dnstype" - "tailscale.com/types/key" - "tailscale.com/types/opt" - "tailscale.com/types/structs" - "tailscale.com/types/tkatype" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/structs" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/types/views" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,RegisterResponseAuth,RegisterRequest,DERPHomeParams,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan,Location,UserProfile diff --git a/tailcfg/tka.go b/tailcfg/tka.go index 97fdcc0db687a..7a1c4a966b7f7 100644 --- a/tailcfg/tka.go +++ b/tailcfg/tka.go @@ -4,8 +4,8 @@ package tailcfg import ( - "tailscale.com/types/key" - "tailscale.com/types/tkatype" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/tkatype" ) // TKAInitBeginRequest submits a genesis AUM to seed the creation of the diff --git a/taildrop/delete.go b/taildrop/delete.go index aaef34df1a7e4..ccf2ab7d08eb8 100644 --- a/taildrop/delete.go +++ b/taildrop/delete.go @@ -13,10 +13,10 @@ import ( "sync" "time" - "tailscale.com/ipn" - "tailscale.com/syncs" - "tailscale.com/tstime" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/logger" ) // deleteDelay is the amount of time to wait before we delete a file. diff --git a/taildrop/delete_test.go b/taildrop/delete_test.go deleted file mode 100644 index 5fa4b9c374fdf..0000000000000 --- a/taildrop/delete_test.go +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package taildrop - -import ( - "os" - "path/filepath" - "slices" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/tstest" - "tailscale.com/tstime" - "tailscale.com/util/must" -) - -func TestDeleter(t *testing.T) { - dir := t.TempDir() - must.Do(touchFile(filepath.Join(dir, "foo.partial"))) - must.Do(touchFile(filepath.Join(dir, "bar.partial"))) - must.Do(touchFile(filepath.Join(dir, "fizz"))) - must.Do(touchFile(filepath.Join(dir, "fizz.deleted"))) - must.Do(touchFile(filepath.Join(dir, "buzz.deleted"))) // lacks a matching "buzz" file - - checkDirectory := func(want ...string) { - t.Helper() - var got []string - for _, de := range must.Get(os.ReadDir(dir)) { - got = append(got, de.Name()) - } - slices.Sort(got) - slices.Sort(want) - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("directory mismatch (-got +want):\n%s", diff) - } - } - - clock := tstest.NewClock(tstest.ClockOpts{Start: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)}) - advance := func(d time.Duration) { - t.Helper() - t.Logf("advance: %v", d) - clock.Advance(d) - } - - eventsChan := make(chan string, 1000) - checkEvents := func(want ...string) { - t.Helper() - tm := time.NewTimer(10 * time.Second) - defer tm.Stop() - var got []string - for range want { - select { - case event := <-eventsChan: - t.Logf("event: %s", event) - got = append(got, event) - case <-tm.C: - t.Fatalf("timed out waiting for event: got %v, want %v", got, want) - } - } - slices.Sort(got) - slices.Sort(want) - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("events mismatch (-got +want):\n%s", diff) - } - } - eventHook := func(event string) { eventsChan <- event } - - var m Manager - var fd fileDeleter - m.opts.Logf = t.Logf - m.opts.Clock = tstime.DefaultClock{Clock: clock} - m.opts.Dir = dir - m.opts.State = must.Get(mem.New(nil, "")) - must.Do(m.opts.State.WriteState(ipn.TaildropReceivedKey, []byte{1})) - fd.Init(&m, eventHook) - defer fd.Shutdown() - insert := func(name string) { - t.Helper() - t.Logf("insert: %v", name) - fd.Insert(name) - } - remove := func(name string) { - t.Helper() - t.Logf("remove: %v", name) - fd.Remove(name) - } - - checkEvents("start full-scan") - checkEvents("end full-scan", "start waitAndDelete") - checkDirectory("foo.partial", "bar.partial", "buzz.deleted") - - advance(deleteDelay / 2) - checkDirectory("foo.partial", "bar.partial", "buzz.deleted") - advance(deleteDelay / 2) - checkEvents("deleted foo.partial", "deleted bar.partial", "deleted buzz.deleted") - checkEvents("end waitAndDelete") - checkDirectory() - - must.Do(touchFile(filepath.Join(dir, "one.partial"))) - insert("one.partial") - checkEvents("start waitAndDelete") - advance(deleteDelay / 4) - must.Do(touchFile(filepath.Join(dir, "two.partial"))) - insert("two.partial") - advance(deleteDelay / 4) - must.Do(touchFile(filepath.Join(dir, "three.partial"))) - insert("three.partial") - advance(deleteDelay / 4) - must.Do(touchFile(filepath.Join(dir, "four.partial"))) - insert("four.partial") - - advance(deleteDelay / 4) - checkEvents("deleted one.partial") - checkDirectory("two.partial", "three.partial", "four.partial") - checkEvents("end waitAndDelete", "start waitAndDelete") - - advance(deleteDelay / 4) - checkEvents("deleted two.partial") - checkDirectory("three.partial", "four.partial") - checkEvents("end waitAndDelete", "start waitAndDelete") - - advance(deleteDelay / 4) - checkEvents("deleted three.partial") - checkDirectory("four.partial") - checkEvents("end waitAndDelete", "start waitAndDelete") - - advance(deleteDelay / 4) - checkEvents("deleted four.partial") - checkDirectory() - checkEvents("end waitAndDelete") - - insert("wuzz.partial") - checkEvents("start waitAndDelete") - remove("wuzz.partial") - checkEvents("end waitAndDelete") -} - -// Test that the asynchronous full scan of the taildrop directory does not occur -// on a cold start if taildrop has never received any files. -func TestDeleterInitWithoutTaildrop(t *testing.T) { - var m Manager - var fd fileDeleter - m.opts.Logf = t.Logf - m.opts.Dir = t.TempDir() - m.opts.State = must.Get(mem.New(nil, "")) - fd.Init(&m, func(event string) { t.Errorf("unexpected event: %v", event) }) - fd.Shutdown() -} diff --git a/taildrop/resume_test.go b/taildrop/resume_test.go deleted file mode 100644 index d366340eb6efa..0000000000000 --- a/taildrop/resume_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package taildrop - -import ( - "bytes" - "io" - "math/rand" - "os" - "testing" - "testing/iotest" - - "tailscale.com/util/must" -) - -func TestResume(t *testing.T) { - oldBlockSize := blockSize - defer func() { blockSize = oldBlockSize }() - blockSize = 256 - - m := ManagerOptions{Logf: t.Logf, Dir: t.TempDir()}.New() - defer m.Shutdown() - - rn := rand.New(rand.NewSource(0)) - want := make([]byte, 12345) - must.Get(io.ReadFull(rn, want)) - - t.Run("resume-noexist", func(t *testing.T) { - r := io.Reader(bytes.NewReader(want)) - - next, close, err := m.HashPartialFile("", "foo") - must.Do(err) - defer close() - offset, r, err := ResumeReader(r, next) - must.Do(err) - must.Do(close()) // Windows wants the file handle to be closed to rename it. - - must.Get(m.PutFile("", "foo", r, offset, -1)) - got := must.Get(os.ReadFile(must.Get(joinDir(m.opts.Dir, "foo")))) - if !bytes.Equal(got, want) { - t.Errorf("content mismatches") - } - }) - - t.Run("resume-retry", func(t *testing.T) { - rn := rand.New(rand.NewSource(0)) - for i := 0; true; i++ { - r := io.Reader(bytes.NewReader(want)) - - next, close, err := m.HashPartialFile("", "bar") - must.Do(err) - defer close() - offset, r, err := ResumeReader(r, next) - must.Do(err) - must.Do(close()) // Windows wants the file handle to be closed to rename it. - - numWant := rn.Int63n(min(int64(len(want))-offset, 1000) + 1) - if offset < int64(len(want)) { - r = io.MultiReader(io.LimitReader(r, numWant), iotest.ErrReader(io.ErrClosedPipe)) - } - if _, err := m.PutFile("", "bar", r, offset, -1); err == nil { - break - } - if i > 1000 { - t.Fatalf("too many iterations to complete the test") - } - } - got := must.Get(os.ReadFile(must.Get(joinDir(m.opts.Dir, "bar")))) - if !bytes.Equal(got, want) { - t.Errorf("content mismatches") - } - }) -} diff --git a/taildrop/retrieve.go b/taildrop/retrieve.go index 3e37b492adc0a..e9ad600b372d5 100644 --- a/taildrop/retrieve.go +++ b/taildrop/retrieve.go @@ -14,8 +14,8 @@ import ( "sort" "time" - "tailscale.com/client/tailscale/apitype" - "tailscale.com/logtail/backoff" + "github.com/sagernet/tailscale/client/tailscale/apitype" + "github.com/sagernet/tailscale/logtail/backoff" ) // HasFilesWaiting reports whether any files are buffered in [Handler.Dir]. diff --git a/taildrop/send.go b/taildrop/send.go index 0dff71b2467e2..ba7ae55415045 100644 --- a/taildrop/send.go +++ b/taildrop/send.go @@ -12,10 +12,10 @@ import ( "sync" "time" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/tstime" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/version/distro" ) type incomingFileKey struct { diff --git a/taildrop/taildrop.go b/taildrop/taildrop.go index 4d14787afbf54..507c81d942209 100644 --- a/taildrop/taildrop.go +++ b/taildrop/taildrop.go @@ -25,11 +25,11 @@ import ( "unicode" "unicode/utf8" - "tailscale.com/ipn" - "tailscale.com/syncs" - "tailscale.com/tstime" - "tailscale.com/types/logger" - "tailscale.com/util/multierr" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/multierr" ) var ( diff --git a/taildrop/taildrop_test.go b/taildrop/taildrop_test.go deleted file mode 100644 index df4783c303203..0000000000000 --- a/taildrop/taildrop_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package taildrop - -import ( - "path/filepath" - "strings" - "testing" -) - -func TestJoinDir(t *testing.T) { - dir := t.TempDir() - tests := []struct { - in string - want string // just relative to m.Dir - wantOk bool - }{ - {"", "", false}, - {"foo", "foo", true}, - {"./foo", "", false}, - {"../foo", "", false}, - {"foo/bar", "", false}, - {"😋", "😋", true}, - {"\xde\xad\xbe\xef", "", false}, - {"foo.partial", "", false}, - {"foo.deleted", "", false}, - {strings.Repeat("a", 1024), "", false}, - {"foo:bar", "", false}, - } - for _, tt := range tests { - got, gotErr := joinDir(dir, tt.in) - got, _ = filepath.Rel(dir, got) - gotOk := gotErr == nil - if got != tt.want || gotOk != tt.wantOk { - t.Errorf("joinDir(%q) = (%v, %v), want (%v, %v)", tt.in, got, gotOk, tt.want, tt.wantOk) - } - } -} - -func TestNextFilename(t *testing.T) { - tests := []struct { - in string - want string - want2 string - }{ - {"foo", "foo (1)", "foo (2)"}, - {"foo(1)", "foo(1) (1)", "foo(1) (2)"}, - {"foo.tar", "foo (1).tar", "foo (2).tar"}, - {"foo.tar.gz", "foo (1).tar.gz", "foo (2).tar.gz"}, - {".bashrc", ".bashrc (1)", ".bashrc (2)"}, - {"fizz buzz.torrent", "fizz buzz (1).torrent", "fizz buzz (2).torrent"}, - {"rawr 2023.12.15.txt", "rawr 2023.12.15 (1).txt", "rawr 2023.12.15 (2).txt"}, - {"IMG_7934.JPEG", "IMG_7934 (1).JPEG", "IMG_7934 (2).JPEG"}, - {"my song.mp3", "my song (1).mp3", "my song (2).mp3"}, - {"archive.7z", "archive (1).7z", "archive (2).7z"}, - {"foo/bar/fizz", "foo/bar/fizz (1)", "foo/bar/fizz (2)"}, - {"新完全マスター N2 文法.pdf", "新完全マスター N2 文法 (1).pdf", "新完全マスター N2 文法 (2).pdf"}, - } - - for _, tt := range tests { - if got := NextFilename(tt.in); got != tt.want { - t.Errorf("NextFilename(%q) = %q, want %q", tt.in, got, tt.want) - } - if got2 := NextFilename(tt.want); got2 != tt.want2 { - t.Errorf("NextFilename(%q) = %q, want %q", tt.want, got2, tt.want2) - } - } -} diff --git a/tempfork/gliderlabs/ssh/context_test.go b/tempfork/gliderlabs/ssh/context_test.go deleted file mode 100644 index dcbd326b77809..0000000000000 --- a/tempfork/gliderlabs/ssh/context_test.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build glidertests - -package ssh - -import "testing" - -func TestSetPermissions(t *testing.T) { - t.Parallel() - permsExt := map[string]string{ - "foo": "bar", - } - session, _, cleanup := newTestSessionWithOptions(t, &Server{ - Handler: func(s Session) { - if _, ok := s.Permissions().Extensions["foo"]; !ok { - t.Fatalf("got %#v; want %#v", s.Permissions().Extensions, permsExt) - } - }, - }, nil, PasswordAuth(func(ctx Context, password string) bool { - ctx.Permissions().Extensions = permsExt - return true - })) - defer cleanup() - if err := session.Run(""); err != nil { - t.Fatal(err) - } -} - -func TestSetValue(t *testing.T) { - t.Parallel() - value := map[string]string{ - "foo": "bar", - } - key := "testValue" - session, _, cleanup := newTestSessionWithOptions(t, &Server{ - Handler: func(s Session) { - v := s.Context().Value(key).(map[string]string) - if v["foo"] != value["foo"] { - t.Fatalf("got %#v; want %#v", v, value) - } - }, - }, nil, PasswordAuth(func(ctx Context, password string) bool { - ctx.SetValue(key, value) - return true - })) - defer cleanup() - if err := session.Run(""); err != nil { - t.Fatal(err) - } -} diff --git a/tempfork/gliderlabs/ssh/example_test.go b/tempfork/gliderlabs/ssh/example_test.go deleted file mode 100644 index c174bc4ae190e..0000000000000 --- a/tempfork/gliderlabs/ssh/example_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package ssh_test - -import ( - "errors" - "io" - "os" - - "tailscale.com/tempfork/gliderlabs/ssh" -) - -func ExampleListenAndServe() { - ssh.ListenAndServe(":2222", func(s ssh.Session) { - io.WriteString(s, "Hello world\n") - }) -} - -func ExamplePasswordAuth() { - ssh.ListenAndServe(":2222", nil, - ssh.PasswordAuth(func(ctx ssh.Context, pass string) bool { - return pass == "secret" - }), - ) -} - -func ExampleNoPty() { - ssh.ListenAndServe(":2222", nil, ssh.NoPty()) -} - -func ExamplePublicKeyAuth() { - ssh.ListenAndServe(":2222", nil, - ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) error { - data, err := os.ReadFile("/path/to/allowed/key.pub") - if err != nil { - return err - } - allowed, _, _, _, err := ssh.ParseAuthorizedKey(data) - if err != nil { - return err - } - if !ssh.KeysEqual(key, allowed) { - return errors.New("some error") - } - return nil - }), - ) -} - -func ExampleHostKeyFile() { - ssh.ListenAndServe(":2222", nil, ssh.HostKeyFile("/path/to/host/key")) -} diff --git a/tempfork/gliderlabs/ssh/options_test.go b/tempfork/gliderlabs/ssh/options_test.go deleted file mode 100644 index 7cf6f376c6a88..0000000000000 --- a/tempfork/gliderlabs/ssh/options_test.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build glidertests - -package ssh - -import ( - "net" - "strings" - "sync/atomic" - "testing" - - gossh "github.com/tailscale/golang-x-crypto/ssh" -) - -func newTestSessionWithOptions(t *testing.T, srv *Server, cfg *gossh.ClientConfig, options ...Option) (*gossh.Session, *gossh.Client, func()) { - for _, option := range options { - if err := srv.SetOption(option); err != nil { - t.Fatal(err) - } - } - return newTestSession(t, srv, cfg) -} - -func TestPasswordAuth(t *testing.T) { - t.Parallel() - testUser := "testuser" - testPass := "testpass" - session, _, cleanup := newTestSessionWithOptions(t, &Server{ - Handler: func(s Session) { - // noop - }, - }, &gossh.ClientConfig{ - User: testUser, - Auth: []gossh.AuthMethod{ - gossh.Password(testPass), - }, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - }, PasswordAuth(func(ctx Context, password string) bool { - if ctx.User() != testUser { - t.Fatalf("user = %#v; want %#v", ctx.User(), testUser) - } - if password != testPass { - t.Fatalf("user = %#v; want %#v", password, testPass) - } - return true - })) - defer cleanup() - if err := session.Run(""); err != nil { - t.Fatal(err) - } -} - -func TestPasswordAuthBadPass(t *testing.T) { - t.Parallel() - l := newLocalListener() - srv := &Server{Handler: func(s Session) {}} - srv.SetOption(PasswordAuth(func(ctx Context, password string) bool { - return false - })) - go srv.serveOnce(l) - _, err := gossh.Dial("tcp", l.Addr().String(), &gossh.ClientConfig{ - User: "testuser", - Auth: []gossh.AuthMethod{ - gossh.Password("testpass"), - }, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - }) - if err != nil { - if !strings.Contains(err.Error(), "unable to authenticate") { - t.Fatal(err) - } - } -} - -type wrappedConn struct { - net.Conn - written int32 -} - -func (c *wrappedConn) Write(p []byte) (n int, err error) { - n, err = c.Conn.Write(p) - atomic.AddInt32(&(c.written), int32(n)) - return -} - -func TestConnWrapping(t *testing.T) { - t.Parallel() - var wrapped *wrappedConn - session, _, cleanup := newTestSessionWithOptions(t, &Server{ - Handler: func(s Session) { - // nothing - }, - }, &gossh.ClientConfig{ - User: "testuser", - Auth: []gossh.AuthMethod{ - gossh.Password("testpass"), - }, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - }, PasswordAuth(func(ctx Context, password string) bool { - return true - }), WrapConn(func(ctx Context, conn net.Conn) net.Conn { - wrapped = &wrappedConn{conn, 0} - return wrapped - })) - defer cleanup() - if err := session.Shell(); err != nil { - t.Fatal(err) - } - if atomic.LoadInt32(&(wrapped.written)) == 0 { - t.Fatal("wrapped conn not written to") - } -} diff --git a/tempfork/gliderlabs/ssh/server_test.go b/tempfork/gliderlabs/ssh/server_test.go deleted file mode 100644 index 177c071170c4e..0000000000000 --- a/tempfork/gliderlabs/ssh/server_test.go +++ /dev/null @@ -1,128 +0,0 @@ -//go:build glidertests - -package ssh - -import ( - "bytes" - "context" - "io" - "testing" - "time" -) - -func TestAddHostKey(t *testing.T) { - s := Server{} - signer, err := generateSigner() - if err != nil { - t.Fatal(err) - } - s.AddHostKey(signer) - if len(s.HostSigners) != 1 { - t.Fatal("Key was not properly added") - } - signer, err = generateSigner() - if err != nil { - t.Fatal(err) - } - s.AddHostKey(signer) - if len(s.HostSigners) != 1 { - t.Fatal("Key was not properly replaced") - } -} - -func TestServerShutdown(t *testing.T) { - l := newLocalListener() - testBytes := []byte("Hello world\n") - s := &Server{ - Handler: func(s Session) { - s.Write(testBytes) - time.Sleep(50 * time.Millisecond) - }, - } - go func() { - err := s.Serve(l) - if err != nil && err != ErrServerClosed { - t.Fatal(err) - } - }() - sessDone := make(chan struct{}) - sess, _, cleanup := newClientSession(t, l.Addr().String(), nil) - go func() { - defer cleanup() - defer close(sessDone) - var stdout bytes.Buffer - sess.Stdout = &stdout - if err := sess.Run(""); err != nil { - t.Fatal(err) - } - if !bytes.Equal(stdout.Bytes(), testBytes) { - t.Fatalf("expected = %s; got %s", testBytes, stdout.Bytes()) - } - }() - - srvDone := make(chan struct{}) - go func() { - defer close(srvDone) - err := s.Shutdown(context.Background()) - if err != nil { - t.Fatal(err) - } - }() - - timeout := time.After(2 * time.Second) - select { - case <-timeout: - t.Fatal("timeout") - return - case <-srvDone: - // TODO: add timeout for sessDone - <-sessDone - return - } -} - -func TestServerClose(t *testing.T) { - l := newLocalListener() - s := &Server{ - Handler: func(s Session) { - time.Sleep(5 * time.Second) - }, - } - go func() { - err := s.Serve(l) - if err != nil && err != ErrServerClosed { - t.Fatal(err) - } - }() - - clientDoneChan := make(chan struct{}) - closeDoneChan := make(chan struct{}) - - sess, _, cleanup := newClientSession(t, l.Addr().String(), nil) - go func() { - defer cleanup() - defer close(clientDoneChan) - <-closeDoneChan - if err := sess.Run(""); err != nil && err != io.EOF { - t.Fatal(err) - } - }() - - go func() { - err := s.Close() - if err != nil { - t.Fatal(err) - } - close(closeDoneChan) - }() - - timeout := time.After(100 * time.Millisecond) - select { - case <-timeout: - t.Error("timeout") - return - case <-s.getDoneChan(): - <-clientDoneChan - return - } -} diff --git a/tempfork/gliderlabs/ssh/session_test.go b/tempfork/gliderlabs/ssh/session_test.go deleted file mode 100644 index a60be5ec12d4e..0000000000000 --- a/tempfork/gliderlabs/ssh/session_test.go +++ /dev/null @@ -1,440 +0,0 @@ -//go:build glidertests - -package ssh - -import ( - "bytes" - "fmt" - "io" - "net" - "testing" - - gossh "github.com/tailscale/golang-x-crypto/ssh" -) - -func (srv *Server) serveOnce(l net.Listener) error { - srv.ensureHandlers() - if err := srv.ensureHostSigner(); err != nil { - return err - } - conn, e := l.Accept() - if e != nil { - return e - } - srv.ChannelHandlers = map[string]ChannelHandler{ - "session": DefaultSessionHandler, - "direct-tcpip": DirectTCPIPHandler, - } - srv.HandleConn(conn) - return nil -} - -func newLocalListener() net.Listener { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - if l, err = net.Listen("tcp6", "[::1]:0"); err != nil { - panic(fmt.Sprintf("failed to listen on a port: %v", err)) - } - } - return l -} - -func newClientSession(t *testing.T, addr string, config *gossh.ClientConfig) (*gossh.Session, *gossh.Client, func()) { - if config == nil { - config = &gossh.ClientConfig{ - User: "testuser", - Auth: []gossh.AuthMethod{ - gossh.Password("testpass"), - }, - } - } - if config.HostKeyCallback == nil { - config.HostKeyCallback = gossh.InsecureIgnoreHostKey() - } - client, err := gossh.Dial("tcp", addr, config) - if err != nil { - t.Fatal(err) - } - session, err := client.NewSession() - if err != nil { - t.Fatal(err) - } - return session, client, func() { - session.Close() - client.Close() - } -} - -func newTestSession(t *testing.T, srv *Server, cfg *gossh.ClientConfig) (*gossh.Session, *gossh.Client, func()) { - l := newLocalListener() - go srv.serveOnce(l) - return newClientSession(t, l.Addr().String(), cfg) -} - -func TestStdout(t *testing.T) { - t.Parallel() - testBytes := []byte("Hello world\n") - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - s.Write(testBytes) - }, - }, nil) - defer cleanup() - var stdout bytes.Buffer - session.Stdout = &stdout - if err := session.Run(""); err != nil { - t.Fatal(err) - } - if !bytes.Equal(stdout.Bytes(), testBytes) { - t.Fatalf("stdout = %#v; want %#v", stdout.Bytes(), testBytes) - } -} - -func TestStderr(t *testing.T) { - t.Parallel() - testBytes := []byte("Hello world\n") - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - s.Stderr().Write(testBytes) - }, - }, nil) - defer cleanup() - var stderr bytes.Buffer - session.Stderr = &stderr - if err := session.Run(""); err != nil { - t.Fatal(err) - } - if !bytes.Equal(stderr.Bytes(), testBytes) { - t.Fatalf("stderr = %#v; want %#v", stderr.Bytes(), testBytes) - } -} - -func TestStdin(t *testing.T) { - t.Parallel() - testBytes := []byte("Hello world\n") - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - io.Copy(s, s) // stdin back into stdout - }, - }, nil) - defer cleanup() - var stdout bytes.Buffer - session.Stdout = &stdout - session.Stdin = bytes.NewBuffer(testBytes) - if err := session.Run(""); err != nil { - t.Fatal(err) - } - if !bytes.Equal(stdout.Bytes(), testBytes) { - t.Fatalf("stdout = %#v; want %#v given stdin = %#v", stdout.Bytes(), testBytes, testBytes) - } -} - -func TestUser(t *testing.T) { - t.Parallel() - testUser := []byte("progrium") - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - io.WriteString(s, s.User()) - }, - }, &gossh.ClientConfig{ - User: string(testUser), - }) - defer cleanup() - var stdout bytes.Buffer - session.Stdout = &stdout - if err := session.Run(""); err != nil { - t.Fatal(err) - } - if !bytes.Equal(stdout.Bytes(), testUser) { - t.Fatalf("stdout = %#v; want %#v given user = %#v", stdout.Bytes(), testUser, string(testUser)) - } -} - -func TestDefaultExitStatusZero(t *testing.T) { - t.Parallel() - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - // noop - }, - }, nil) - defer cleanup() - err := session.Run("") - if err != nil { - t.Fatalf("expected nil but got %v", err) - } -} - -func TestExplicitExitStatusZero(t *testing.T) { - t.Parallel() - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - s.Exit(0) - }, - }, nil) - defer cleanup() - err := session.Run("") - if err != nil { - t.Fatalf("expected nil but got %v", err) - } -} - -func TestExitStatusNonZero(t *testing.T) { - t.Parallel() - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - s.Exit(1) - }, - }, nil) - defer cleanup() - err := session.Run("") - e, ok := err.(*gossh.ExitError) - if !ok { - t.Fatalf("expected ExitError but got %T", err) - } - if e.ExitStatus() != 1 { - t.Fatalf("exit-status = %#v; want %#v", e.ExitStatus(), 1) - } -} - -func TestPty(t *testing.T) { - t.Parallel() - term := "xterm" - winWidth := 40 - winHeight := 80 - done := make(chan bool) - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - ptyReq, _, isPty := s.Pty() - if !isPty { - t.Fatalf("expected pty but none requested") - } - if ptyReq.Term != term { - t.Fatalf("expected term %#v but got %#v", term, ptyReq.Term) - } - if ptyReq.Window.Width != winWidth { - t.Fatalf("expected window width %#v but got %#v", winWidth, ptyReq.Window.Width) - } - if ptyReq.Window.Height != winHeight { - t.Fatalf("expected window height %#v but got %#v", winHeight, ptyReq.Window.Height) - } - close(done) - }, - }, nil) - defer cleanup() - if err := session.RequestPty(term, winHeight, winWidth, gossh.TerminalModes{}); err != nil { - t.Fatalf("expected nil but got %v", err) - } - if err := session.Shell(); err != nil { - t.Fatalf("expected nil but got %v", err) - } - <-done -} - -func TestPtyResize(t *testing.T) { - t.Parallel() - winch0 := Window{Width: 40, Height: 80} - winch1 := Window{Width: 80, Height: 160} - winch2 := Window{Width: 20, Height: 40} - winches := make(chan Window) - done := make(chan bool) - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - ptyReq, winCh, isPty := s.Pty() - if !isPty { - t.Fatalf("expected pty but none requested") - } - if ptyReq.Window != winch0 { - t.Fatalf("expected window %#v but got %#v", winch0, ptyReq.Window) - } - for win := range winCh { - winches <- win - } - close(done) - }, - }, nil) - defer cleanup() - // winch0 - if err := session.RequestPty("xterm", winch0.Height, winch0.Width, gossh.TerminalModes{}); err != nil { - t.Fatalf("expected nil but got %v", err) - } - if err := session.Shell(); err != nil { - t.Fatalf("expected nil but got %v", err) - } - gotWinch := <-winches - if gotWinch != winch0 { - t.Fatalf("expected window %#v but got %#v", winch0, gotWinch) - } - // winch1 - winchMsg := struct{ w, h uint32 }{uint32(winch1.Width), uint32(winch1.Height)} - ok, err := session.SendRequest("window-change", true, gossh.Marshal(&winchMsg)) - if err == nil && !ok { - t.Fatalf("unexpected error or bad reply on send request") - } - gotWinch = <-winches - if gotWinch != winch1 { - t.Fatalf("expected window %#v but got %#v", winch1, gotWinch) - } - // winch2 - winchMsg = struct{ w, h uint32 }{uint32(winch2.Width), uint32(winch2.Height)} - ok, err = session.SendRequest("window-change", true, gossh.Marshal(&winchMsg)) - if err == nil && !ok { - t.Fatalf("unexpected error or bad reply on send request") - } - gotWinch = <-winches - if gotWinch != winch2 { - t.Fatalf("expected window %#v but got %#v", winch2, gotWinch) - } - session.Close() - <-done -} - -func TestSignals(t *testing.T) { - t.Parallel() - - // errChan lets us get errors back from the session - errChan := make(chan error, 5) - - // doneChan lets us specify that we should exit. - doneChan := make(chan interface{}) - - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - // We need to use a buffered channel here, otherwise it's possible for the - // second call to Signal to get discarded. - signals := make(chan Signal, 2) - s.Signals(signals) - - select { - case sig := <-signals: - if sig != SIGINT { - errChan <- fmt.Errorf("expected signal %v but got %v", SIGINT, sig) - return - } - case <-doneChan: - errChan <- fmt.Errorf("Unexpected done") - return - } - - select { - case sig := <-signals: - if sig != SIGKILL { - errChan <- fmt.Errorf("expected signal %v but got %v", SIGKILL, sig) - return - } - case <-doneChan: - errChan <- fmt.Errorf("Unexpected done") - return - } - }, - }, nil) - defer cleanup() - - go func() { - session.Signal(gossh.SIGINT) - session.Signal(gossh.SIGKILL) - }() - - go func() { - errChan <- session.Run("") - }() - - err := <-errChan - close(doneChan) - - if err != nil { - t.Fatalf("expected nil but got %v", err) - } -} - -func TestBreakWithChanRegistered(t *testing.T) { - t.Parallel() - - // errChan lets us get errors back from the session - errChan := make(chan error, 5) - - // doneChan lets us specify that we should exit. - doneChan := make(chan interface{}) - - breakChan := make(chan bool) - - readyToReceiveBreak := make(chan bool) - - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - s.Break(breakChan) // register a break channel with the session - readyToReceiveBreak <- true - - select { - case <-breakChan: - io.WriteString(s, "break") - case <-doneChan: - errChan <- fmt.Errorf("Unexpected done") - return - } - }, - }, nil) - defer cleanup() - var stdout bytes.Buffer - session.Stdout = &stdout - go func() { - errChan <- session.Run("") - }() - - <-readyToReceiveBreak - ok, err := session.SendRequest("break", true, nil) - if err != nil { - t.Fatalf("expected nil but got %v", err) - } - if ok != true { - t.Fatalf("expected true but got %v", ok) - } - - err = <-errChan - close(doneChan) - - if err != nil { - t.Fatalf("expected nil but got %v", err) - } - if !bytes.Equal(stdout.Bytes(), []byte("break")) { - t.Fatalf("stdout = %#v, expected 'break'", stdout.Bytes()) - } -} - -func TestBreakWithoutChanRegistered(t *testing.T) { - t.Parallel() - - // errChan lets us get errors back from the session - errChan := make(chan error, 5) - - // doneChan lets us specify that we should exit. - doneChan := make(chan interface{}) - - waitUntilAfterBreakSent := make(chan bool) - - session, _, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) { - <-waitUntilAfterBreakSent - }, - }, nil) - defer cleanup() - var stdout bytes.Buffer - session.Stdout = &stdout - go func() { - errChan <- session.Run("") - }() - - ok, err := session.SendRequest("break", true, nil) - if err != nil { - t.Fatalf("expected nil but got %v", err) - } - if ok != false { - t.Fatalf("expected false but got %v", ok) - } - waitUntilAfterBreakSent <- true - - err = <-errChan - close(doneChan) - if err != nil { - t.Fatalf("expected nil but got %v", err) - } -} diff --git a/tempfork/gliderlabs/ssh/ssh_test.go b/tempfork/gliderlabs/ssh/ssh_test.go deleted file mode 100644 index aa301b0489f21..0000000000000 --- a/tempfork/gliderlabs/ssh/ssh_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package ssh - -import ( - "testing" -) - -func TestKeysEqual(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Errorf("The code did panic") - } - }() - - if KeysEqual(nil, nil) { - t.Error("two nil keys should not return true") - } -} diff --git a/tempfork/gliderlabs/ssh/tcpip_test.go b/tempfork/gliderlabs/ssh/tcpip_test.go deleted file mode 100644 index 118b5d53ac4a1..0000000000000 --- a/tempfork/gliderlabs/ssh/tcpip_test.go +++ /dev/null @@ -1,85 +0,0 @@ -//go:build glidertests - -package ssh - -import ( - "bytes" - "io" - "net" - "strconv" - "strings" - "testing" - - gossh "github.com/tailscale/golang-x-crypto/ssh" -) - -var sampleServerResponse = []byte("Hello world") - -func sampleSocketServer() net.Listener { - l := newLocalListener() - - go func() { - conn, err := l.Accept() - if err != nil { - return - } - conn.Write(sampleServerResponse) - conn.Close() - }() - - return l -} - -func newTestSessionWithForwarding(t *testing.T, forwardingEnabled bool) (net.Listener, *gossh.Client, func()) { - l := sampleSocketServer() - - _, client, cleanup := newTestSession(t, &Server{ - Handler: func(s Session) {}, - LocalPortForwardingCallback: func(ctx Context, destinationHost string, destinationPort uint32) bool { - addr := net.JoinHostPort(destinationHost, strconv.FormatInt(int64(destinationPort), 10)) - if addr != l.Addr().String() { - panic("unexpected destinationHost: " + addr) - } - return forwardingEnabled - }, - }, nil) - - return l, client, func() { - cleanup() - l.Close() - } -} - -func TestLocalPortForwardingWorks(t *testing.T) { - t.Parallel() - - l, client, cleanup := newTestSessionWithForwarding(t, true) - defer cleanup() - - conn, err := client.Dial("tcp", l.Addr().String()) - if err != nil { - t.Fatalf("Error connecting to %v: %v", l.Addr().String(), err) - } - result, err := io.ReadAll(conn) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(result, sampleServerResponse) { - t.Fatalf("result = %#v; want %#v", result, sampleServerResponse) - } -} - -func TestLocalPortForwardingRespectsCallback(t *testing.T) { - t.Parallel() - - l, client, cleanup := newTestSessionWithForwarding(t, false) - defer cleanup() - - _, err := client.Dial("tcp", l.Addr().String()) - if err == nil { - t.Fatalf("Expected error connecting to %v but it succeeded", l.Addr().String()) - } - if !strings.Contains(err.Error(), "port forwarding is disabled") { - t.Fatalf("Expected permission error but got %#v", err) - } -} diff --git a/tempfork/heap/heap_test.go b/tempfork/heap/heap_test.go deleted file mode 100644 index ddaf47a7f59e0..0000000000000 --- a/tempfork/heap/heap_test.go +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package heap - -import ( - "math/rand" - "testing" - - "golang.org/x/exp/constraints" -) - -type myHeap[T constraints.Ordered] []T - -func (h *myHeap[T]) Less(i, j int) bool { - return (*h)[i] < (*h)[j] -} - -func (h *myHeap[T]) Swap(i, j int) { - (*h)[i], (*h)[j] = (*h)[j], (*h)[i] -} - -func (h *myHeap[T]) Len() int { - return len(*h) -} - -func (h *myHeap[T]) Pop() (v T) { - *h, v = (*h)[:h.Len()-1], (*h)[h.Len()-1] - return -} - -func (h *myHeap[T]) Push(v T) { - *h = append(*h, v) -} - -func (h myHeap[T]) verify(t *testing.T, i int) { - t.Helper() - n := h.Len() - j1 := 2*i + 1 - j2 := 2*i + 2 - if j1 < n { - if h.Less(j1, i) { - t.Errorf("heap invariant invalidated [%d] = %v > [%d] = %v", i, h[i], j1, h[j1]) - return - } - h.verify(t, j1) - } - if j2 < n { - if h.Less(j2, i) { - t.Errorf("heap invariant invalidated [%d] = %v > [%d] = %v", i, h[i], j1, h[j2]) - return - } - h.verify(t, j2) - } -} - -func TestInit0(t *testing.T) { - h := new(myHeap[int]) - for i := 20; i > 0; i-- { - h.Push(0) // all elements are the same - } - Init[int](h) - h.verify(t, 0) - - for i := 1; h.Len() > 0; i++ { - x := Pop[int](h) - h.verify(t, 0) - if x != 0 { - t.Errorf("%d.th pop got %d; want %d", i, x, 0) - } - } -} - -func TestInit1(t *testing.T) { - h := new(myHeap[int]) - for i := 20; i > 0; i-- { - h.Push(i) // all elements are different - } - Init[int](h) - h.verify(t, 0) - - for i := 1; h.Len() > 0; i++ { - x := Pop[int](h) - h.verify(t, 0) - if x != i { - t.Errorf("%d.th pop got %d; want %d", i, x, i) - } - } -} - -func Test(t *testing.T) { - h := new(myHeap[int]) - h.verify(t, 0) - - for i := 20; i > 10; i-- { - h.Push(i) - } - Init[int](h) - h.verify(t, 0) - - for i := 10; i > 0; i-- { - Push[int](h, i) - h.verify(t, 0) - } - - for i := 1; h.Len() > 0; i++ { - x := Pop[int](h) - if i < 20 { - Push[int](h, 20+i) - } - h.verify(t, 0) - if x != i { - t.Errorf("%d.th pop got %d; want %d", i, x, i) - } - } -} - -func TestRemove0(t *testing.T) { - h := new(myHeap[int]) - for i := range 10 { - h.Push(i) - } - h.verify(t, 0) - - for h.Len() > 0 { - i := h.Len() - 1 - x := Remove[int](h, i) - if x != i { - t.Errorf("Remove(%d) got %d; want %d", i, x, i) - } - h.verify(t, 0) - } -} - -func TestRemove1(t *testing.T) { - h := new(myHeap[int]) - for i := range 10 { - h.Push(i) - } - h.verify(t, 0) - - for i := 0; h.Len() > 0; i++ { - x := Remove[int](h, 0) - if x != i { - t.Errorf("Remove(0) got %d; want %d", x, i) - } - h.verify(t, 0) - } -} - -func TestRemove2(t *testing.T) { - N := 10 - - h := new(myHeap[int]) - for i := range N { - h.Push(i) - } - h.verify(t, 0) - - m := make(map[int]bool) - for h.Len() > 0 { - m[Remove[int](h, (h.Len()-1)/2)] = true - h.verify(t, 0) - } - - if len(m) != N { - t.Errorf("len(m) = %d; want %d", len(m), N) - } - for i := range len(m) { - if !m[i] { - t.Errorf("m[%d] doesn't exist", i) - } - } -} - -func BenchmarkDup(b *testing.B) { - const n = 10000 - h := make(myHeap[int], 0, n) - for range b.N { - for j := 0; j < n; j++ { - Push[int](&h, 0) // all elements are the same - } - for h.Len() > 0 { - Pop[int](&h) - } - } -} - -func TestFix(t *testing.T) { - h := new(myHeap[int]) - h.verify(t, 0) - - for i := 200; i > 0; i -= 10 { - Push[int](h, i) - } - h.verify(t, 0) - - if (*h)[0] != 10 { - t.Fatalf("Expected head to be 10, was %d", (*h)[0]) - } - (*h)[0] = 210 - Fix[int](h, 0) - h.verify(t, 0) - - for i := 100; i > 0; i-- { - elem := rand.Intn(h.Len()) - if i&1 == 0 { - (*h)[elem] *= 2 - } else { - (*h)[elem] /= 2 - } - Fix[int](h, elem) - h.verify(t, 0) - } -} diff --git a/tka/aum.go b/tka/aum.go index 07a34b4f62458..3e06e6b7f6842 100644 --- a/tka/aum.go +++ b/tka/aum.go @@ -11,9 +11,9 @@ import ( "fmt" "github.com/fxamacker/cbor/v2" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/util/set" "golang.org/x/crypto/blake2s" - "tailscale.com/types/tkatype" - "tailscale.com/util/set" ) // AUMHash represents the BLAKE2s digest of an Authority Update Message (AUM). diff --git a/tka/aum_test.go b/tka/aum_test.go deleted file mode 100644 index 4297efabff13f..0000000000000 --- a/tka/aum_test.go +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "bytes" - "testing" - - "github.com/google/go-cmp/cmp" - "golang.org/x/crypto/blake2s" - "tailscale.com/types/tkatype" -) - -func TestSerialization(t *testing.T) { - uint2 := uint(2) - var fakeAUMHash AUMHash - - tcs := []struct { - Name string - AUM AUM - Expect []byte - }{ - { - "AddKey", - AUM{MessageKind: AUMAddKey, Key: &Key{}}, - []byte{ - 0xa3, // major type 5 (map), 3 items - 0x01, // |- major type 0 (int), value 1 (first key, MessageKind) - 0x01, // |- major type 0 (int), value 1 (first value, AUMAddKey) - 0x02, // |- major type 0 (int), value 2 (second key, PrevAUMHash) - 0xf6, // |- major type 7 (val), value null (second value, nil) - 0x03, // |- major type 0 (int), value 3 (third key, Key) - 0xa3, // |- major type 5 (map), 3 items (type Key) - 0x01, // |- major type 0 (int), value 1 (first key, Kind) - 0x00, // |- major type 0 (int), value 0 (first value) - 0x02, // |- major type 0 (int), value 2 (second key, Votes) - 0x00, // |- major type 0 (int), value 0 (first value) - 0x03, // |- major type 0 (int), value 3 (third key, Public) - 0xf6, // |- major type 7 (val), value null (third value, nil) - }, - }, - { - "RemoveKey", - AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}}, - []byte{ - 0xa3, // major type 5 (map), 3 items - 0x01, // |- major type 0 (int), value 1 (first key, MessageKind) - 0x02, // |- major type 0 (int), value 2 (first value, AUMRemoveKey) - 0x02, // |- major type 0 (int), value 2 (second key, PrevAUMHash) - 0xf6, // |- major type 7 (val), value null (second value, nil) - 0x04, // |- major type 0 (int), value 4 (third key, KeyID) - 0x42, // |- major type 2 (byte string), 2 items - 0x01, // |- major type 0 (int), value 1 (byte 1) - 0x02, // |- major type 0 (int), value 2 (byte 2) - }, - }, - { - "UpdateKey", - AUM{MessageKind: AUMUpdateKey, Votes: &uint2, KeyID: []byte{1, 2}, Meta: map[string]string{"a": "b"}}, - []byte{ - 0xa5, // major type 5 (map), 5 items - 0x01, // |- major type 0 (int), value 1 (first key, MessageKind) - 0x04, // |- major type 0 (int), value 4 (first value, AUMUpdateKey) - 0x02, // |- major type 0 (int), value 2 (second key, PrevAUMHash) - 0xf6, // |- major type 7 (val), value null (second value, nil) - 0x04, // |- major type 0 (int), value 4 (third key, KeyID) - 0x42, // |- major type 2 (byte string), 2 items - 0x01, // |- major type 0 (int), value 1 (byte 1) - 0x02, // |- major type 0 (int), value 2 (byte 2) - 0x06, // |- major type 0 (int), value 6 (fourth key, Votes) - 0x02, // |- major type 0 (int), value 2 (forth value, 2) - 0x07, // |- major type 0 (int), value 7 (fifth key, Meta) - 0xa1, // |- major type 5 (map), 1 item (map[string]string type) - 0x61, // |- major type 3 (text string), value 1 (first key, one byte long) - 0x61, // |- byte 'a' - 0x61, // |- major type 3 (text string), value 1 (first value, one byte long) - 0x62, // |- byte 'b' - }, - }, - { - "Checkpoint", - AUM{MessageKind: AUMCheckpoint, PrevAUMHash: []byte{1, 2}, State: &State{ - LastAUMHash: &fakeAUMHash, - Keys: []Key{ - {Kind: Key25519, Public: []byte{5, 6}}, - }, - }}, - append( - append([]byte{ - 0xa3, // major type 5 (map), 3 items - 0x01, // |- major type 0 (int), value 1 (first key, MessageKind) - 0x05, // |- major type 0 (int), value 5 (first value, AUMCheckpoint) - 0x02, // |- major type 0 (int), value 2 (second key, PrevAUMHash) - 0x42, // |- major type 2 (byte string), 2 items (second value) - 0x01, // |- major type 0 (int), value 1 (byte 1) - 0x02, // |- major type 0 (int), value 2 (byte 2) - 0x05, // |- major type 0 (int), value 5 (third key, State) - 0xa3, // |- major type 5 (map), 3 items (third value, State type) - 0x01, // |- major type 0 (int), value 1 (first key, LastAUMHash) - 0x58, 0x20, // |- major type 2 (byte string), 32 items (first value) - }, - bytes.Repeat([]byte{0}, 32)...), - []byte{ - 0x02, // |- major type 0 (int), value 2 (second key, DisablementSecrets) - 0xf6, // |- major type 7 (val), value null (second value, nil) - 0x03, // |- major type 0 (int), value 3 (third key, Keys) - 0x81, // |- major type 4 (array), value 1 (one item in array) - 0xa3, // |- major type 5 (map), 3 items (Key type) - 0x01, // |- major type 0 (int), value 1 (first key, Kind) - 0x01, // |- major type 0 (int), value 1 (first value, Key25519) - 0x02, // |- major type 0 (int), value 2 (second key, Votes) - 0x00, // |- major type 0 (int), value 0 (second value, 0) - 0x03, // |- major type 0 (int), value 3 (third key, Public) - 0x42, // |- major type 2 (byte string), 2 items (third value) - 0x05, // |- major type 0 (int), value 5 (byte 5) - 0x06, // |- major type 0 (int), value 6 (byte 6) - }...), - }, - { - "Signature", - AUM{MessageKind: AUMAddKey, Signatures: []tkatype.Signature{{KeyID: []byte{1}}}}, - []byte{ - 0xa3, // major type 5 (map), 3 items - 0x01, // |- major type 0 (int), value 1 (first key, MessageKind) - 0x01, // |- major type 0 (int), value 1 (first value, AUMAddKey) - 0x02, // |- major type 0 (int), value 2 (second key, PrevAUMHash) - 0xf6, // |- major type 7 (val), value null (second value, nil) - 0x17, // |- major type 0 (int), value 22 (third key, Signatures) - 0x81, // |- major type 4 (array), value 1 (one item in array) - 0xa2, // |- major type 5 (map), 2 items (Signature type) - 0x01, // |- major type 0 (int), value 1 (first key, KeyID) - 0x41, // |- major type 2 (byte string), 1 item - 0x01, // |- major type 0 (int), value 1 (byte 1) - 0x02, // |- major type 0 (int), value 2 (second key, Signature) - 0xf6, // |- major type 7 (val), value null (second value, nil) - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.Name, func(t *testing.T) { - data := []byte(tc.AUM.Serialize()) - if diff := cmp.Diff(tc.Expect, data); diff != "" { - t.Errorf("serialization differs (-want, +got):\n%s", diff) - } - - var decodedAUM AUM - if err := decodedAUM.Unserialize(data); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - if diff := cmp.Diff(tc.AUM, decodedAUM); diff != "" { - t.Errorf("unmarshalled version differs (-want, +got):\n%s", diff) - } - }) - } -} - -func TestAUMWeight(t *testing.T) { - var fakeKeyID [blake2s.Size]byte - testingRand(t, 1).Read(fakeKeyID[:]) - - pub, _ := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - pub, _ = testingKey25519(t, 2) - key2 := Key{Kind: Key25519, Public: pub, Votes: 2} - - tcs := []struct { - Name string - AUM AUM - State State - Want uint - }{ - { - "Empty", - AUM{}, - State{}, - 0, - }, - { - "Key unknown", - AUM{ - Signatures: []tkatype.Signature{{KeyID: fakeKeyID[:]}}, - }, - State{}, - 0, - }, - { - "Unary key", - AUM{ - Signatures: []tkatype.Signature{{KeyID: key.MustID()}}, - }, - State{ - Keys: []Key{key}, - }, - 2, - }, - { - "Multiple keys", - AUM{ - Signatures: []tkatype.Signature{{KeyID: key.MustID()}, {KeyID: key2.MustID()}}, - }, - State{ - Keys: []Key{key, key2}, - }, - 4, - }, - { - "Double use", - AUM{ - Signatures: []tkatype.Signature{{KeyID: key.MustID()}, {KeyID: key.MustID()}}, - }, - State{ - Keys: []Key{key}, - }, - 2, - }, - } - - for _, tc := range tcs { - t.Run(tc.Name, func(t *testing.T) { - got := tc.AUM.Weight(tc.State) - if got != tc.Want { - t.Errorf("Weight() = %d, want %d", got, tc.Want) - } - }) - } -} - -func TestAUMHashes(t *testing.T) { - // .Hash(): a hash over everything. - // .SigHash(): a hash over everything except the signatures. - // The signatures are over a hash of the AUM, so - // using SigHash() breaks this circularity. - - aum := AUM{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519}} - sigHash1 := aum.SigHash() - aumHash1 := aum.Hash() - - aum.Signatures = []tkatype.Signature{{KeyID: []byte{1, 2, 3, 4}}} - sigHash2 := aum.SigHash() - aumHash2 := aum.Hash() - if len(aum.Signatures) != 1 { - t.Error("signature was removed by one of the hash functions") - } - - if !bytes.Equal(sigHash1[:], sigHash1[:]) { - t.Errorf("signature hash dependent on signatures!\n\t1 = %x\n\t2 = %x", sigHash1, sigHash2) - } - if bytes.Equal(aumHash1[:], aumHash2[:]) { - t.Error("aum hash didnt change") - } -} diff --git a/tka/builder.go b/tka/builder.go index c14ba2330ae0d..28ec369aba108 100644 --- a/tka/builder.go +++ b/tka/builder.go @@ -7,7 +7,7 @@ import ( "fmt" "os" - "tailscale.com/types/tkatype" + "github.com/sagernet/tailscale/types/tkatype" ) // Types implementing Signer can sign update messages. diff --git a/tka/builder_test.go b/tka/builder_test.go deleted file mode 100644 index 666af9ad07daf..0000000000000 --- a/tka/builder_test.go +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "crypto/ed25519" - "testing" - - "github.com/google/go-cmp/cmp" - "tailscale.com/types/tkatype" -) - -type signer25519 ed25519.PrivateKey - -func (s signer25519) SignAUM(sigHash tkatype.AUMSigHash) ([]tkatype.Signature, error) { - priv := ed25519.PrivateKey(s) - key := Key{Kind: Key25519, Public: priv.Public().(ed25519.PublicKey)} - - return []tkatype.Signature{{ - KeyID: key.MustID(), - Signature: ed25519.Sign(priv, sigHash[:]), - }}, nil -} - -func TestAuthorityBuilderAddKey(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - storage := &Mem{} - a, _, err := Create(storage, State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, signer25519(priv)) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - pub2, _ := testingKey25519(t, 2) - key2 := Key{Kind: Key25519, Public: pub2, Votes: 1} - - b := a.NewUpdater(signer25519(priv)) - if err := b.AddKey(key2); err != nil { - t.Fatalf("AddKey(%v) failed: %v", key2, err) - } - updates, err := b.Finalize(storage) - if err != nil { - t.Fatalf("Finalize() failed: %v", err) - } - - // See if the update is valid by applying it to the authority - // + checking if the new key is there. - if err := a.Inform(storage, updates); err != nil { - t.Fatalf("could not apply generated updates: %v", err) - } - if _, err := a.state.GetKey(key2.MustID()); err != nil { - t.Errorf("could not read new key: %v", err) - } -} - -func TestAuthorityBuilderRemoveKey(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - pub2, _ := testingKey25519(t, 2) - key2 := Key{Kind: Key25519, Public: pub2, Votes: 1} - - storage := &Mem{} - a, _, err := Create(storage, State{ - Keys: []Key{key, key2}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, signer25519(priv)) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - b := a.NewUpdater(signer25519(priv)) - if err := b.RemoveKey(key2.MustID()); err != nil { - t.Fatalf("RemoveKey(%v) failed: %v", key2, err) - } - updates, err := b.Finalize(storage) - if err != nil { - t.Fatalf("Finalize() failed: %v", err) - } - - // See if the update is valid by applying it to the authority - // + checking if the key has been removed. - if err := a.Inform(storage, updates); err != nil { - t.Fatalf("could not apply generated updates: %v", err) - } - if _, err := a.state.GetKey(key2.MustID()); err != ErrNoSuchKey { - t.Errorf("GetKey(key2).err = %v, want %v", err, ErrNoSuchKey) - } -} - -func TestAuthorityBuilderSetKeyVote(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - storage := &Mem{} - a, _, err := Create(storage, State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, signer25519(priv)) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - b := a.NewUpdater(signer25519(priv)) - if err := b.SetKeyVote(key.MustID(), 5); err != nil { - t.Fatalf("SetKeyVote(%v) failed: %v", key.MustID(), err) - } - updates, err := b.Finalize(storage) - if err != nil { - t.Fatalf("Finalize() failed: %v", err) - } - - // See if the update is valid by applying it to the authority - // + checking if the update is there. - if err := a.Inform(storage, updates); err != nil { - t.Fatalf("could not apply generated updates: %v", err) - } - k, err := a.state.GetKey(key.MustID()) - if err != nil { - t.Fatal(err) - } - if got, want := k.Votes, uint(5); got != want { - t.Errorf("key.Votes = %d, want %d", got, want) - } -} - -func TestAuthorityBuilderSetKeyMeta(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2, Meta: map[string]string{"a": "b"}} - - storage := &Mem{} - a, _, err := Create(storage, State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, signer25519(priv)) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - b := a.NewUpdater(signer25519(priv)) - if err := b.SetKeyMeta(key.MustID(), map[string]string{"b": "c"}); err != nil { - t.Fatalf("SetKeyMeta(%v) failed: %v", key, err) - } - updates, err := b.Finalize(storage) - if err != nil { - t.Fatalf("Finalize() failed: %v", err) - } - - // See if the update is valid by applying it to the authority - // + checking if the update is there. - if err := a.Inform(storage, updates); err != nil { - t.Fatalf("could not apply generated updates: %v", err) - } - k, err := a.state.GetKey(key.MustID()) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(map[string]string{"b": "c"}, k.Meta); diff != "" { - t.Errorf("updated meta differs (-want, +got):\n%s", diff) - } -} - -func TestAuthorityBuilderMultiple(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - storage := &Mem{} - a, _, err := Create(storage, State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, signer25519(priv)) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - pub2, _ := testingKey25519(t, 2) - key2 := Key{Kind: Key25519, Public: pub2, Votes: 1} - - b := a.NewUpdater(signer25519(priv)) - if err := b.AddKey(key2); err != nil { - t.Fatalf("AddKey(%v) failed: %v", key2, err) - } - if err := b.SetKeyVote(key2.MustID(), 42); err != nil { - t.Fatalf("SetKeyVote(%v) failed: %v", key2, err) - } - if err := b.RemoveKey(key.MustID()); err != nil { - t.Fatalf("RemoveKey(%v) failed: %v", key, err) - } - updates, err := b.Finalize(storage) - if err != nil { - t.Fatalf("Finalize() failed: %v", err) - } - - // See if the update is valid by applying it to the authority - // + checking if the update is there. - if err := a.Inform(storage, updates); err != nil { - t.Fatalf("could not apply generated updates: %v", err) - } - k, err := a.state.GetKey(key2.MustID()) - if err != nil { - t.Fatal(err) - } - if got, want := k.Votes, uint(42); got != want { - t.Errorf("key.Votes = %d, want %d", got, want) - } - if _, err := a.state.GetKey(key.MustID()); err != ErrNoSuchKey { - t.Errorf("GetKey(key).err = %v, want %v", err, ErrNoSuchKey) - } -} - -func TestAuthorityBuilderCheckpointsAfterXUpdates(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - storage := &Mem{} - a, _, err := Create(storage, State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, signer25519(priv)) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - for i := 0; i <= checkpointEvery; i++ { - pub2, _ := testingKey25519(t, int64(i+2)) - key2 := Key{Kind: Key25519, Public: pub2, Votes: 1} - - b := a.NewUpdater(signer25519(priv)) - if err := b.AddKey(key2); err != nil { - t.Fatalf("AddKey(%v) failed: %v", key2, err) - } - updates, err := b.Finalize(storage) - if err != nil { - t.Fatalf("Finalize() failed: %v", err) - } - // See if the update is valid by applying it to the authority - // + checking if the new key is there. - if err := a.Inform(storage, updates); err != nil { - t.Fatalf("could not apply generated updates: %v", err) - } - if _, err := a.state.GetKey(key2.MustID()); err != nil { - t.Fatal(err) - } - - wantKind := AUMAddKey - if i == checkpointEvery-1 { // Genesis + 49 updates == 50 (the value of checkpointEvery) - wantKind = AUMCheckpoint - } - lastAUM, err := storage.AUM(a.Head()) - if err != nil { - t.Fatal(err) - } - if lastAUM.MessageKind != wantKind { - t.Errorf("[%d] HeadAUM.MessageKind = %v, want %v", i, lastAUM.MessageKind, wantKind) - } - } - - // Try starting an authority just based on storage. - a2, err := Open(storage) - if err != nil { - t.Fatalf("Failed to open from stored AUMs: %v", err) - } - if a.Head() != a2.Head() { - t.Errorf("stored and computed HEAD differ: got %v, want %v", a2.Head(), a.Head()) - } -} diff --git a/tka/chaintest_test.go b/tka/chaintest_test.go deleted file mode 100644 index 5811f9c8381ed..0000000000000 --- a/tka/chaintest_test.go +++ /dev/null @@ -1,378 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "bytes" - "crypto/ed25519" - "fmt" - "strconv" - "strings" - "testing" - "text/scanner" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/types/tkatype" -) - -// chaintest_test.go implements test helpers for concisely describing -// chains of possibly signed AUMs, to assist in making tests shorter and -// easier to read. - -// parsed representation of a named AUM in a test chain. -type testchainNode struct { - Name string - Parent string - Uses []scanner.Position - - HashSeed int - Template string - SignedWith string - - // When set, uses this hash as the parent hash when - // Parent is not set. - // - // Set when a testChain is based on a different one - // (in scenario_test.go). - ParentHash *AUMHash -} - -// testChain represents a constructed web of AUMs for testing purposes. -type testChain struct { - FirstIdent string - Nodes map[string]*testchainNode - AUMs map[string]AUM - AUMHashes map[string]AUMHash - - // Configured by options to NewTestchain() - Template map[string]AUM - Key map[string]*Key - KeyPrivs map[string]ed25519.PrivateKey - SignAllKeys []string -} - -// newTestchain constructs a web of AUMs based on the provided input and -// options. -// -// Input is expected to be a graph & tweaks, looking like this: -// -// G1 -> A -> B -// | -> C -// -// which defines AUMs G1, A, B, and C; with G1 having no parent, A having -// G1 as a parent, and both B & C having A as a parent. -// -// Tweaks are specified like this: -// -// . = -// -// for example: G1.hashSeed = 2 -// -// There are 3 available tweaks: -// - hashSeed: Set to an integer to tweak the AUM hash of that AUM. -// - template: Set to the name of a template provided via optTemplate(). -// The template is copied and use as the content for that AUM. -// - signedWith: Set to the name of a key provided via optKey(). This -// key is used to sign that AUM. -func newTestchain(t *testing.T, input string, options ...testchainOpt) *testChain { - t.Helper() - - var ( - s scanner.Scanner - out = testChain{ - Nodes: map[string]*testchainNode{}, - Template: map[string]AUM{}, - Key: map[string]*Key{}, - KeyPrivs: map[string]ed25519.PrivateKey{}, - } - ) - - // Process any options - for _, o := range options { - if o.Template != nil { - out.Template[o.Name] = *o.Template - } - if o.Key != nil { - out.Key[o.Name] = o.Key - out.KeyPrivs[o.Name] = o.Private - } - if o.SignAllWith { - out.SignAllKeys = append(out.SignAllKeys, o.Name) - } - } - - s.Init(strings.NewReader(input)) - s.Mode = scanner.ScanIdents | scanner.SkipComments | scanner.ScanComments | scanner.ScanChars | scanner.ScanInts - s.Whitespace ^= 1 << '\t' // clear tabs - var ( - lastIdent string - lastWasChain bool // if the last token was '->' - ) - for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() { - switch tok { - case '\t': - t.Fatalf("tabs disallowed, use spaces (seen at %v)", s.Pos()) - - case '.': // tweaks, like .hashSeed = - s.Scan() - tweak := s.TokenText() - if tok := s.Scan(); tok == '=' { - s.Scan() - switch tweak { - case "hashSeed": - out.Nodes[lastIdent].HashSeed, _ = strconv.Atoi(s.TokenText()) - case "template": - out.Nodes[lastIdent].Template = s.TokenText() - case "signedWith": - out.Nodes[lastIdent].SignedWith = s.TokenText() - } - } - - case scanner.Ident: - out.recordPos(s.TokenText(), s.Pos()) - // If the last token was '->', that means - // that the next identifier has a child relationship - // with the identifier preceding '->'. - if lastWasChain { - out.recordParent(t, s.TokenText(), lastIdent) - } - lastIdent = s.TokenText() - if out.FirstIdent == "" { - out.FirstIdent = s.TokenText() - } - - case '-': // handle '->' - switch s.Peek() { - case '>': - s.Scan() - lastWasChain = true - continue - } - - case '|': // handle '|' - line, col := s.Pos().Line, s.Pos().Column - nodeLoop: - for _, n := range out.Nodes { - for _, p := range n.Uses { - // Find the identifier used right here on the line above. - if p.Line == line-1 && col <= p.Column && col > p.Column-len(n.Name) { - lastIdent = n.Name - out.recordPos(n.Name, s.Pos()) - break nodeLoop - } - } - } - } - lastWasChain = false - // t.Logf("tok = %v, %q", tok, s.TokenText()) - } - - out.buildChain() - return &out -} - -// called from the parser to record the location of an -// identifier (a named AUM). -func (c *testChain) recordPos(ident string, pos scanner.Position) { - n := c.Nodes[ident] - if n == nil { - n = &testchainNode{Name: ident} - } - - n.Uses = append(n.Uses, pos) - c.Nodes[ident] = n -} - -// called from the parser to record a parent relationship between -// two AUMs. -func (c *testChain) recordParent(t *testing.T, child, parent string) { - if p := c.Nodes[child].Parent; p != "" && p != parent { - t.Fatalf("differing parent specified for %s: %q != %q", child, p, parent) - } - c.Nodes[child].Parent = parent -} - -// called after parsing to build the web of AUM structures. -// This method populates c.AUMs and c.AUMHashes. -func (c *testChain) buildChain() { - pending := make(map[string]*testchainNode, len(c.Nodes)) - for k, v := range c.Nodes { - pending[k] = v - } - - // AUMs with a parent need to know their hash, so we - // only compute AUMs who's parents have been computed - // each iteration. Since at least the genesis AUM - // had no parent, theres always a path to completion - // in O(n+1) where n is the number of AUMs. - c.AUMs = make(map[string]AUM, len(c.Nodes)) - c.AUMHashes = make(map[string]AUMHash, len(c.Nodes)) - for range len(c.Nodes) + 1 { - if len(pending) == 0 { - return - } - - next := make([]*testchainNode, 0, 10) - for _, v := range pending { - if _, parentPending := pending[v.Parent]; !parentPending { - next = append(next, v) - } - } - - for _, v := range next { - aum := c.makeAUM(v) - h := aum.Hash() - - c.AUMHashes[v.Name] = h - c.AUMs[v.Name] = aum - delete(pending, v.Name) - } - } - panic("unexpected: incomplete despite len(Nodes)+1 iterations") -} - -func (c *testChain) makeAUM(v *testchainNode) AUM { - // By default, the AUM used is just a no-op AUM - // with a parent hash set (if any). - // - // If .template is set to the same name as in - // a provided optTemplate(), the AUM is built - // from a copy of that instead. - // - // If .hashSeed = is set, the KeyID is - // tweaked to effect tweaking the hash. This is useful - // if you want one AUM to have a lower hash than another. - aum := AUM{MessageKind: AUMNoOp} - if template := v.Template; template != "" { - aum = c.Template[template] - } - if v.Parent != "" { - parentHash := c.AUMHashes[v.Parent] - aum.PrevAUMHash = parentHash[:] - } else if v.ParentHash != nil { - aum.PrevAUMHash = (*v.ParentHash)[:] - } - if seed := v.HashSeed; seed != 0 { - aum.KeyID = []byte{byte(seed)} - } - if err := aum.StaticValidate(); err != nil { - // Usually caused by a test writer specifying a template - // AUM which is ultimately invalid. - panic(fmt.Sprintf("aum %+v failed static validation: %v", aum, err)) - } - - sigHash := aum.SigHash() - for _, key := range c.SignAllKeys { - aum.Signatures = append(aum.Signatures, tkatype.Signature{ - KeyID: c.Key[key].MustID(), - Signature: ed25519.Sign(c.KeyPrivs[key], sigHash[:]), - }) - } - - // If the aum was specified as being signed by some key, then - // sign it using that key. - if key := v.SignedWith; key != "" { - aum.Signatures = append(aum.Signatures, tkatype.Signature{ - KeyID: c.Key[key].MustID(), - Signature: ed25519.Sign(c.KeyPrivs[key], sigHash[:]), - }) - } - - return aum -} - -// Chonk returns a tailchonk containing all AUMs. -func (c *testChain) Chonk() Chonk { - var out Mem - for _, update := range c.AUMs { - if err := out.CommitVerifiedAUMs([]AUM{update}); err != nil { - panic(err) - } - } - return &out -} - -// ChonkWith returns a tailchonk containing the named AUMs. -func (c *testChain) ChonkWith(names ...string) Chonk { - var out Mem - for _, name := range names { - update := c.AUMs[name] - if err := out.CommitVerifiedAUMs([]AUM{update}); err != nil { - panic(err) - } - } - return &out -} - -type testchainOpt struct { - Name string - Template *AUM - Key *Key - Private ed25519.PrivateKey - SignAllWith bool -} - -func optTemplate(name string, template AUM) testchainOpt { - return testchainOpt{ - Name: name, - Template: &template, - } -} - -func optKey(name string, key Key, priv ed25519.PrivateKey) testchainOpt { - return testchainOpt{ - Name: name, - Key: &key, - Private: priv, - } -} - -func optSignAllUsing(keyName string) testchainOpt { - return testchainOpt{ - Name: keyName, - SignAllWith: true, - } -} - -func TestNewTestchain(t *testing.T) { - c := newTestchain(t, ` - genesis -> B -> C - | -> D - | -> E -> F - - E.hashSeed = 12 // tweak E to have the lowest hash so its chosen - F.template = test - `, optTemplate("test", AUM{MessageKind: AUMNoOp, KeyID: []byte{10}})) - - want := map[string]*testchainNode{ - "genesis": {Name: "genesis", Uses: []scanner.Position{{Line: 2, Column: 16}}}, - "B": { - Name: "B", - Parent: "genesis", - Uses: []scanner.Position{{Line: 2, Column: 21}, {Line: 3, Column: 21}, {Line: 4, Column: 21}}, - }, - "C": {Name: "C", Parent: "B", Uses: []scanner.Position{{Line: 2, Column: 26}}}, - "D": {Name: "D", Parent: "B", Uses: []scanner.Position{{Line: 3, Column: 26}}}, - "E": {Name: "E", Parent: "B", HashSeed: 12, Uses: []scanner.Position{{Line: 4, Column: 26}, {Line: 6, Column: 10}}}, - "F": {Name: "F", Parent: "E", Template: "test", Uses: []scanner.Position{{Line: 4, Column: 31}, {Line: 7, Column: 10}}}, - } - - if diff := cmp.Diff(want, c.Nodes, cmpopts.IgnoreFields(scanner.Position{}, "Offset")); diff != "" { - t.Errorf("decoded state differs (-want, +got):\n%s", diff) - } - if !bytes.Equal(c.AUMs["F"].KeyID, []byte{10}) { - t.Errorf("AUM 'F' missing KeyID from template: %v", c.AUMs["F"]) - } - - // chonk := c.Chonk() - // authority, err := Open(chonk) - // if err != nil { - // t.Errorf("failed to initialize from chonk: %v", err) - // } - - // if authority.Head() != c.AUMHashes["F"] { - // t.Errorf("head = %X, want %X", authority.Head(), c.AUMHashes["F"]) - // } -} diff --git a/tka/deeplink_test.go b/tka/deeplink_test.go deleted file mode 100644 index 03523202fed8b..0000000000000 --- a/tka/deeplink_test.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "testing" -) - -func TestGenerateDeeplink(t *testing.T) { - pub, _ := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - c := newTestchain(t, ` - G1 -> L1 - - G1.template = genesis - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - ) - a, _ := Open(c.Chonk()) - - nodeKey := "nodekey:1234567890" - tlPub := "tlpub:1234567890" - deviceName := "Example Device" - osName := "iOS" - loginName := "insecure@example.com" - - deeplink, err := a.NewDeeplink(NewDeeplinkParams{ - NodeKey: nodeKey, - TLPub: tlPub, - DeviceName: deviceName, - OSName: osName, - LoginName: loginName, - }) - if err != nil { - t.Errorf("deeplink generation failed: %v", err) - } - - res := a.ValidateDeeplink(deeplink) - if !res.IsValid { - t.Errorf("deeplink validation failed: %s", res.Error) - } - if res.NodeKey != nodeKey { - t.Errorf("node key mismatch: %s != %s", res.NodeKey, nodeKey) - } - if res.TLPub != tlPub { - t.Errorf("tlpub mismatch: %s != %s", res.TLPub, tlPub) - } -} diff --git a/tka/key.go b/tka/key.go index 07736795d8e58..75fb85770de22 100644 --- a/tka/key.go +++ b/tka/key.go @@ -9,7 +9,7 @@ import ( "fmt" "github.com/hdevalence/ed25519consensus" - "tailscale.com/types/tkatype" + "github.com/sagernet/tailscale/types/tkatype" ) // KeyKind describes the different varieties of a Key. diff --git a/tka/key_test.go b/tka/key_test.go deleted file mode 100644 index e912f89c4f7eb..0000000000000 --- a/tka/key_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "bytes" - "crypto/ed25519" - "encoding/binary" - "math/rand" - "testing" - - "tailscale.com/types/key" - "tailscale.com/types/tkatype" -) - -// returns a random source based on the test name + extraSeed. -func testingRand(t *testing.T, extraSeed int64) *rand.Rand { - var seed int64 - if err := binary.Read(bytes.NewBuffer([]byte(t.Name())), binary.LittleEndian, &seed); err != nil { - panic(err) - } - return rand.New(rand.NewSource(seed + extraSeed)) -} - -// generates a 25519 private key based on the seed + test name. -func testingKey25519(t *testing.T, seed int64) (ed25519.PublicKey, ed25519.PrivateKey) { - pub, priv, err := ed25519.GenerateKey(testingRand(t, seed)) - if err != nil { - panic(err) - } - return pub, priv -} - -func TestVerify25519(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{ - Kind: Key25519, - Public: pub, - } - - aum := AUM{ - MessageKind: AUMRemoveKey, - KeyID: []byte{1, 2, 3, 4}, - // Signatures is set to crap so we are sure its ignored in the sigHash computation. - Signatures: []tkatype.Signature{{KeyID: []byte{45, 42}}}, - } - sigHash := aum.SigHash() - aum.Signatures = []tkatype.Signature{ - { - KeyID: key.MustID(), - Signature: ed25519.Sign(priv, sigHash[:]), - }, - } - - if err := signatureVerify(&aum.Signatures[0], aum.SigHash(), key); err != nil { - t.Errorf("signature verification failed: %v", err) - } - - // Make sure it fails with a different public key. - pub2, _ := testingKey25519(t, 2) - key2 := Key{Kind: Key25519, Public: pub2} - if err := signatureVerify(&aum.Signatures[0], aum.SigHash(), key2); err == nil { - t.Error("signature verification with different key did not fail") - } -} - -func TestNLPrivate(t *testing.T) { - p := key.NewNLPrivate() - pub := p.Public() - - // Test that key.NLPrivate implements Signer by making a new - // authority. - k := Key{Kind: Key25519, Public: pub.Verifier(), Votes: 1} - _, aum, err := Create(&Mem{}, State{ - Keys: []Key{k}, - DisablementSecrets: [][]byte{bytes.Repeat([]byte{1}, 32)}, - }, p) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - // Make sure the generated genesis AUM was signed. - if got, want := len(aum.Signatures), 1; got != want { - t.Fatalf("len(signatures) = %d, want %d", got, want) - } - sigHash := aum.SigHash() - if ok := ed25519.Verify(pub.Verifier(), sigHash[:], aum.Signatures[0].Signature); !ok { - t.Error("signature did not verify") - } - - // We manually compute the keyID, so make sure its consistent with - // tka.Key.ID(). - if !bytes.Equal(k.MustID(), p.KeyID()) { - t.Errorf("private.KeyID() & tka KeyID differ: %x != %x", k.MustID(), p.KeyID()) - } -} diff --git a/tka/scenario_test.go b/tka/scenario_test.go deleted file mode 100644 index 89a8111e18cef..0000000000000 --- a/tka/scenario_test.go +++ /dev/null @@ -1,377 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "crypto/ed25519" - "sort" - "testing" -) - -type scenarioNode struct { - Name string - A *Authority - AUMs map[string]AUM - - storage Chonk -} - -type scenarioTest struct { - t *testing.T - - defaultKey *Key - defaultPriv ed25519.PrivateKey - - initial *testChain - - nodes map[string]*scenarioNode -} - -func (s *scenarioTest) mkNode(name string) *scenarioNode { - storage := s.initial.Chonk() - authority, err := Open(storage) - if err != nil { - s.t.Fatal(err) - } - - aums := make(map[string]AUM, len(s.initial.AUMs)) - for k, v := range s.initial.AUMs { - aums[k] = v - } - - n := &scenarioNode{ - A: authority, - AUMs: aums, - Name: name, - storage: storage, - } - - s.nodes[name] = n - return n -} - -// mkNodeWithForks creates a new node based on the initial AUMs in the -// scenario, but additionally with the forking chains applied. -// -// chains is expected to be a map containing chains that should be known -// by this node, with the key being the parent AUM the chain extends from. -func (s *scenarioTest) mkNodeWithForks(name string, signWithDefault bool, chains map[string]*testChain) *scenarioNode { - n := s.mkNode(name) - - // re-jig the provided chain to be based on the provided parent, - // and optionally signed with the default key. - for parentName, chain := range chains { - parent, exists := n.AUMs[parentName] - if !exists { - panic("cannot use nonexistent parent: " + parentName) - } - parentHash := parent.Hash() - chain.Nodes[chain.FirstIdent].ParentHash = &parentHash - - if signWithDefault { - chain.Key["default_key"] = s.defaultKey - chain.KeyPrivs["default_key"] = s.defaultPriv - chain.SignAllKeys = append(chain.SignAllKeys, "default_key") - } - chain.buildChain() - - aums := make([]AUM, 0, len(chain.AUMs)) - for name, a := range chain.AUMs { - aums = append(aums, a) - n.AUMs[name] = a - } - // AUMs passed to Inform need to be ordered in - // from ancestor to leaf. - sort.SliceStable(aums, func(i, j int) bool { - jParent, _ := aums[j].Parent() - if aums[i].Hash() == jParent { - return true - } - return false - }) - if err := n.A.Inform(n.storage, aums); err != nil { - panic(err) - } - } - - return n -} - -func (s *scenarioTest) syncBetween(n1, n2 *scenarioNode) error { - o1, err := n1.A.SyncOffer(n1.storage) - if err != nil { - return err - } - o2, err := n2.A.SyncOffer(n2.storage) - if err != nil { - return err - } - - aumsFrom1, err := n1.A.MissingAUMs(n1.storage, o2) - if err != nil { - return err - } - aumsFrom2, err := n2.A.MissingAUMs(n2.storage, o1) - if err != nil { - return err - } - if err := n2.A.Inform(n2.storage, aumsFrom1); err != nil { - return err - } - if err := n1.A.Inform(n1.storage, aumsFrom2); err != nil { - return err - } - return nil -} - -func (s *scenarioTest) testSyncsBetween(n1, n2 *scenarioNode) { - if err := s.syncBetween(n1, n2); err != nil { - s.t.Fatal(err) - } -} - -func (s *scenarioTest) checkHaveConsensus(n1, n2 *scenarioNode) { - if h1, h2 := n1.A.Head(), n2.A.Head(); h1 != h2 { - s.t.Errorf("node %s & %s are not in sync", n1.Name, n2.Name) - } -} - -// testScenario implements scaffolding for testing that authorities -// with different head states can synchronize. -// -// sharedChain and sharedOptions are passed to testChain to create an -// initial set of AUMs which all nodes know about. A default key and genesis -// AUM are created for you under the template 'genesis' and key 'key'. -func testScenario(t *testing.T, sharedChain string, sharedOptions ...testchainOpt) *scenarioTest { - t.Helper() - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 1} - sharedOptions = append(sharedOptions, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optKey("key", key, priv), - optSignAllUsing("key")) - - return &scenarioTest{ - t: t, - defaultKey: &key, - defaultPriv: priv, - initial: newTestchain(t, sharedChain, sharedOptions...), - nodes: map[string]*scenarioNode{}, - } -} - -func TestScenarioHelpers(t *testing.T) { - s := testScenario(t, ` - G -> L1 - G.template = genesis - `) - control := s.mkNode("control") - - n := s.mkNodeWithForks("n", true, map[string]*testChain{ - "L1": newTestchain(t, `L2 -> L3`), - }) - - // Make sure node has both the initial AUMs and the - // chain from L1. - if _, ok := n.AUMs["G"]; !ok { - t.Errorf("node n is missing %s", "G") - } - if _, ok := n.AUMs["L1"]; !ok { - t.Errorf("node n is missing %s", "L1") - } - if _, ok := n.AUMs["L2"]; !ok { - t.Errorf("node n is missing %s", "L2") - } - if _, ok := n.AUMs["L3"]; !ok { - t.Errorf("node n is missing %s", "L3") - } - if err := signatureVerify(&n.AUMs["L3"].Signatures[0], n.AUMs["L3"].SigHash(), *s.defaultKey); err != nil { - t.Errorf("chained AUM was not signed: %v", err) - } - - s.testSyncsBetween(control, n) - s.checkHaveConsensus(control, n) -} - -func TestNormalPropagation(t *testing.T) { - s := testScenario(t, ` - G -> L1 -> L2 - G.template = genesis - `) - control := s.mkNode("control") - - // Lets say theres a node with some updates! - n1 := s.mkNodeWithForks("n1", true, map[string]*testChain{ - "L2": newTestchain(t, `L3 -> L4`), - }) - // Can control haz the updates? - s.testSyncsBetween(control, n1) - s.checkHaveConsensus(control, n1) - - // A new node came online, can the new node learn everything - // just via control? - n2 := s.mkNode("n2") - s.testSyncsBetween(control, n2) - s.checkHaveConsensus(control, n2) - - // So by virtue of syncing with control n2 should be at the same - // state as n1. - s.checkHaveConsensus(n1, n2) -} - -func TestForkingPropagation(t *testing.T) { - pub, priv := testingKey25519(t, 2) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - addKey2 := AUM{MessageKind: AUMAddKey, Key: &key} - - s := testScenario(t, ` - G -> AddSecondKey -> L1 -> L2 - G.template = genesis - AddSecondKey.template = addKey2 - `, - optKey("key2", key, priv), - optTemplate("addKey2", addKey2)) - - control := s.mkNode("control") - - // Random, non-forking updates from n1 - n1 := s.mkNodeWithForks("n1", true, map[string]*testChain{ - "L2": newTestchain(t, `L3 -> L4`), - }) - // Can control haz the updates? - s.testSyncsBetween(control, n1) - s.checkHaveConsensus(control, n1) - - // Ooooo what about a forking update? - n2 := s.mkNodeWithForks("n2", false, map[string]*testChain{ - "L1": newTestchain(t, - `F1 -> F2 - F1.template = removeKey1`, - optSignAllUsing("key2"), - optKey("key2", key, priv), - optTemplate("removeKey1", AUM{MessageKind: AUMRemoveKey, KeyID: s.defaultKey.MustID()})), - }) - s.testSyncsBetween(control, n2) - s.checkHaveConsensus(control, n2) - - // No wozzles propagating from n2->CTRL, what about CTRL->n1? - s.testSyncsBetween(control, n1) - s.checkHaveConsensus(n1, n2) - - if _, err := n1.A.state.GetKey(s.defaultKey.MustID()); err != ErrNoSuchKey { - t.Error("default key was still present") - } - if _, err := n1.A.state.GetKey(key.MustID()); err != nil { - t.Errorf("key2 was not trusted: %v", err) - } -} - -func TestInvalidAUMPropagationRejected(t *testing.T) { - s := testScenario(t, ` - G -> L1 -> L2 - G.template = genesis - `) - control := s.mkNode("control") - - // Construct an invalid L4 AUM, and manually apply it to n1, - // resulting in a corrupted Authority. - n1 := s.mkNodeWithForks("n1", true, map[string]*testChain{ - "L2": newTestchain(t, `L3`), - }) - l3 := n1.AUMs["L3"] - l3H := l3.Hash() - l4 := AUM{MessageKind: AUMAddKey, PrevAUMHash: l3H[:]} - if err := l4.sign25519(s.defaultPriv); err != nil { - t.Fatal(err) - } - l4H := l4.Hash() - n1.storage.CommitVerifiedAUMs([]AUM{l4}) - n1.A.state.LastAUMHash = &l4H - - // Does control nope out with syncing? - if err := s.syncBetween(control, n1); err == nil { - t.Error("sync with invalid AUM was successful") - } - - // Control should not have accepted ANY of the updates, even - // though L3 was well-formed. - l2 := control.AUMs["L2"] - l2H := l2.Hash() - if control.A.Head() != l2H { - t.Errorf("head was %x, expected %x", control.A.Head(), l2H) - } -} - -func TestUnsignedAUMPropagationRejected(t *testing.T) { - s := testScenario(t, ` - G -> L1 -> L2 - G.template = genesis - `) - control := s.mkNode("control") - - // Construct an unsigned L4 AUM, and manually apply it to n1, - // resulting in a corrupted Authority. - n1 := s.mkNodeWithForks("n1", true, map[string]*testChain{ - "L2": newTestchain(t, `L3`), - }) - l3 := n1.AUMs["L3"] - l3H := l3.Hash() - l4 := AUM{MessageKind: AUMNoOp, PrevAUMHash: l3H[:]} - l4H := l4.Hash() - n1.storage.CommitVerifiedAUMs([]AUM{l4}) - n1.A.state.LastAUMHash = &l4H - - // Does control nope out with syncing? - if err := s.syncBetween(control, n1); err == nil || err.Error() != "update 1 invalid: unsigned AUM" { - t.Errorf("sync with unsigned AUM was successful (err = %v)", err) - } - - // Control should not have accepted ANY of the updates, even - // though L3 was well-formed. - l2 := control.AUMs["L2"] - l2H := l2.Hash() - if control.A.Head() != l2H { - t.Errorf("head was %x, expected %x", control.A.Head(), l2H) - } -} - -func TestBadSigAUMPropagationRejected(t *testing.T) { - s := testScenario(t, ` - G -> L1 -> L2 - G.template = genesis - `) - control := s.mkNode("control") - - // Construct a otherwise-valid L4 AUM but mess up the signature. - n1 := s.mkNodeWithForks("n1", true, map[string]*testChain{ - "L2": newTestchain(t, `L3`), - }) - l3 := n1.AUMs["L3"] - l3H := l3.Hash() - l4 := AUM{MessageKind: AUMNoOp, PrevAUMHash: l3H[:]} - if err := l4.sign25519(s.defaultPriv); err != nil { - t.Fatal(err) - } - l4.Signatures[0].Signature[3] = 42 - l4H := l4.Hash() - n1.storage.CommitVerifiedAUMs([]AUM{l4}) - n1.A.state.LastAUMHash = &l4H - - // Does control nope out with syncing? - if err := s.syncBetween(control, n1); err == nil || err.Error() != "update 1 invalid: signature 0: invalid signature" { - t.Errorf("sync with unsigned AUM was successful (err = %v)", err) - } - - // Control should not have accepted ANY of the updates, even - // though L3 was well-formed. - l2 := control.AUMs["L2"] - l2H := l2.Hash() - if control.A.Head() != l2H { - t.Errorf("head was %x, expected %x", control.A.Head(), l2H) - } -} diff --git a/tka/sig.go b/tka/sig.go index c82f9715c33fb..725d97a4ff2bd 100644 --- a/tka/sig.go +++ b/tka/sig.go @@ -13,10 +13,10 @@ import ( "github.com/fxamacker/cbor/v2" "github.com/hdevalence/ed25519consensus" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/tkatype" "golang.org/x/crypto/blake2s" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/tkatype" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=NodeKeySignature diff --git a/tka/sig_test.go b/tka/sig_test.go deleted file mode 100644 index d64575e7c7b45..0000000000000 --- a/tka/sig_test.go +++ /dev/null @@ -1,633 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "crypto/ed25519" - "reflect" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/types/key" - "tailscale.com/types/tkatype" -) - -func TestSigDirect(t *testing.T) { - node := key.NewNode() - nodeKeyPub, _ := node.Public().MarshalBinary() - - // Verification key (the key used to sign) - pub, priv := testingKey25519(t, 1) - k := Key{Kind: Key25519, Public: pub, Votes: 2} - - sig := NodeKeySignature{ - SigKind: SigDirect, - KeyID: k.MustID(), - Pubkey: nodeKeyPub, - } - sigHash := sig.SigHash() - sig.Signature = ed25519.Sign(priv, sigHash[:]) - - if sig.SigHash() != sigHash { - t.Errorf("sigHash changed after signing: %x != %x", sig.SigHash(), sigHash) - } - - if err := sig.verifySignature(node.Public(), k); err != nil { - t.Fatalf("verifySignature() failed: %v", err) - } - - // Test verification fails when verifying for a different node - if err := sig.verifySignature(key.NewNode().Public(), k); err == nil { - t.Error("verifySignature() did not error for different nodekey") - } - - // Test verification fails if the wrong verification key is provided - copy(k.Public, []byte{1, 2, 3, 4}) - if err := sig.verifySignature(node.Public(), k); err == nil { - t.Error("verifySignature() did not error for wrong verification key") - } -} - -func TestSigNested(t *testing.T) { - // Network-lock key (the key used to sign the nested sig) - pub, priv := testingKey25519(t, 1) - k := Key{Kind: Key25519, Public: pub, Votes: 2} - // Rotation key (the key used to sign the outer sig) - rPub, rPriv := testingKey25519(t, 2) - // The old node key which is being rotated out - oldNode := key.NewNode() - oldPub, _ := oldNode.Public().MarshalBinary() - // The new node key that is being rotated in - node := key.NewNode() - nodeKeyPub, _ := node.Public().MarshalBinary() - - // The original signature for the old node key, signed by - // the network-lock key. - nestedSig := NodeKeySignature{ - SigKind: SigDirect, - KeyID: k.MustID(), - Pubkey: oldPub, - WrappingPubkey: rPub, - } - sigHash := nestedSig.SigHash() - nestedSig.Signature = ed25519.Sign(priv, sigHash[:]) - if err := nestedSig.verifySignature(oldNode.Public(), k); err != nil { - t.Fatalf("verifySignature(oldNode) failed: %v", err) - } - if l := sigChainLength(nestedSig); l != 1 { - t.Errorf("nestedSig chain length = %v, want 1", l) - } - - // The signature authorizing the rotation, signed by the - // rotation key & embedding the original signature. - sig := NodeKeySignature{ - SigKind: SigRotation, - Pubkey: nodeKeyPub, - Nested: &nestedSig, - } - sigHash = sig.SigHash() - sig.Signature = ed25519.Sign(rPriv, sigHash[:]) - - if err := sig.verifySignature(node.Public(), k); err != nil { - t.Fatalf("verifySignature(node) failed: %v", err) - } - if l := sigChainLength(sig); l != 2 { - t.Errorf("sig chain length = %v, want 2", l) - } - - // Test verification fails if the wrong verification key is provided - kBad := Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}, Votes: 2} - if err := sig.verifySignature(node.Public(), kBad); err == nil { - t.Error("verifySignature() did not error for wrong verification key") - } - - // Test verification fails if the inner signature is invalid - tmp := make([]byte, ed25519.SignatureSize) - copy(tmp, nestedSig.Signature) - copy(nestedSig.Signature, []byte{1, 2, 3, 4}) - if err := sig.verifySignature(node.Public(), k); err == nil { - t.Error("verifySignature(node) succeeded with bad inner signature") - } - copy(nestedSig.Signature, tmp) - - // Test verification fails if the outer signature is invalid - copy(sig.Signature, []byte{1, 2, 3, 4}) - if err := sig.verifySignature(node.Public(), k); err == nil { - t.Error("verifySignature(node) succeeded with bad outer signature") - } - - // Test verification fails if the outer signature is signed with a - // different public key to whats specified in WrappingPubkey - sig.Signature = ed25519.Sign(priv, sigHash[:]) - if err := sig.verifySignature(node.Public(), k); err == nil { - t.Error("verifySignature(node) succeeded with different signature") - } -} - -func TestSigNested_DeepNesting(t *testing.T) { - // Network-lock key (the key used to sign the nested sig) - pub, priv := testingKey25519(t, 1) - k := Key{Kind: Key25519, Public: pub, Votes: 2} - // Rotation key (the key used to sign the outer sig) - rPub, rPriv := testingKey25519(t, 2) - // The old node key which is being rotated out - oldNode := key.NewNode() - oldPub, _ := oldNode.Public().MarshalBinary() - - // The original signature for the old node key, signed by - // the network-lock key. - nestedSig := NodeKeySignature{ - SigKind: SigDirect, - KeyID: k.MustID(), - Pubkey: oldPub, - WrappingPubkey: rPub, - } - sigHash := nestedSig.SigHash() - nestedSig.Signature = ed25519.Sign(priv, sigHash[:]) - if err := nestedSig.verifySignature(oldNode.Public(), k); err != nil { - t.Fatalf("verifySignature(oldNode) failed: %v", err) - } - - outer := nestedSig - var lastNodeKey key.NodePrivate - for range 15 { // 15 = max nesting level for CBOR - lastNodeKey = key.NewNode() - nodeKeyPub, _ := lastNodeKey.Public().MarshalBinary() - - tmp := outer - sig := NodeKeySignature{ - SigKind: SigRotation, - Pubkey: nodeKeyPub, - Nested: &tmp, - } - sigHash = sig.SigHash() - sig.Signature = ed25519.Sign(rPriv, sigHash[:]) - - outer = sig - } - - if err := outer.verifySignature(lastNodeKey.Public(), k); err != nil { - t.Fatalf("verifySignature(lastNodeKey) failed: %v", err) - } - - // Test this works with our public API - a, _ := Open(newTestchain(t, "G1\nG1.template = genesis", - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{k}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }})).Chonk()) - if err := a.NodeKeyAuthorized(lastNodeKey.Public(), outer.Serialize()); err != nil { - t.Errorf("NodeKeyAuthorized(lastNodeKey) failed: %v", err) - } - - // Test verification fails if the inner signature is invalid - tmp := make([]byte, ed25519.SignatureSize) - copy(tmp, nestedSig.Signature) - copy(nestedSig.Signature, []byte{1, 2, 3, 4}) - if err := outer.verifySignature(lastNodeKey.Public(), k); err == nil { - t.Error("verifySignature(lastNodeKey) succeeded with bad inner signature") - } - copy(nestedSig.Signature, tmp) - - // Test verification fails if an intermediate signature is invalid - copy(outer.Nested.Nested.Signature, []byte{1, 2, 3, 4}) - if err := outer.verifySignature(lastNodeKey.Public(), k); err == nil { - t.Error("verifySignature(lastNodeKey) succeeded with bad outer signature") - } -} - -func TestSigCredential(t *testing.T) { - // Network-lock key (the key used to sign the nested sig) - pub, priv := testingKey25519(t, 1) - k := Key{Kind: Key25519, Public: pub, Votes: 2} - // 'credential' key (the one being delegated to) - cPub, cPriv := testingKey25519(t, 2) - // The node key being certified - node := key.NewNode() - nodeKeyPub, _ := node.Public().MarshalBinary() - - // The signature certifying delegated trust to another - // public key. - nestedSig := NodeKeySignature{ - SigKind: SigCredential, - KeyID: k.MustID(), - WrappingPubkey: cPub, - } - sigHash := nestedSig.SigHash() - nestedSig.Signature = ed25519.Sign(priv, sigHash[:]) - - // The signature authorizing the node key, signed by the - // delegated key & embedding the original signature. - sig := NodeKeySignature{ - SigKind: SigRotation, - Pubkey: nodeKeyPub, - Nested: &nestedSig, - } - sigHash = sig.SigHash() - sig.Signature = ed25519.Sign(cPriv, sigHash[:]) - if err := sig.verifySignature(node.Public(), k); err != nil { - t.Fatalf("verifySignature(node) failed: %v", err) - } - - // Test verification fails if the wrong verification key is provided - kBad := Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}, Votes: 2} - if err := sig.verifySignature(node.Public(), kBad); err == nil { - t.Error("verifySignature() did not error for wrong verification key") - } - - // Test someone can't misuse our public API for verifying node-keys - a, _ := Open(newTestchain(t, "G1\nG1.template = genesis", - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{k}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }})).Chonk()) - if err := a.NodeKeyAuthorized(node.Public(), nestedSig.Serialize()); err == nil { - t.Error("NodeKeyAuthorized(SigCredential, node) did not fail") - } - // but that they can use it properly (nested in a SigRotation) - if err := a.NodeKeyAuthorized(node.Public(), sig.Serialize()); err != nil { - t.Errorf("NodeKeyAuthorized(SigRotation{SigCredential}, node) failed: %v", err) - } - - // Test verification fails if the inner signature is invalid - tmp := make([]byte, ed25519.SignatureSize) - copy(tmp, nestedSig.Signature) - copy(nestedSig.Signature, []byte{1, 2, 3, 4}) - if err := sig.verifySignature(node.Public(), k); err == nil { - t.Error("verifySignature(node) succeeded with bad inner signature") - } - copy(nestedSig.Signature, tmp) - - // Test verification fails if the outer signature is invalid - copy(tmp, sig.Signature) - copy(sig.Signature, []byte{1, 2, 3, 4}) - if err := sig.verifySignature(node.Public(), k); err == nil { - t.Error("verifySignature(node) succeeded with bad outer signature") - } - copy(sig.Signature, tmp) - - // Test verification fails if we attempt to check a different node-key - otherNode := key.NewNode() - if err := sig.verifySignature(otherNode.Public(), k); err == nil { - t.Error("verifySignature(otherNode) succeeded with different principal") - } - - // Test verification fails if the outer signature is signed with a - // different public key to whats specified in WrappingPubkey - sig.Signature = ed25519.Sign(priv, sigHash[:]) - if err := sig.verifySignature(node.Public(), k); err == nil { - t.Error("verifySignature(node) succeeded with different signature") - } -} - -func TestSigSerializeUnserialize(t *testing.T) { - nodeKeyPub := []byte{1, 2, 3, 4} - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - sig := NodeKeySignature{ - SigKind: SigDirect, - KeyID: key.MustID(), - Pubkey: nodeKeyPub, - Nested: &NodeKeySignature{ - SigKind: SigDirect, - KeyID: key.MustID(), - Pubkey: nodeKeyPub, - }, - } - sigHash := sig.SigHash() - sig.Signature = ed25519.Sign(priv, sigHash[:]) - - var decoded NodeKeySignature - if err := decoded.Unserialize(sig.Serialize()); err != nil { - t.Fatalf("Unserialize() failed: %v", err) - } - if diff := cmp.Diff(sig, decoded); diff != "" { - t.Errorf("unmarshalled version differs (-want, +got):\n%s", diff) - } -} - -func TestNodeKeySignatureRotationDetails(t *testing.T) { - // Trusted network lock key - pub, priv := testingKey25519(t, 1) - k := Key{Kind: Key25519, Public: pub, Votes: 2} - - // 'credential' key (the one being delegated to) - cPub, cPriv := testingKey25519(t, 2) - - n1, n2, n3 := key.NewNode(), key.NewNode(), key.NewNode() - n1pub, _ := n1.Public().MarshalBinary() - n2pub, _ := n2.Public().MarshalBinary() - n3pub, _ := n3.Public().MarshalBinary() - - tests := []struct { - name string - nodeKey key.NodePublic - sigFn func() NodeKeySignature - want *RotationDetails - }{ - { - name: "SigDirect", - nodeKey: n1.Public(), - sigFn: func() NodeKeySignature { - s := NodeKeySignature{ - SigKind: SigDirect, - KeyID: pub, - Pubkey: n1pub, - } - sigHash := s.SigHash() - s.Signature = ed25519.Sign(priv, sigHash[:]) - return s - }, - want: nil, - }, - { - name: "SigWrappedCredential", - nodeKey: n1.Public(), - sigFn: func() NodeKeySignature { - nestedSig := NodeKeySignature{ - SigKind: SigCredential, - KeyID: pub, - WrappingPubkey: cPub, - } - sigHash := nestedSig.SigHash() - nestedSig.Signature = ed25519.Sign(priv, sigHash[:]) - - sig := NodeKeySignature{ - SigKind: SigRotation, - Pubkey: n1pub, - Nested: &nestedSig, - } - sigHash = sig.SigHash() - sig.Signature = ed25519.Sign(cPriv, sigHash[:]) - return sig - }, - want: &RotationDetails{ - InitialSig: &NodeKeySignature{ - SigKind: SigCredential, - KeyID: pub, - WrappingPubkey: cPub, - }, - }, - }, - { - name: "SigRotation", - nodeKey: n2.Public(), - sigFn: func() NodeKeySignature { - nestedSig := NodeKeySignature{ - SigKind: SigDirect, - Pubkey: n1pub, - KeyID: pub, - WrappingPubkey: cPub, - } - sigHash := nestedSig.SigHash() - nestedSig.Signature = ed25519.Sign(priv, sigHash[:]) - - sig := NodeKeySignature{ - SigKind: SigRotation, - Pubkey: n2pub, - Nested: &nestedSig, - } - sigHash = sig.SigHash() - sig.Signature = ed25519.Sign(cPriv, sigHash[:]) - return sig - }, - want: &RotationDetails{ - InitialSig: &NodeKeySignature{ - SigKind: SigDirect, - Pubkey: n1pub, - KeyID: pub, - WrappingPubkey: cPub, - }, - PrevNodeKeys: []key.NodePublic{n1.Public()}, - }, - }, - { - name: "SigRotationNestedTwice", - nodeKey: n3.Public(), - sigFn: func() NodeKeySignature { - initialSig := NodeKeySignature{ - SigKind: SigDirect, - Pubkey: n1pub, - KeyID: pub, - WrappingPubkey: cPub, - } - sigHash := initialSig.SigHash() - initialSig.Signature = ed25519.Sign(priv, sigHash[:]) - - prevRotation := NodeKeySignature{ - SigKind: SigRotation, - Pubkey: n2pub, - Nested: &initialSig, - } - sigHash = prevRotation.SigHash() - prevRotation.Signature = ed25519.Sign(cPriv, sigHash[:]) - - sig := NodeKeySignature{ - SigKind: SigRotation, - Pubkey: n3pub, - Nested: &prevRotation, - } - sigHash = sig.SigHash() - sig.Signature = ed25519.Sign(cPriv, sigHash[:]) - - return sig - }, - want: &RotationDetails{ - InitialSig: &NodeKeySignature{ - SigKind: SigDirect, - Pubkey: n1pub, - KeyID: pub, - WrappingPubkey: cPub, - }, - PrevNodeKeys: []key.NodePublic{n2.Public(), n1.Public()}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.want != nil { - initialHash := tt.want.InitialSig.SigHash() - tt.want.InitialSig.Signature = ed25519.Sign(priv, initialHash[:]) - } - - sig := tt.sigFn() - if err := sig.verifySignature(tt.nodeKey, k); err != nil { - t.Fatalf("verifySignature(node) failed: %v", err) - } - got, err := sig.rotationDetails() - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("rotationDetails() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestDecodeWrappedAuthkey(t *testing.T) { - k, isWrapped, sig, priv := DecodeWrappedAuthkey("tskey-32mjsdkdsffds9o87dsfkjlh", nil) - if want := "tskey-32mjsdkdsffds9o87dsfkjlh"; k != want { - t.Errorf("decodeWrappedAuthkey().key = %q, want %q", k, want) - } - if isWrapped { - t.Error("decodeWrappedAuthkey().isWrapped = true, want false") - } - if sig != nil { - t.Errorf("decodeWrappedAuthkey().sig = %v, want nil", sig) - } - if priv != nil { - t.Errorf("decodeWrappedAuthkey().priv = %v, want nil", priv) - } - - k, isWrapped, sig, priv = DecodeWrappedAuthkey("tskey-auth-k7UagY1CNTRL-ZZZZZ--TLpAEDA1ggnXuw4/fWnNWUwcoOjLemhOvml1juMl5lhLmY5sBUsj8EWEAfL2gdeD9g8VDw5tgcxCiHGlEb67BgU2DlFzZApi4LheLJraA+pYjTGChVhpZz1iyiBPD+U2qxDQAbM3+WFY0EBlggxmVqG53Hu0Rg+KmHJFMlUhfgzo+AQP6+Kk9GzvJJOs4-k36RdoSFqaoARfQo0UncHAV0t3YTqrkD5r/z2jTrE43GZWobnce7RGD4qYckUyVSF+DOj4BA/r4qT0bO8kk6zg", nil) - if want := "tskey-auth-k7UagY1CNTRL-ZZZZZ"; k != want { - t.Errorf("decodeWrappedAuthkey().key = %q, want %q", k, want) - } - if !isWrapped { - t.Error("decodeWrappedAuthkey().isWrapped = false, want true") - } - - if sig == nil { - t.Fatal("decodeWrappedAuthkey().sig = nil, want non-nil signature") - } - sigHash := sig.SigHash() - if !ed25519.Verify(sig.KeyID, sigHash[:], sig.Signature) { - t.Error("signature failed to verify") - } - - // Make sure the private is correct by using it. - someSig := ed25519.Sign(priv, []byte{1, 2, 3, 4}) - if !ed25519.Verify(sig.WrappingPubkey, []byte{1, 2, 3, 4}, someSig) { - t.Error("failed to use priv") - } - -} - -func TestResignNKS(t *testing.T) { - // Tailnet lock keypair of a signing node. - authPub, authPriv := testingKey25519(t, 1) - authKey := Key{Kind: Key25519, Public: authPub, Votes: 2} - - // Node's own tailnet lock key used to sign rotation signatures. - tlPriv := key.NewNLPrivate() - - // The original (oldest) node key, signed by a signing node. - origNode := key.NewNode() - origPub, _ := origNode.Public().MarshalBinary() - - // The original signature for the old node key, signed by - // the network-lock key. - directSig := NodeKeySignature{ - SigKind: SigDirect, - KeyID: authKey.MustID(), - Pubkey: origPub, - WrappingPubkey: tlPriv.Public().Verifier(), - } - sigHash := directSig.SigHash() - directSig.Signature = ed25519.Sign(authPriv, sigHash[:]) - if err := directSig.verifySignature(origNode.Public(), authKey); err != nil { - t.Fatalf("verifySignature(origNode) failed: %v", err) - } - - // Generate a bunch of node keys to be used by tests. - var nodeKeys []key.NodePublic - for range 20 { - n := key.NewNode() - nodeKeys = append(nodeKeys, n.Public()) - } - - // mkSig creates a signature chain starting with a direct signature - // with rotation signatures matching provided keys (from the nodeKeys slice). - mkSig := func(prevKeyIDs ...int) tkatype.MarshaledSignature { - sig := &directSig - for _, i := range prevKeyIDs { - pk, _ := nodeKeys[i].MarshalBinary() - sig = &NodeKeySignature{ - SigKind: SigRotation, - Pubkey: pk, - Nested: sig, - } - var err error - sig.Signature, err = tlPriv.SignNKS(sig.SigHash()) - if err != nil { - t.Error(err) - } - } - return sig.Serialize() - } - - tests := []struct { - name string - oldSig tkatype.MarshaledSignature - wantPrevNodeKeys []key.NodePublic - }{ - { - name: "first-rotation", - oldSig: directSig.Serialize(), - wantPrevNodeKeys: []key.NodePublic{origNode.Public()}, - }, - { - name: "second-rotation", - oldSig: mkSig(0), - wantPrevNodeKeys: []key.NodePublic{nodeKeys[0], origNode.Public()}, - }, - { - name: "truncate-chain", - oldSig: mkSig(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14), - wantPrevNodeKeys: []key.NodePublic{ - nodeKeys[14], - nodeKeys[13], - nodeKeys[12], - nodeKeys[11], - nodeKeys[10], - nodeKeys[9], - nodeKeys[8], - nodeKeys[7], - nodeKeys[6], - nodeKeys[5], - nodeKeys[4], - nodeKeys[3], - nodeKeys[2], - nodeKeys[1], - origNode.Public(), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - newNode := key.NewNode() - got, err := ResignNKS(tlPriv, newNode.Public(), tt.oldSig) - if err != nil { - t.Fatalf("ResignNKS() error = %v", err) - } - var gotSig NodeKeySignature - if err := gotSig.Unserialize(got); err != nil { - t.Fatalf("Unserialize() failed: %v", err) - } - if err := gotSig.verifySignature(newNode.Public(), authKey); err != nil { - t.Errorf("verifySignature(newNode) error: %v", err) - } - - rd, err := gotSig.rotationDetails() - if err != nil { - t.Fatalf("rotationDetails() error = %v", err) - } - if sigChainLength(gotSig) != len(tt.wantPrevNodeKeys)+1 { - t.Errorf("sigChainLength() = %v, want %v", sigChainLength(gotSig), len(tt.wantPrevNodeKeys)+1) - } - if diff := cmp.Diff(tt.wantPrevNodeKeys, rd.PrevNodeKeys, cmpopts.EquateComparable(key.NodePublic{})); diff != "" { - t.Errorf("PrevNodeKeys mismatch (-want +got):\n%s", diff) - } - }) - } -} - -func sigChainLength(s NodeKeySignature) int { - if s.Nested != nil { - return 1 + sigChainLength(*s.Nested) - } - return 1 -} diff --git a/tka/state.go b/tka/state.go index 0a459bd9a1b24..2cb8912a5e0fa 100644 --- a/tka/state.go +++ b/tka/state.go @@ -8,8 +8,8 @@ import ( "errors" "fmt" + "github.com/sagernet/tailscale/types/tkatype" "golang.org/x/crypto/argon2" - "tailscale.com/types/tkatype" ) // ErrNoSuchKey is returned if the key referenced by a KeyID does not exist. diff --git a/tka/state_test.go b/tka/state_test.go deleted file mode 100644 index 060bd9350dd06..0000000000000 --- a/tka/state_test.go +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "bytes" - "encoding/hex" - "errors" - "testing" - - "github.com/fxamacker/cbor/v2" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" -) - -func fromHex(in string) []byte { - out, err := hex.DecodeString(in) - if err != nil { - panic(err) - } - return out -} - -func hashFromHex(in string) *AUMHash { - var out AUMHash - copy(out[:], fromHex(in)) - return &out -} - -func TestCloneState(t *testing.T) { - tcs := []struct { - Name string - State State - }{ - { - "Empty", - State{}, - }, - { - "Key", - State{ - Keys: []Key{{Kind: Key25519, Votes: 2, Public: []byte{5, 6, 7, 8}, Meta: map[string]string{"a": "b"}}}, - }, - }, - { - "StateID", - State{ - StateID1: 42, - StateID2: 22, - }, - }, - { - "DisablementSecrets", - State{ - DisablementSecrets: [][]byte{ - {1, 2, 3, 4}, - {5, 6, 7, 8}, - }, - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.Name, func(t *testing.T) { - if diff := cmp.Diff(tc.State, tc.State.Clone()); diff != "" { - t.Errorf("output state differs (-want, +got):\n%s", diff) - } - - // Make sure the cloned State is the same even after - // an encode + decode into + from CBOR. - t.Run("cbor", func(t *testing.T) { - out := bytes.NewBuffer(nil) - encoder, err := cbor.CTAP2EncOptions().EncMode() - if err != nil { - t.Fatal(err) - } - if err := encoder.NewEncoder(out).Encode(tc.State.Clone()); err != nil { - t.Fatal(err) - } - - var decodedState State - if err := cbor.Unmarshal(out.Bytes(), &decodedState); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - if diff := cmp.Diff(tc.State, decodedState); diff != "" { - t.Errorf("decoded state differs (-want, +got):\n%s", diff) - } - }) - }) - } -} - -func TestApplyUpdatesChain(t *testing.T) { - intOne := uint(1) - tcs := []struct { - Name string - Updates []AUM - Start State - End State - }{ - { - "AddKey", - []AUM{{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}}, - State{}, - State{ - Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}, - LastAUMHash: hashFromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03"), - }, - }, - { - "RemoveKey", - []AUM{{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2, 3, 4}, PrevAUMHash: fromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03")}}, - State{ - Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}, - LastAUMHash: hashFromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03"), - }, - State{ - LastAUMHash: hashFromHex("15d65756abfafbb592279503f40759898590c9c59056be1e2e9f02684c15ba4b"), - }, - }, - { - "UpdateKey", - []AUM{{MessageKind: AUMUpdateKey, KeyID: []byte{1, 2, 3, 4}, Votes: &intOne, Meta: map[string]string{"a": "b"}, PrevAUMHash: fromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03")}}, - State{ - Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}, - LastAUMHash: hashFromHex("53898e4311d0b6087fcbb871563868a16c629d9267df851fcfa7b52b31d2bd03"), - }, - State{ - LastAUMHash: hashFromHex("d55458a9c3ed6997439ba5a18b9b62d2c6e5e0c1bb4c61409e92a1281a3b458d"), - Keys: []Key{{Kind: Key25519, Votes: 1, Meta: map[string]string{"a": "b"}, Public: []byte{1, 2, 3, 4}}}, - }, - }, - { - "ChainedKeyUpdates", - []AUM{ - {MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{5, 6, 7, 8}}}, - {MessageKind: AUMRemoveKey, KeyID: []byte{1, 2, 3, 4}, PrevAUMHash: fromHex("f09bda3bb7cf6756ea9adc25770aede4b3ca8142949d6ef5ca0add29af912fd4")}, - }, - State{ - Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}, - }, - State{ - Keys: []Key{{Kind: Key25519, Public: []byte{5, 6, 7, 8}}}, - LastAUMHash: hashFromHex("218165fe5f757304b9deaff4ac742890364f5f509e533c74e80e0ce35e44ee1d"), - }, - }, - { - "Checkpoint", - []AUM{ - {MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{5, 6, 7, 8}}}, - {MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}, - }, PrevAUMHash: fromHex("f09bda3bb7cf6756ea9adc25770aede4b3ca8142949d6ef5ca0add29af912fd4")}, - }, - State{DisablementSecrets: [][]byte{{1, 2, 3, 4}}}, - State{ - Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}, - LastAUMHash: hashFromHex("57343671da5eea3cfb502954e976e8028bffd3540b50a043b2a65a8d8d8217d0"), - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.Name, func(t *testing.T) { - state := tc.Start - for i := range tc.Updates { - var err error - // t.Logf("update[%d] start-state = %+v", i, state) - state, err = state.applyVerifiedAUM(tc.Updates[i]) - if err != nil { - t.Fatalf("Apply message[%d] failed: %v", i, err) - } - // t.Logf("update[%d] end-state = %+v", i, state) - - updateHash := tc.Updates[i].Hash() - if got, want := *state.LastAUMHash, updateHash[:]; !bytes.Equal(got[:], want) { - t.Errorf("expected state.LastAUMHash = %x (update %d), got %x", want, i, got) - } - } - - if diff := cmp.Diff(tc.End, state, cmpopts.EquateEmpty()); diff != "" { - t.Errorf("output state differs (+got, -want):\n%s", diff) - } - }) - } -} - -func TestApplyUpdateErrors(t *testing.T) { - tooLargeVotes := uint(99999) - tcs := []struct { - Name string - Updates []AUM - Start State - Error error - }{ - { - "AddKey exists", - []AUM{{MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}}, - State{Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}}, - errors.New("key already exists"), - }, - { - "RemoveKey notfound", - []AUM{{MessageKind: AUMRemoveKey, Key: &Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}}, - State{}, - ErrNoSuchKey, - }, - { - "UpdateKey notfound", - []AUM{{MessageKind: AUMUpdateKey, KeyID: []byte{1}}}, - State{}, - ErrNoSuchKey, - }, - { - "UpdateKey now fails validation", - []AUM{{MessageKind: AUMUpdateKey, KeyID: []byte{1}, Votes: &tooLargeVotes}}, - State{Keys: []Key{{Kind: Key25519, Public: []byte{1}}}}, - errors.New("updated key fails validation: excessive key weight: 99999 > 4096"), - }, - { - "Bad lastAUMHash", - []AUM{ - {MessageKind: AUMAddKey, Key: &Key{Kind: Key25519, Public: []byte{5, 6, 7, 8}}}, - {MessageKind: AUMRemoveKey, KeyID: []byte{1, 2, 3, 4}, PrevAUMHash: fromHex("1234")}, - }, - State{ - Keys: []Key{{Kind: Key25519, Public: []byte{1, 2, 3, 4}}}, - }, - errors.New("parent AUMHash mismatch"), - }, - { - "Bad StateID", - []AUM{{MessageKind: AUMCheckpoint, State: &State{StateID1: 1}}}, - State{Keys: []Key{{Kind: Key25519, Public: []byte{1}}}, StateID1: 42}, - errors.New("checkpointed state has an incorrect stateID"), - }, - } - - for _, tc := range tcs { - t.Run(tc.Name, func(t *testing.T) { - state := tc.Start - for i := range tc.Updates { - var err error - // t.Logf("update[%d] start-state = %+v", i, state) - state, err = state.applyVerifiedAUM(tc.Updates[i]) - if err != nil { - if err.Error() != tc.Error.Error() { - t.Errorf("state[%d].Err = %v, want %v", i, err, tc.Error) - } else { - return - } - } - // t.Logf("update[%d] end-state = %+v", i, state) - } - - t.Errorf("did not error, expected %v", tc.Error) - }) - } -} diff --git a/tka/sync_test.go b/tka/sync_test.go deleted file mode 100644 index 7250eacf7d143..0000000000000 --- a/tka/sync_test.go +++ /dev/null @@ -1,377 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "bytes" - "strconv" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestSyncOffer(t *testing.T) { - c := newTestchain(t, ` - A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> A9 -> A10 - A10 -> A11 -> A12 -> A13 -> A14 -> A15 -> A16 -> A17 -> A18 - A18 -> A19 -> A20 -> A21 -> A22 -> A23 -> A24 -> A25 - `) - storage := c.Chonk() - a, err := Open(storage) - if err != nil { - t.Fatal(err) - } - got, err := a.SyncOffer(storage) - if err != nil { - t.Fatal(err) - } - - // A SyncOffer includes a selection of AUMs going backwards in the tree, - // progressively skipping more and more each iteration. - want := SyncOffer{ - Head: c.AUMHashes["A25"], - Ancestors: []AUMHash{ - c.AUMHashes["A"+strconv.Itoa(25-ancestorsSkipStart)], - c.AUMHashes["A"+strconv.Itoa(25-ancestorsSkipStart< A2 - // Node 2 has: A1 -> A2 -> A3 -> A4 - c := newTestchain(t, ` - A1 -> A2 -> A3 -> A4 - `) - a1H, a2H := c.AUMHashes["A1"], c.AUMHashes["A2"] - - chonk1 := c.ChonkWith("A1", "A2") - n1, err := Open(chonk1) - if err != nil { - t.Fatal(err) - } - offer1, err := n1.SyncOffer(chonk1) - if err != nil { - t.Fatal(err) - } - - chonk2 := c.Chonk() // All AUMs - n2, err := Open(chonk2) - if err != nil { - t.Fatal(err) - } - offer2, err := n2.SyncOffer(chonk2) - if err != nil { - t.Fatal(err) - } - - // Node 1 only knows about the first two nodes, so the head of n2 is - // alien to it. - t.Run("n1", func(t *testing.T) { - got, err := computeSyncIntersection(chonk1, offer1, offer2) - if err != nil { - t.Fatalf("computeSyncIntersection() failed: %v", err) - } - want := &intersection{ - tailIntersection: &a1H, - } - if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" { - t.Errorf("intersection diff (-want, +got):\n%s", diff) - } - }) - - // Node 2 knows about the full chain, so it can see that the head of n1 - // intersects with a subset of its chain (a Head Intersection). - t.Run("n2", func(t *testing.T) { - got, err := computeSyncIntersection(chonk2, offer2, offer1) - if err != nil { - t.Fatalf("computeSyncIntersection() failed: %v", err) - } - want := &intersection{ - headIntersection: &a2H, - } - if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" { - t.Errorf("intersection diff (-want, +got):\n%s", diff) - } - }) -} - -func TestComputeSyncIntersection_ForkSmallDiff(t *testing.T) { - // The number of nodes in the chain is longer than ancestorSkipStart, - // so that during sync both nodes are able to find a common ancestor - // which was later than A1. - - c := newTestchain(t, ` - A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> A9 -> A10 - | -> F1 - // Make F1 different to A9. - // hashSeed is chosen such that the hash is higher than A9. - F1.hashSeed = 7 - `) - // Node 1 has: A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> F1 - // Node 2 has: A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> A9 -> A10 - f1H, a9H := c.AUMHashes["F1"], c.AUMHashes["A9"] - - if bytes.Compare(f1H[:], a9H[:]) < 0 { - t.Fatal("failed assert: h(a9) > h(f1H)\nTweak hashSeed till this passes") - } - - chonk1 := c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "F1") - n1, err := Open(chonk1) - if err != nil { - t.Fatal(err) - } - offer1, err := n1.SyncOffer(chonk1) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(SyncOffer{ - Head: c.AUMHashes["F1"], - Ancestors: []AUMHash{ - c.AUMHashes["A"+strconv.Itoa(9-ancestorsSkipStart)], - c.AUMHashes["A1"], - }, - }, offer1); diff != "" { - t.Errorf("offer1 diff (-want, +got):\n%s", diff) - } - - chonk2 := c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10") - n2, err := Open(chonk2) - if err != nil { - t.Fatal(err) - } - offer2, err := n2.SyncOffer(chonk2) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(SyncOffer{ - Head: c.AUMHashes["A10"], - Ancestors: []AUMHash{ - c.AUMHashes["A"+strconv.Itoa(10-ancestorsSkipStart)], - c.AUMHashes["A1"], - }, - }, offer2); diff != "" { - t.Errorf("offer2 diff (-want, +got):\n%s", diff) - } - - // Node 1 only knows about the first eight nodes, so the head of n2 is - // alien to it. - t.Run("n1", func(t *testing.T) { - // n2 has 10 nodes, so the first common ancestor should be 10-ancestorsSkipStart - wantIntersection := c.AUMHashes["A"+strconv.Itoa(10-ancestorsSkipStart)] - - got, err := computeSyncIntersection(chonk1, offer1, offer2) - if err != nil { - t.Fatalf("computeSyncIntersection() failed: %v", err) - } - want := &intersection{ - tailIntersection: &wantIntersection, - } - if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" { - t.Errorf("intersection diff (-want, +got):\n%s", diff) - } - }) - - // Node 2 knows about the full chain but doesn't recognize the head. - t.Run("n2", func(t *testing.T) { - // n1 has 9 nodes, so the first common ancestor should be 9-ancestorsSkipStart - wantIntersection := c.AUMHashes["A"+strconv.Itoa(9-ancestorsSkipStart)] - - got, err := computeSyncIntersection(chonk2, offer2, offer1) - if err != nil { - t.Fatalf("computeSyncIntersection() failed: %v", err) - } - want := &intersection{ - tailIntersection: &wantIntersection, - } - if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" { - t.Errorf("intersection diff (-want, +got):\n%s", diff) - } - }) -} - -func TestMissingAUMs_FastForward(t *testing.T) { - // Node 1 has: A1 -> A2 - // Node 2 has: A1 -> A2 -> A3 -> A4 - c := newTestchain(t, ` - A1 -> A2 -> A3 -> A4 - A1.hashSeed = 1 - A2.hashSeed = 2 - A3.hashSeed = 3 - A4.hashSeed = 4 - `) - - chonk1 := c.ChonkWith("A1", "A2") - n1, err := Open(chonk1) - if err != nil { - t.Fatal(err) - } - offer1, err := n1.SyncOffer(chonk1) - if err != nil { - t.Fatal(err) - } - - chonk2 := c.Chonk() // All AUMs - n2, err := Open(chonk2) - if err != nil { - t.Fatal(err) - } - offer2, err := n2.SyncOffer(chonk2) - if err != nil { - t.Fatal(err) - } - - // Node 1 only knows about the first two nodes, so the head of n2 is - // alien to it. As such, it should send history from the newest ancestor, - // A1 (if the chain was longer there would be one in the middle). - t.Run("n1", func(t *testing.T) { - got, err := n1.MissingAUMs(chonk1, offer2) - if err != nil { - t.Fatalf("MissingAUMs() failed: %v", err) - } - - // Both sides have A1, so the only AUM that n2 might not have is - // A2. - want := []AUM{c.AUMs["A2"]} - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff) - } - }) - - // Node 2 knows about the full chain, so it can see that the head of n1 - // intersects with a subset of its chain (a Head Intersection). - t.Run("n2", func(t *testing.T) { - got, err := n2.MissingAUMs(chonk2, offer1) - if err != nil { - t.Fatalf("MissingAUMs() failed: %v", err) - } - - want := []AUM{ - c.AUMs["A3"], - c.AUMs["A4"], - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff) - } - }) -} - -func TestMissingAUMs_Fork(t *testing.T) { - // Node 1 has: A1 -> A2 -> A3 -> F1 - // Node 2 has: A1 -> A2 -> A3 -> A4 - c := newTestchain(t, ` - A1 -> A2 -> A3 -> A4 - | -> F1 - A1.hashSeed = 1 - A2.hashSeed = 2 - A3.hashSeed = 3 - A4.hashSeed = 4 - `) - - chonk1 := c.ChonkWith("A1", "A2", "A3", "F1") - n1, err := Open(chonk1) - if err != nil { - t.Fatal(err) - } - offer1, err := n1.SyncOffer(chonk1) - if err != nil { - t.Fatal(err) - } - - chonk2 := c.ChonkWith("A1", "A2", "A3", "A4") - n2, err := Open(chonk2) - if err != nil { - t.Fatal(err) - } - offer2, err := n2.SyncOffer(chonk2) - if err != nil { - t.Fatal(err) - } - - t.Run("n1", func(t *testing.T) { - got, err := n1.MissingAUMs(chonk1, offer2) - if err != nil { - t.Fatalf("MissingAUMs() failed: %v", err) - } - - // Both sides have A1, so n1 will send everything it knows from - // there to head. - want := []AUM{ - c.AUMs["A2"], - c.AUMs["A3"], - c.AUMs["F1"], - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff) - } - }) - - t.Run("n2", func(t *testing.T) { - got, err := n2.MissingAUMs(chonk2, offer1) - if err != nil { - t.Fatalf("MissingAUMs() failed: %v", err) - } - - // Both sides have A1, so n2 will send everything it knows from - // there to head. - want := []AUM{ - c.AUMs["A2"], - c.AUMs["A3"], - c.AUMs["A4"], - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff) - } - }) -} - -func TestSyncSimpleE2E(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - c := newTestchain(t, ` - G1 -> L1 -> L2 -> L3 - G1.template = genesis - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optKey("key", key, priv), - optSignAllUsing("key")) - - nodeStorage := &Mem{} - node, err := Bootstrap(nodeStorage, c.AUMs["G1"]) - if err != nil { - t.Fatalf("node Bootstrap() failed: %v", err) - } - controlStorage := c.Chonk() - control, err := Open(controlStorage) - if err != nil { - t.Fatalf("control Open() failed: %v", err) - } - - // Control knows the full chain, node only knows the genesis. Lets see - // if they can sync. - nodeOffer, err := node.SyncOffer(nodeStorage) - if err != nil { - t.Fatal(err) - } - controlAUMs, err := control.MissingAUMs(controlStorage, nodeOffer) - if err != nil { - t.Fatalf("control.MissingAUMs(%v) failed: %v", nodeOffer, err) - } - if err := node.Inform(nodeStorage, controlAUMs); err != nil { - t.Fatalf("node.Inform(%v) failed: %v", controlAUMs, err) - } - - if cHash, nHash := control.Head(), node.Head(); cHash != nHash { - t.Errorf("node & control are not synced: c=%x, n=%x", cHash, nHash) - } -} diff --git a/tka/tailchonk.go b/tka/tailchonk.go index 32d2215dec9a1..d7f2a0e1a79f2 100644 --- a/tka/tailchonk.go +++ b/tka/tailchonk.go @@ -13,7 +13,7 @@ import ( "time" "github.com/fxamacker/cbor/v2" - "tailscale.com/atomicfile" + "github.com/sagernet/tailscale/atomicfile" ) // Chonk implementations provide durable storage for AUMs and other diff --git a/tka/tailchonk_test.go b/tka/tailchonk_test.go deleted file mode 100644 index 86d5642a3bd10..0000000000000 --- a/tka/tailchonk_test.go +++ /dev/null @@ -1,693 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "golang.org/x/crypto/blake2s" -) - -// randHash derives a fake blake2s hash from the test name -// and the given seed. -func randHash(t *testing.T, seed int64) [blake2s.Size]byte { - var out [blake2s.Size]byte - testingRand(t, seed).Read(out[:]) - return out -} - -func TestImplementsChonk(t *testing.T) { - impls := []Chonk{&Mem{}, &FS{}} - t.Logf("chonks: %v", impls) -} - -func TestTailchonk_ChildAUMs(t *testing.T) { - for _, chonk := range []Chonk{&Mem{}, &FS{base: t.TempDir()}} { - t.Run(fmt.Sprintf("%T", chonk), func(t *testing.T) { - parentHash := randHash(t, 1) - data := []AUM{ - { - MessageKind: AUMRemoveKey, - KeyID: []byte{1, 2}, - PrevAUMHash: parentHash[:], - }, - { - MessageKind: AUMRemoveKey, - KeyID: []byte{3, 4}, - PrevAUMHash: parentHash[:], - }, - } - - if err := chonk.CommitVerifiedAUMs(data); err != nil { - t.Fatalf("CommitVerifiedAUMs failed: %v", err) - } - stored, err := chonk.ChildAUMs(parentHash) - if err != nil { - t.Fatalf("ChildAUMs failed: %v", err) - } - if diff := cmp.Diff(data, stored); diff != "" { - t.Errorf("stored AUM differs (-want, +got):\n%s", diff) - } - }) - } -} - -func TestTailchonk_AUMMissing(t *testing.T) { - for _, chonk := range []Chonk{&Mem{}, &FS{base: t.TempDir()}} { - t.Run(fmt.Sprintf("%T", chonk), func(t *testing.T) { - var notExists AUMHash - notExists[:][0] = 42 - if _, err := chonk.AUM(notExists); err != os.ErrNotExist { - t.Errorf("chonk.AUM(notExists).err = %v, want %v", err, os.ErrNotExist) - } - }) - } -} - -func TestTailchonkMem_Orphans(t *testing.T) { - chonk := Mem{} - - parentHash := randHash(t, 1) - orphan := AUM{MessageKind: AUMNoOp} - aums := []AUM{ - orphan, - // A parent is specified, so we shouldnt see it in GetOrphans() - { - MessageKind: AUMRemoveKey, - KeyID: []byte{3, 4}, - PrevAUMHash: parentHash[:], - }, - } - if err := chonk.CommitVerifiedAUMs(aums); err != nil { - t.Fatalf("CommitVerifiedAUMs failed: %v", err) - } - - stored, err := chonk.Orphans() - if err != nil { - t.Fatalf("Orphans failed: %v", err) - } - if diff := cmp.Diff([]AUM{orphan}, stored); diff != "" { - t.Errorf("stored AUM differs (-want, +got):\n%s", diff) - } -} - -func TestTailchonk_ReadChainFromHead(t *testing.T) { - for _, chonk := range []Chonk{&Mem{}, &FS{base: t.TempDir()}} { - - t.Run(fmt.Sprintf("%T", chonk), func(t *testing.T) { - genesis := AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}} - gHash := genesis.Hash() - intermediate := AUM{PrevAUMHash: gHash[:]} - iHash := intermediate.Hash() - leaf := AUM{PrevAUMHash: iHash[:]} - - commitSet := []AUM{ - genesis, - intermediate, - leaf, - } - if err := chonk.CommitVerifiedAUMs(commitSet); err != nil { - t.Fatalf("CommitVerifiedAUMs failed: %v", err) - } - // t.Logf("genesis hash = %X", genesis.Hash()) - // t.Logf("intermediate hash = %X", intermediate.Hash()) - // t.Logf("leaf hash = %X", leaf.Hash()) - - // Read the chain from the leaf backwards. - gotLeafs, err := chonk.Heads() - if err != nil { - t.Fatalf("Heads failed: %v", err) - } - if diff := cmp.Diff([]AUM{leaf}, gotLeafs); diff != "" { - t.Fatalf("leaf AUM differs (-want, +got):\n%s", diff) - } - - parent, _ := gotLeafs[0].Parent() - gotIntermediate, err := chonk.AUM(parent) - if err != nil { - t.Fatalf("AUM() failed: %v", err) - } - if diff := cmp.Diff(intermediate, gotIntermediate); diff != "" { - t.Errorf("intermediate AUM differs (-want, +got):\n%s", diff) - } - - parent, _ = gotIntermediate.Parent() - gotGenesis, err := chonk.AUM(parent) - if err != nil { - t.Fatalf("AUM() failed: %v", err) - } - if diff := cmp.Diff(genesis, gotGenesis); diff != "" { - t.Errorf("genesis AUM differs (-want, +got):\n%s", diff) - } - }) - } -} - -func TestTailchonkFS_Commit(t *testing.T) { - chonk := &FS{base: t.TempDir()} - parentHash := randHash(t, 1) - aum := AUM{MessageKind: AUMNoOp, PrevAUMHash: parentHash[:]} - - if err := chonk.CommitVerifiedAUMs([]AUM{aum}); err != nil { - t.Fatal(err) - } - - dir, base := chonk.aumDir(aum.Hash()) - if got, want := dir, filepath.Join(chonk.base, "PD"); got != want { - t.Errorf("aum dir=%s, want %s", got, want) - } - if want := "PD57DVP6GKC76OOZMXFFZUSOEFQXOLAVT7N2ZM5KB3HDIMCANF4A"; base != want { - t.Errorf("aum base=%s, want %s", base, want) - } - if _, err := os.Stat(filepath.Join(dir, base)); err != nil { - t.Errorf("stat of AUM file failed: %v", err) - } - if _, err := os.Stat(filepath.Join(chonk.base, "M7", "M7LL2NDB4NKCZIUPVS6RDM2GUOIMW6EEAFVBWMVCPUANQJPHT3SQ")); err != nil { - t.Errorf("stat of AUM parent failed: %v", err) - } - - info, err := chonk.get(aum.Hash()) - if err != nil { - t.Fatal(err) - } - if info.PurgedUnix > 0 { - t.Errorf("recently-created AUM PurgedUnix = %d, want 0", info.PurgedUnix) - } -} - -func TestTailchonkFS_CommitTime(t *testing.T) { - chonk := &FS{base: t.TempDir()} - parentHash := randHash(t, 1) - aum := AUM{MessageKind: AUMNoOp, PrevAUMHash: parentHash[:]} - - if err := chonk.CommitVerifiedAUMs([]AUM{aum}); err != nil { - t.Fatal(err) - } - ct, err := chonk.CommitTime(aum.Hash()) - if err != nil { - t.Fatalf("CommitTime() failed: %v", err) - } - if ct.Before(time.Now().Add(-time.Minute)) || ct.After(time.Now().Add(time.Minute)) { - t.Errorf("commit time was wrong: %v more than a minute off from now (%v)", ct, time.Now()) - } -} - -func TestTailchonkFS_PurgeAUMs(t *testing.T) { - chonk := &FS{base: t.TempDir()} - parentHash := randHash(t, 1) - aum := AUM{MessageKind: AUMNoOp, PrevAUMHash: parentHash[:]} - - if err := chonk.CommitVerifiedAUMs([]AUM{aum}); err != nil { - t.Fatal(err) - } - if err := chonk.PurgeAUMs([]AUMHash{aum.Hash()}); err != nil { - t.Fatal(err) - } - - if _, err := chonk.AUM(aum.Hash()); err != os.ErrNotExist { - t.Errorf("AUM() on purged AUM returned err = %v, want ErrNotExist", err) - } - - info, err := chonk.get(aum.Hash()) - if err != nil { - t.Fatal(err) - } - if info.PurgedUnix == 0 { - t.Errorf("recently-created AUM PurgedUnix = %d, want non-zero", info.PurgedUnix) - } -} - -func TestTailchonkFS_AllAUMs(t *testing.T) { - chonk := &FS{base: t.TempDir()} - genesis := AUM{MessageKind: AUMRemoveKey, KeyID: []byte{1, 2}} - gHash := genesis.Hash() - intermediate := AUM{PrevAUMHash: gHash[:]} - iHash := intermediate.Hash() - leaf := AUM{PrevAUMHash: iHash[:]} - - commitSet := []AUM{ - genesis, - intermediate, - leaf, - } - if err := chonk.CommitVerifiedAUMs(commitSet); err != nil { - t.Fatalf("CommitVerifiedAUMs failed: %v", err) - } - - hashes, err := chonk.AllAUMs() - if err != nil { - t.Fatal(err) - } - hashesLess := func(a, b AUMHash) bool { - return bytes.Compare(a[:], b[:]) < 0 - } - if diff := cmp.Diff([]AUMHash{genesis.Hash(), intermediate.Hash(), leaf.Hash()}, hashes, cmpopts.SortSlices(hashesLess)); diff != "" { - t.Fatalf("AllAUMs() output differs (-want, +got):\n%s", diff) - } -} - -func TestMarkActiveChain(t *testing.T) { - type aumTemplate struct { - AUM AUM - } - - tcs := []struct { - name string - minChain int - chain []aumTemplate - expectLastActiveIdx int // expected lastActiveAncestor, corresponds to an index on chain. - }{ - { - name: "genesis", - minChain: 2, - chain: []aumTemplate{ - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - }, - expectLastActiveIdx: 0, - }, - { - name: "simple truncate", - minChain: 2, - chain: []aumTemplate{ - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - }, - expectLastActiveIdx: 1, - }, - { - name: "long truncate", - minChain: 5, - chain: []aumTemplate{ - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - }, - expectLastActiveIdx: 2, - }, - { - name: "truncate finding checkpoint", - minChain: 2, - chain: []aumTemplate{ - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMAddKey, Key: &Key{}}}, // Should keep searching upwards for a checkpoint - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - {AUM: AUM{MessageKind: AUMCheckpoint, State: &State{}}}, - }, - expectLastActiveIdx: 1, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - verdict := make(map[AUMHash]retainState, len(tc.chain)) - - // Build the state of the tailchonk for tests. - storage := &Mem{} - var prev AUMHash - for i := range tc.chain { - if !prev.IsZero() { - tc.chain[i].AUM.PrevAUMHash = make([]byte, len(prev[:])) - copy(tc.chain[i].AUM.PrevAUMHash, prev[:]) - } - if err := storage.CommitVerifiedAUMs([]AUM{tc.chain[i].AUM}); err != nil { - t.Fatal(err) - } - - h := tc.chain[i].AUM.Hash() - prev = h - verdict[h] = 0 - } - - got, err := markActiveChain(storage, verdict, tc.minChain, prev) - if err != nil { - t.Logf("state = %+v", verdict) - t.Fatalf("markActiveChain() failed: %v", err) - } - want := tc.chain[tc.expectLastActiveIdx].AUM.Hash() - if got != want { - t.Logf("state = %+v", verdict) - t.Errorf("lastActiveAncestor = %v, want %v", got, want) - } - - // Make sure the verdict array was marked correctly. - for i := range tc.chain { - h := tc.chain[i].AUM.Hash() - if i >= tc.expectLastActiveIdx { - if (verdict[h] & retainStateActive) == 0 { - t.Errorf("verdict[%v] = %v, want %v set", h, verdict[h], retainStateActive) - } - } else { - if (verdict[h] & retainStateCandidate) == 0 { - t.Errorf("verdict[%v] = %v, want %v set", h, verdict[h], retainStateCandidate) - } - } - } - }) - } -} - -func TestMarkDescendantAUMs(t *testing.T) { - c := newTestchain(t, ` - genesis -> B -> C -> C2 - | -> D - | -> E -> F -> G -> H - | -> E2 - - // tweak seeds so hashes arent identical - C.hashSeed = 1 - D.hashSeed = 2 - E.hashSeed = 3 - E2.hashSeed = 4 - `) - - verdict := make(map[AUMHash]retainState, len(c.AUMs)) - for _, a := range c.AUMs { - verdict[a.Hash()] = 0 - } - - // Mark E & C. - verdict[c.AUMHashes["C"]] = retainStateActive - verdict[c.AUMHashes["E"]] = retainStateActive - - if err := markDescendantAUMs(c.Chonk(), verdict); err != nil { - t.Errorf("markDescendantAUMs() failed: %v", err) - } - - // Make sure the descendants got marked. - hs := c.AUMHashes - for _, h := range []AUMHash{hs["C2"], hs["F"], hs["G"], hs["H"], hs["E2"]} { - if (verdict[h] & retainStateLeaf) == 0 { - t.Errorf("%v was not marked as a descendant", h) - } - } - for _, h := range []AUMHash{hs["genesis"], hs["B"], hs["D"]} { - if (verdict[h] & retainStateLeaf) != 0 { - t.Errorf("%v was marked as a descendant and shouldnt be", h) - } - } -} - -func TestMarkAncestorIntersectionAUMs(t *testing.T) { - fakeState := &State{ - Keys: []Key{{Kind: Key25519, Votes: 1}}, - DisablementSecrets: [][]byte{bytes.Repeat([]byte{1}, 32)}, - } - - tcs := []struct { - name string - chain *testChain - verdicts map[string]retainState - initialAncestor string - wantAncestor string - wantRetained []string - wantDeleted []string - }{ - { - name: "genesis", - chain: newTestchain(t, ` - A - A.template = checkpoint`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState})), - initialAncestor: "A", - wantAncestor: "A", - verdicts: map[string]retainState{ - "A": retainStateActive, - }, - wantRetained: []string{"A"}, - }, - { - name: "no adjustment", - chain: newTestchain(t, ` - DEAD -> A -> B -> C - A.template = checkpoint - B.template = checkpoint`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState})), - initialAncestor: "A", - wantAncestor: "A", - verdicts: map[string]retainState{ - "A": retainStateActive, - "B": retainStateActive, - "C": retainStateActive, - "DEAD": retainStateCandidate, - }, - wantRetained: []string{"A", "B", "C"}, - wantDeleted: []string{"DEAD"}, - }, - { - name: "fork", - chain: newTestchain(t, ` - A -> B -> C -> D - | -> FORK - A.template = checkpoint - C.template = checkpoint - D.template = checkpoint - FORK.hashSeed = 2`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState})), - initialAncestor: "D", - wantAncestor: "C", - verdicts: map[string]retainState{ - "A": retainStateCandidate, - "B": retainStateCandidate, - "C": retainStateCandidate, - "D": retainStateActive, - "FORK": retainStateYoung, - }, - wantRetained: []string{"C", "D", "FORK"}, - wantDeleted: []string{"A", "B"}, - }, - { - name: "fork finding earlier checkpoint", - chain: newTestchain(t, ` - A -> B -> C -> D -> E -> F - | -> FORK - A.template = checkpoint - B.template = checkpoint - E.template = checkpoint - FORK.hashSeed = 2`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState})), - initialAncestor: "E", - wantAncestor: "B", - verdicts: map[string]retainState{ - "A": retainStateCandidate, - "B": retainStateCandidate, - "C": retainStateCandidate, - "D": retainStateCandidate, - "E": retainStateActive, - "F": retainStateActive, - "FORK": retainStateYoung, - }, - wantRetained: []string{"B", "C", "D", "E", "F", "FORK"}, - wantDeleted: []string{"A"}, - }, - { - name: "fork multi", - chain: newTestchain(t, ` - A -> B -> C -> D -> E - | -> DEADFORK - C -> FORK - A.template = checkpoint - C.template = checkpoint - D.template = checkpoint - E.template = checkpoint - FORK.hashSeed = 2 - DEADFORK.hashSeed = 3`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState})), - initialAncestor: "D", - wantAncestor: "C", - verdicts: map[string]retainState{ - "A": retainStateCandidate, - "B": retainStateCandidate, - "C": retainStateCandidate, - "D": retainStateActive, - "E": retainStateActive, - "FORK": retainStateYoung, - "DEADFORK": 0, - }, - wantRetained: []string{"C", "D", "E", "FORK"}, - wantDeleted: []string{"A", "B", "DEADFORK"}, - }, - { - name: "fork multi 2", - chain: newTestchain(t, ` - A -> B -> C -> D -> E -> F -> G - - F -> F1 - D -> F2 - B -> F3 - - A.template = checkpoint - B.template = checkpoint - D.template = checkpoint - F.template = checkpoint - F1.hashSeed = 2 - F2.hashSeed = 3 - F3.hashSeed = 4`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState})), - initialAncestor: "F", - wantAncestor: "B", - verdicts: map[string]retainState{ - "A": retainStateCandidate, - "B": retainStateCandidate, - "C": retainStateCandidate, - "D": retainStateCandidate, - "E": retainStateCandidate, - "F": retainStateActive, - "G": retainStateActive, - "F1": retainStateYoung, - "F2": retainStateYoung, - "F3": retainStateYoung, - }, - wantRetained: []string{"B", "C", "D", "E", "F", "G", "F1", "F2", "F3"}, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - verdict := make(map[AUMHash]retainState, len(tc.verdicts)) - for name, v := range tc.verdicts { - verdict[tc.chain.AUMHashes[name]] = v - } - - got, err := markAncestorIntersectionAUMs(tc.chain.Chonk(), verdict, tc.chain.AUMHashes[tc.initialAncestor]) - if err != nil { - t.Logf("state = %+v", verdict) - t.Fatalf("markAncestorIntersectionAUMs() failed: %v", err) - } - if want := tc.chain.AUMHashes[tc.wantAncestor]; got != want { - t.Logf("state = %+v", verdict) - t.Errorf("lastActiveAncestor = %v, want %v", got, want) - } - - for _, name := range tc.wantRetained { - h := tc.chain.AUMHashes[name] - if v := verdict[h]; v&retainAUMMask == 0 { - t.Errorf("AUM %q was not retained: verdict = %v", name, v) - } - } - for _, name := range tc.wantDeleted { - h := tc.chain.AUMHashes[name] - if v := verdict[h]; v&retainAUMMask != 0 { - t.Errorf("AUM %q was retained: verdict = %v", name, v) - } - } - - if t.Failed() { - for name, hash := range tc.chain.AUMHashes { - t.Logf("AUM[%q] = %v", name, hash) - } - } - }) - } -} - -type compactingChonkFake struct { - Mem - - aumAge map[AUMHash]time.Time - t *testing.T - wantDelete []AUMHash -} - -func (c *compactingChonkFake) AllAUMs() ([]AUMHash, error) { - out := make([]AUMHash, 0, len(c.Mem.aums)) - for h := range c.Mem.aums { - out = append(out, h) - } - return out, nil -} - -func (c *compactingChonkFake) CommitTime(hash AUMHash) (time.Time, error) { - return c.aumAge[hash], nil -} - -func (c *compactingChonkFake) PurgeAUMs(hashes []AUMHash) error { - lessHashes := func(a, b AUMHash) bool { - return bytes.Compare(a[:], b[:]) < 0 - } - if diff := cmp.Diff(c.wantDelete, hashes, cmpopts.SortSlices(lessHashes)); diff != "" { - c.t.Errorf("deletion set differs (-want, +got):\n%s", diff) - } - return nil -} - -// Avoid go vet complaining about copying a lock value -func cloneMem(src, dst *Mem) { - dst.l = sync.RWMutex{} - dst.aums = src.aums - dst.parentIndex = src.parentIndex - dst.lastActiveAncestor = src.lastActiveAncestor -} - -func TestCompact(t *testing.T) { - fakeState := &State{ - Keys: []Key{{Kind: Key25519, Votes: 1}}, - DisablementSecrets: [][]byte{bytes.Repeat([]byte{1}, 32)}, - } - - // A & B are deleted because the new lastActiveAncestor advances beyond them. - // OLD is deleted because it does not match retention criteria, and - // though it is a descendant of the new lastActiveAncestor (C), it is not a - // descendant of a retained AUM. - // G, & H are retained as recent (MinChain=2) ancestors of HEAD. - // E & F are retained because they are between retained AUMs (G+) and - // their newest checkpoint ancestor. - // D is retained because it is the newest checkpoint ancestor from - // MinChain-retained AUMs. - // G2 is retained because it is a descendant of a retained AUM (G). - // F1 is retained because it is new enough by wall-clock time. - // F2 is retained because it is a descendant of a retained AUM (F1). - // C2 is retained because it is between an ancestor checkpoint and - // a retained AUM (F1). - // C is retained because it is the new lastActiveAncestor. It is the - // new lastActiveAncestor because it is the newest common checkpoint - // of all retained AUMs. - c := newTestchain(t, ` - A -> B -> C -> C2 -> D -> E -> F -> G -> H - | -> F1 -> F2 | -> G2 - | -> OLD - - // make {A,B,C,D} compaction candidates - A.template = checkpoint - B.template = checkpoint - C.template = checkpoint - D.template = checkpoint - - // tweak seeds of forks so hashes arent identical - F1.hashSeed = 1 - OLD.hashSeed = 2 - G2.hashSeed = 3 - `, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState})) - - storage := &compactingChonkFake{ - aumAge: map[AUMHash]time.Time{(c.AUMHashes["F1"]): time.Now()}, - t: t, - wantDelete: []AUMHash{c.AUMHashes["A"], c.AUMHashes["B"], c.AUMHashes["OLD"]}, - } - - cloneMem(c.Chonk().(*Mem), &storage.Mem) - - lastActiveAncestor, err := Compact(storage, c.AUMHashes["H"], CompactionOptions{MinChain: 2, MinAge: time.Hour}) - if err != nil { - t.Errorf("Compact() failed: %v", err) - } - if lastActiveAncestor != c.AUMHashes["C"] { - t.Errorf("last active ancestor = %v, want %v", lastActiveAncestor, c.AUMHashes["C"]) - } - - if t.Failed() { - for name, hash := range c.AUMHashes { - t.Logf("AUM[%q] = %v", name, hash) - } - } -} diff --git a/tka/tka.go b/tka/tka.go index 04b712660d270..1bca0a32b42f0 100644 --- a/tka/tka.go +++ b/tka/tka.go @@ -12,9 +12,9 @@ import ( "sort" "github.com/fxamacker/cbor/v2" - "tailscale.com/types/key" - "tailscale.com/types/tkatype" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/tkatype" + "github.com/sagernet/tailscale/util/set" ) // Strict settings for the CBOR decoder. diff --git a/tka/tka_test.go b/tka/tka_test.go deleted file mode 100644 index 9e3c4e79d05bd..0000000000000 --- a/tka/tka_test.go +++ /dev/null @@ -1,654 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tka - -import ( - "bytes" - "testing" - - "github.com/google/go-cmp/cmp" - "tailscale.com/types/key" - "tailscale.com/types/tkatype" -) - -func TestComputeChainCandidates(t *testing.T) { - c := newTestchain(t, ` - G1 -> I1 -> I2 -> I3 -> L2 - | -> L1 | -> L3 - - G2 -> L4 - - // We tweak these AUMs so they are different hashes. - G2.hashSeed = 2 - L1.hashSeed = 2 - L3.hashSeed = 2 - L4.hashSeed = 3 - `) - // Should result in 4 chains: - // G1->L1, G1->L2, G1->L3, G2->L4 - - i1H := c.AUMHashes["I1"] - got, err := computeChainCandidates(c.Chonk(), &i1H, 50) - if err != nil { - t.Fatalf("computeChainCandidates() failed: %v", err) - } - - want := []chain{ - {Oldest: c.AUMs["G2"], Head: c.AUMs["L4"]}, - {Oldest: c.AUMs["G1"], Head: c.AUMs["L3"], chainsThroughActive: true}, - {Oldest: c.AUMs["G1"], Head: c.AUMs["L1"], chainsThroughActive: true}, - {Oldest: c.AUMs["G1"], Head: c.AUMs["L2"], chainsThroughActive: true}, - } - if diff := cmp.Diff(want, got, cmp.AllowUnexported(chain{})); diff != "" { - t.Errorf("chains differ (-want, +got):\n%s", diff) - } -} - -func TestForkResolutionHash(t *testing.T) { - c := newTestchain(t, ` - G1 -> L1 - | -> L2 - - // tweak hashes so L1 & L2 are not identical - L1.hashSeed = 2 - L2.hashSeed = 3 - `) - - got, err := computeActiveChain(c.Chonk(), nil, 50) - if err != nil { - t.Fatalf("computeActiveChain() failed: %v", err) - } - - // The fork with the lowest AUM hash should have been chosen. - l1H := c.AUMHashes["L1"] - l2H := c.AUMHashes["L2"] - want := l1H - if bytes.Compare(l2H[:], l1H[:]) < 0 { - want = l2H - } - - if got := got.Head.Hash(); got != want { - t.Errorf("head was %x, want %x", got, want) - } -} - -func TestForkResolutionSigWeight(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - c := newTestchain(t, ` - G1 -> L1 - | -> L2 - - G1.template = addKey - L1.hashSeed = 11 - L2.signedWith = key - `, - optTemplate("addKey", AUM{MessageKind: AUMAddKey, Key: &key}), - optKey("key", key, priv)) - - l1H := c.AUMHashes["L1"] - l2H := c.AUMHashes["L2"] - if bytes.Compare(l2H[:], l1H[:]) < 0 { - t.Fatal("failed assert: h(l1) > h(l2)\nTweak hashSeed till this passes") - } - - got, err := computeActiveChain(c.Chonk(), nil, 50) - if err != nil { - t.Fatalf("computeActiveChain() failed: %v", err) - } - - // Based on the hash, l1H should be chosen. - // But based on the signature weight (which has higher - // precedence), it should be l2H - want := l2H - if got := got.Head.Hash(); got != want { - t.Errorf("head was %x, want %x", got, want) - } -} - -func TestForkResolutionMessageType(t *testing.T) { - pub, _ := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - c := newTestchain(t, ` - G1 -> L1 - | -> L2 - | -> L3 - - G1.template = addKey - L1.hashSeed = 11 - L2.template = removeKey - L3.hashSeed = 18 - `, - optTemplate("addKey", AUM{MessageKind: AUMAddKey, Key: &key}), - optTemplate("removeKey", AUM{MessageKind: AUMRemoveKey, KeyID: key.MustID()})) - - l1H := c.AUMHashes["L1"] - l2H := c.AUMHashes["L2"] - l3H := c.AUMHashes["L3"] - if bytes.Compare(l2H[:], l1H[:]) < 0 { - t.Fatal("failed assert: h(l1) > h(l2)\nTweak hashSeed till this passes") - } - if bytes.Compare(l2H[:], l3H[:]) < 0 { - t.Fatal("failed assert: h(l3) > h(l2)\nTweak hashSeed till this passes") - } - - got, err := computeActiveChain(c.Chonk(), nil, 50) - if err != nil { - t.Fatalf("computeActiveChain() failed: %v", err) - } - - // Based on the hash, L1 or L3 should be chosen. - // But based on the preference for AUMRemoveKey messages, - // it should be L2. - want := l2H - if got := got.Head.Hash(); got != want { - t.Errorf("head was %x, want %x", got, want) - } -} - -func TestComputeStateAt(t *testing.T) { - pub, _ := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - c := newTestchain(t, ` - G1 -> I1 -> I2 - I1.template = addKey - `, - optTemplate("addKey", AUM{MessageKind: AUMAddKey, Key: &key})) - - // G1 is before the key, so there shouldn't be a key there. - state, err := computeStateAt(c.Chonk(), 500, c.AUMHashes["G1"]) - if err != nil { - t.Fatalf("computeStateAt(G1) failed: %v", err) - } - if _, err := state.GetKey(key.MustID()); err != ErrNoSuchKey { - t.Errorf("expected key to be missing: err = %v", err) - } - if *state.LastAUMHash != c.AUMHashes["G1"] { - t.Errorf("LastAUMHash = %x, want %x", *state.LastAUMHash, c.AUMHashes["G1"]) - } - - // I1 & I2 are after the key, so the computed state should contain - // the key. - for _, wantHash := range []AUMHash{c.AUMHashes["I1"], c.AUMHashes["I2"]} { - state, err = computeStateAt(c.Chonk(), 500, wantHash) - if err != nil { - t.Fatalf("computeStateAt(%X) failed: %v", wantHash, err) - } - if *state.LastAUMHash != wantHash { - t.Errorf("LastAUMHash = %x, want %x", *state.LastAUMHash, wantHash) - } - if _, err := state.GetKey(key.MustID()); err != nil { - t.Errorf("expected key to be present at state: err = %v", err) - } - } -} - -// fakeAUM generates an AUM structure based on the template. -// If parent is provided, PrevAUMHash is set to that value. -// -// If template is an AUM, the returned AUM is based on that. -// If template is an int, a NOOP AUM is returned, and the -// provided int can be used to tweak the resulting hash (needed -// for tests you want one AUM to be 'lower' than another, so that -// that chain is taken based on fork resolution rules). -func fakeAUM(t *testing.T, template any, parent *AUMHash) (AUM, AUMHash) { - if seed, ok := template.(int); ok { - a := AUM{MessageKind: AUMNoOp, KeyID: []byte{byte(seed)}} - if parent != nil { - a.PrevAUMHash = (*parent)[:] - } - h := a.Hash() - return a, h - } - - if a, ok := template.(AUM); ok { - if parent != nil { - a.PrevAUMHash = (*parent)[:] - } - h := a.Hash() - return a, h - } - - panic("template must be an int or an AUM") -} - -func TestOpenAuthority(t *testing.T) { - pub, _ := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - // /- L1 - // G1 - I1 - I2 - I3 -L2 - // \-L3 - // G2 - L4 - // - // We set the previous-known ancestor to G1, so the - // ancestor to start from should be G1. - g1, g1H := fakeAUM(t, AUM{MessageKind: AUMAddKey, Key: &key}, nil) - i1, i1H := fakeAUM(t, 2, &g1H) // AUM{MessageKind: AUMAddKey, Key: &key2} - l1, l1H := fakeAUM(t, 13, &i1H) - - i2, i2H := fakeAUM(t, 2, &i1H) - i3, i3H := fakeAUM(t, 5, &i2H) - l2, l2H := fakeAUM(t, AUM{MessageKind: AUMNoOp, KeyID: []byte{7}, Signatures: []tkatype.Signature{{KeyID: key.MustID()}}}, &i3H) - l3, l3H := fakeAUM(t, 4, &i3H) - - g2, g2H := fakeAUM(t, 8, nil) - l4, _ := fakeAUM(t, 9, &g2H) - - // We make sure that I2 has a lower hash than L1, so - // it should take that path rather than L1. - if bytes.Compare(l1H[:], i2H[:]) < 0 { - t.Fatal("failed assert: h(i2) > h(l1)\nTweak parameters to fakeAUM till this passes") - } - // We make sure L2 has a signature with key, so it should - // take that path over L3. We assert that the L3 hash - // is less than L2 so the test will fail if the signature - // preference logic is broken. - if bytes.Compare(l2H[:], l3H[:]) < 0 { - t.Fatal("failed assert: h(l3) > h(l2)\nTweak parameters to fakeAUM till this passes") - } - - // Construct the state of durable storage. - chonk := &Mem{} - err := chonk.CommitVerifiedAUMs([]AUM{g1, i1, l1, i2, i3, l2, l3, g2, l4}) - if err != nil { - t.Fatal(err) - } - chonk.SetLastActiveAncestor(i1H) - - a, err := Open(chonk) - if err != nil { - t.Fatalf("New() failed: %v", err) - } - // Should include the key added in G1 - if _, err := a.state.GetKey(key.MustID()); err != nil { - t.Errorf("missing G1 key: %v", err) - } - // The head of the chain should be L2. - if a.Head() != l2H { - t.Errorf("head was %x, want %x", a.state.LastAUMHash, l2H) - } -} - -func TestOpenAuthority_EmptyErrors(t *testing.T) { - _, err := Open(&Mem{}) - if err == nil { - t.Error("Expected an error initializing an empty authority, got nil") - } -} - -func TestAuthorityHead(t *testing.T) { - c := newTestchain(t, ` - G1 -> L1 - | -> L2 - - L1.hashSeed = 2 - `) - - a, _ := Open(c.Chonk()) - if got, want := a.head.Hash(), a.Head(); got != want { - t.Errorf("Hash() returned %x, want %x", got, want) - } -} - -func TestAuthorityValidDisablement(t *testing.T) { - pub, _ := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - c := newTestchain(t, ` - G1 -> L1 - - G1.template = genesis - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - ) - - a, _ := Open(c.Chonk()) - if valid := a.ValidDisablement([]byte{1, 2, 3}); !valid { - t.Error("ValidDisablement() returned false, want true") - } -} - -func TestCreateBootstrapAuthority(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - a1, genesisAUM, err := Create(&Mem{}, State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, signer25519(priv)) - if err != nil { - t.Fatalf("Create() failed: %v", err) - } - - a2, err := Bootstrap(&Mem{}, genesisAUM) - if err != nil { - t.Fatalf("Bootstrap() failed: %v", err) - } - - if a1.Head() != a2.Head() { - t.Fatal("created and bootstrapped authority differ") - } - - // Both authorities should trust the key laid down in the genesis state. - if !a1.KeyTrusted(key.MustID()) { - t.Error("a1 did not trust genesis key") - } - if !a2.KeyTrusted(key.MustID()) { - t.Error("a2 did not trust genesis key") - } -} - -func TestAuthorityInformNonLinear(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - c := newTestchain(t, ` - G1 -> L1 - | -> L2 -> L3 - | -> L4 -> L5 - - G1.template = genesis - L1.hashSeed = 3 - L2.hashSeed = 2 - L4.hashSeed = 2 - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optKey("key", key, priv), - optSignAllUsing("key")) - - storage := &Mem{} - a, err := Bootstrap(storage, c.AUMs["G1"]) - if err != nil { - t.Fatalf("Bootstrap() failed: %v", err) - } - - // L2 does not chain from L1, disabling the isHeadChain optimization - // and forcing Inform() to take the slow path. - informAUMs := []AUM{c.AUMs["L1"], c.AUMs["L2"], c.AUMs["L3"], c.AUMs["L4"], c.AUMs["L5"]} - - if err := a.Inform(storage, informAUMs); err != nil { - t.Fatalf("Inform() failed: %v", err) - } - for i, update := range informAUMs { - stored, err := storage.AUM(update.Hash()) - if err != nil { - t.Errorf("reading stored update %d: %v", i, err) - continue - } - if diff := cmp.Diff(update, stored); diff != "" { - t.Errorf("update %d differs (-want, +got):\n%s", i, diff) - } - } - - if a.Head() != c.AUMHashes["L3"] { - t.Fatal("authority did not converge to correct AUM") - } -} - -func TestAuthorityInformLinear(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - c := newTestchain(t, ` - G1 -> L1 -> L2 -> L3 - - G1.template = genesis - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optKey("key", key, priv), - optSignAllUsing("key")) - - storage := &Mem{} - a, err := Bootstrap(storage, c.AUMs["G1"]) - if err != nil { - t.Fatalf("Bootstrap() failed: %v", err) - } - - informAUMs := []AUM{c.AUMs["L1"], c.AUMs["L2"], c.AUMs["L3"]} - - if err := a.Inform(storage, informAUMs); err != nil { - t.Fatalf("Inform() failed: %v", err) - } - for i, update := range informAUMs { - stored, err := storage.AUM(update.Hash()) - if err != nil { - t.Errorf("reading stored update %d: %v", i, err) - continue - } - if diff := cmp.Diff(update, stored); diff != "" { - t.Errorf("update %d differs (-want, +got):\n%s", i, diff) - } - } - - if a.Head() != c.AUMHashes["L3"] { - t.Fatal("authority did not converge to correct AUM") - } -} - -func TestInteropWithNLKey(t *testing.T) { - priv1 := key.NewNLPrivate() - pub1 := priv1.Public() - pub2 := key.NewNLPrivate().Public() - pub3 := key.NewNLPrivate().Public() - - a, _, err := Create(&Mem{}, State{ - Keys: []Key{ - { - Kind: Key25519, - Votes: 1, - Public: pub1.KeyID(), - }, - { - Kind: Key25519, - Votes: 1, - Public: pub2.KeyID(), - }, - }, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }, priv1) - if err != nil { - t.Errorf("tka.Create: %v", err) - return - } - - if !a.KeyTrusted(pub1.KeyID()) { - t.Error("pub1 want trusted, got untrusted") - } - if !a.KeyTrusted(pub2.KeyID()) { - t.Error("pub2 want trusted, got untrusted") - } - if a.KeyTrusted(pub3.KeyID()) { - t.Error("pub3 want untrusted, got trusted") - } -} - -func TestAuthorityCompact(t *testing.T) { - pub, priv := testingKey25519(t, 1) - key := Key{Kind: Key25519, Public: pub, Votes: 2} - - c := newTestchain(t, ` - G -> A -> B -> C -> D -> E - - G.template = genesis - C.template = checkpoint2 - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optTemplate("checkpoint2", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{key}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optKey("key", key, priv), - optSignAllUsing("key")) - - storage := &FS{base: t.TempDir()} - a, err := Bootstrap(storage, c.AUMs["G"]) - if err != nil { - t.Fatalf("Bootstrap() failed: %v", err) - } - a.Inform(storage, []AUM{c.AUMs["A"], c.AUMs["B"], c.AUMs["C"], c.AUMs["D"], c.AUMs["E"]}) - - // Should compact down to C -> D -> E - if err := a.Compact(storage, CompactionOptions{MinChain: 2, MinAge: 1}); err != nil { - t.Fatal(err) - } - if a.oldestAncestor.Hash() != c.AUMHashes["C"] { - t.Errorf("ancestor = %v, want %v", a.oldestAncestor.Hash(), c.AUMHashes["C"]) - } - - // Make sure the stored authority is still openable and resolves to the same state. - stored, err := Open(storage) - if err != nil { - t.Fatalf("Failed to open stored authority: %v", err) - } - if stored.Head() != a.Head() { - t.Errorf("Stored authority head differs: head = %v, want %v", stored.Head(), a.Head()) - } - t.Logf("original ancestor = %v", c.AUMHashes["G"]) - if anc, _ := storage.LastActiveAncestor(); *anc != c.AUMHashes["C"] { - t.Errorf("ancestor = %v, want %v", anc, c.AUMHashes["C"]) - } -} - -func TestFindParentForRewrite(t *testing.T) { - pub, _ := testingKey25519(t, 1) - k1 := Key{Kind: Key25519, Public: pub, Votes: 1} - - pub2, _ := testingKey25519(t, 2) - k2 := Key{Kind: Key25519, Public: pub2, Votes: 1} - k2ID, _ := k2.ID() - pub3, _ := testingKey25519(t, 3) - k3 := Key{Kind: Key25519, Public: pub3, Votes: 1} - - c := newTestchain(t, ` - A -> B -> C -> D -> E - A.template = genesis - B.template = add2 - C.template = add3 - D.template = remove2 - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{k1}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optTemplate("add2", AUM{MessageKind: AUMAddKey, Key: &k2}), - optTemplate("add3", AUM{MessageKind: AUMAddKey, Key: &k3}), - optTemplate("remove2", AUM{MessageKind: AUMRemoveKey, KeyID: k2ID})) - - a, err := Open(c.Chonk()) - if err != nil { - t.Fatal(err) - } - - // k1 was trusted at genesis, so there's no better rewrite parent - // than the genesis. - k1ID, _ := k1.ID() - k1P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k1ID}, k1ID) - if err != nil { - t.Fatalf("FindParentForRewrite(k1) failed: %v", err) - } - if k1P != a.oldestAncestor.Hash() { - t.Errorf("FindParentForRewrite(k1) = %v, want %v", k1P, a.oldestAncestor.Hash()) - } - - // k3 was trusted at C, so B would be an ideal rewrite point. - k3ID, _ := k3.ID() - k3P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k3ID}, k1ID) - if err != nil { - t.Fatalf("FindParentForRewrite(k3) failed: %v", err) - } - if k3P != c.AUMHashes["B"] { - t.Errorf("FindParentForRewrite(k3) = %v, want %v", k3P, c.AUMHashes["B"]) - } - - // k2 was added but then removed, so HEAD is an appropriate rewrite point. - k2P, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k2ID}, k1ID) - if err != nil { - t.Fatalf("FindParentForRewrite(k2) failed: %v", err) - } - if k3P != c.AUMHashes["B"] { - t.Errorf("FindParentForRewrite(k2) = %v, want %v", k2P, a.Head()) - } - - // There's no appropriate point where both k2 and k3 are simultaneously not trusted, - // so the best rewrite point is the genesis AUM. - doubleP, err := a.findParentForRewrite(c.Chonk(), []tkatype.KeyID{k2ID, k3ID}, k1ID) - if err != nil { - t.Fatalf("FindParentForRewrite({k2, k3}) failed: %v", err) - } - if doubleP != a.oldestAncestor.Hash() { - t.Errorf("FindParentForRewrite({k2, k3}) = %v, want %v", doubleP, a.oldestAncestor.Hash()) - } -} - -func TestMakeRetroactiveRevocation(t *testing.T) { - pub, _ := testingKey25519(t, 1) - k1 := Key{Kind: Key25519, Public: pub, Votes: 1} - - pub2, _ := testingKey25519(t, 2) - k2 := Key{Kind: Key25519, Public: pub2, Votes: 1} - pub3, _ := testingKey25519(t, 3) - k3 := Key{Kind: Key25519, Public: pub3, Votes: 1} - - c := newTestchain(t, ` - A -> B -> C -> D - A.template = genesis - C.template = add2 - D.template = add3 - `, - optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{ - Keys: []Key{k1}, - DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})}, - }}), - optTemplate("add2", AUM{MessageKind: AUMAddKey, Key: &k2}), - optTemplate("add3", AUM{MessageKind: AUMAddKey, Key: &k3})) - - a, err := Open(c.Chonk()) - if err != nil { - t.Fatal(err) - } - - // k2 was added by C, so a forking revocation should: - // - have B as a parent - // - trust the remaining keys at the time, k1 & k3. - k1ID, _ := k1.ID() - k2ID, _ := k2.ID() - k3ID, _ := k3.ID() - forkingAUM, err := a.MakeRetroactiveRevocation(c.Chonk(), []tkatype.KeyID{k2ID}, k1ID, AUMHash{}) - if err != nil { - t.Fatalf("MakeRetroactiveRevocation(k2) failed: %v", err) - } - if bHash := c.AUMHashes["B"]; !bytes.Equal(forkingAUM.PrevAUMHash, bHash[:]) { - t.Errorf("forking AUM has parent %v, want %v", forkingAUM.PrevAUMHash, bHash[:]) - } - if _, err := forkingAUM.State.GetKey(k1ID); err != nil { - t.Error("Forked state did not trust k1") - } - if _, err := forkingAUM.State.GetKey(k3ID); err != nil { - t.Error("Forked state did not trust k3") - } - if _, err := forkingAUM.State.GetKey(k2ID); err == nil { - t.Error("Forked state trusted removed-key k2") - } - - // Test that removing all trusted keys results in an error. - _, err = a.MakeRetroactiveRevocation(c.Chonk(), []tkatype.KeyID{k1ID, k2ID, k3ID}, k1ID, AUMHash{}) - if wantErr := "cannot revoke all trusted keys"; err == nil || err.Error() != wantErr { - t.Fatalf("MakeRetroactiveRevocation({k1, k2, k3}) returned %v, expected %q", err, wantErr) - } -} diff --git a/tool/gocross/autoflags.go b/tool/gocross/autoflags.go index b28d3bc5dd26e..3697dd1d0675e 100644 --- a/tool/gocross/autoflags.go +++ b/tool/gocross/autoflags.go @@ -9,7 +9,7 @@ import ( "runtime" "strings" - "tailscale.com/version/mkversion" + "github.com/sagernet/tailscale/version/mkversion" ) // Autoflags adjusts the commandline argv into a new commandline diff --git a/tool/gocross/autoflags_test.go b/tool/gocross/autoflags_test.go deleted file mode 100644 index a0f3edfd2bb68..0000000000000 --- a/tool/gocross/autoflags_test.go +++ /dev/null @@ -1,705 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "reflect" - "testing" - - "tailscale.com/version/mkversion" -) - -var fakeVersion = mkversion.VersionInfo{ - Short: "1.2.3", - Long: "1.2.3-long", - GitHash: "abcd", - OtherHash: "defg", - Xcode: "100.2.3", - Winres: "1,2,3,0", -} - -func TestAutoflags(t *testing.T) { - tests := []struct { - // name convention: "__to___" - name string - env map[string]string - argv []string - goroot string - nativeGOOS string - nativeGOARCH string - - wantEnv map[string]string - envDiff string - wantArgv []string - }{ - { - name: "linux_amd64_to_linux_amd64", - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "./cmd/tailcontrol", - }, - }, - { - name: "install_linux_amd64_to_linux_amd64", - argv: []string{"gocross", "install", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "install", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_linux_riscv64", - env: map[string]string{ - "GOARCH": "riscv64", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=0 (was ) -CGO_LDFLAGS= (was ) -GOARCH=riscv64 (was riscv64) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_freebsd_amd64", - env: map[string]string{ - "GOOS": "freebsd", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=0 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=freebsd (was freebsd) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_linux_amd64_race", - argv: []string{"gocross", "test", "-race", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "test", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "-race", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_windows_amd64", - env: map[string]string{ - "GOOS": "windows", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=0 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=windows (was windows) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg -H windows -s", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_android_amd64", - env: map[string]string{ - "GOOS": "android", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=0 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=android (was android) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_android_amd64_cgo", - env: map[string]string{ - "GOOS": "android", - "CGO_ENABLED": "1", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was 1) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=android (was android) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "darwin_arm64_to_darwin_arm64", - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "darwin", - nativeGOARCH: "arm64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=arm64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=darwin (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,omitidna,omitpemdecrypt", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "darwin_arm64_to_darwin_arm64_empty_goos", - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - env: map[string]string{ - "GOOS": "", - }, - goroot: "/goroot", - nativeGOOS: "darwin", - nativeGOARCH: "arm64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=arm64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=darwin (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,omitidna,omitpemdecrypt", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "darwin_arm64_to_darwin_arm64_empty_goarch", - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - env: map[string]string{ - "GOARCH": "", - }, - goroot: "/goroot", - nativeGOOS: "darwin", - nativeGOARCH: "arm64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=arm64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=darwin (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,omitidna,omitpemdecrypt", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "darwin_arm64_to_darwin_amd64", - env: map[string]string{ - "GOARCH": "amd64", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "darwin", - nativeGOARCH: "arm64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was amd64) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=darwin (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,omitidna,omitpemdecrypt", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "darwin_arm64_to_ios_arm64", - env: map[string]string{ - "GOOS": "ios", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "darwin", - nativeGOARCH: "arm64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=arm64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=ios (was ios) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=1 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,omitidna,omitpemdecrypt", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", - "./cmd/tailcontrol", - }, - }, - { - name: "darwin_arm64_to_darwin_amd64_xcode", - env: map[string]string{ - "GOOS": "darwin", - "GOARCH": "amd64", - "XCODE_VERSION_ACTUAL": "1300", - "MACOSX_DEPLOYMENT_TARGET": "11.3", - "SDKROOT": "/my/sdk/root", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "darwin", - nativeGOARCH: "arm64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g -mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS=-mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was ) -GOARCH=amd64 (was amd64) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=darwin (was darwin) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,omitidna,omitpemdecrypt,ts_macext", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg -w", - "./cmd/tailcontrol", - }, - }, - { - name: "darwin_amd64_to_ios_arm64_xcode", - env: map[string]string{ - "GOOS": "ios", - "GOARCH": "arm64", - "XCODE_VERSION_ACTUAL": "1300", - "IPHONEOS_DEPLOYMENT_TARGET": "15.0", - "SDKROOT": "/my/sdk/root", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "darwin", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g -miphoneos-version-min=15.0 -isysroot /my/sdk/root -arch arm64 (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS=-miphoneos-version-min=15.0 -isysroot /my/sdk/root -arch arm64 (was ) -GOARCH=arm64 (was arm64) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=ios (was ios) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=1 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,omitidna,omitpemdecrypt,ts_macext", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg -w", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_linux_amd64_in_goroot", - argv: []string{"go", "build", "./cmd/tailcontrol"}, - goroot: "/special/toolchain/path", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/special/toolchain/path (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "go", "build", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_list_amd64_to_linux_amd64", - argv: []string{"gocross", "list", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "list", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_linux_amd64_with_extra_glibc_path", - env: map[string]string{ - "GOCROSS_GLIBC_DIR": "/my/glibc/path", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static -L /my/glibc/path'", - "./cmd/tailcontrol", - }, - }, - { - name: "linux_amd64_to_linux_amd64_go_run_tags", - - argv: []string{"go", "run", "./cmd/mkctr", "--tags=foo"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was ) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "go", "run", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "./cmd/mkctr", - "--tags=foo", - }, - }, - { - name: "linux_amd64_to_linux_amd64_custom_toolchain", - env: map[string]string{ - "GOTOOLCHAIN": "go1.30rc5", - }, - argv: []string{"gocross", "build", "./cmd/tailcontrol"}, - goroot: "/goroot", - nativeGOOS: "linux", - nativeGOARCH: "amd64", - - envDiff: `CC=cc (was ) -CGO_CFLAGS=-O3 -std=gnu11 -g (was ) -CGO_ENABLED=1 (was ) -CGO_LDFLAGS= (was ) -GOARCH=amd64 (was ) -GOARM=5 (was ) -GOMIPS=softfloat (was ) -GOOS=linux (was ) -GOROOT=/goroot (was ) -GOTOOLCHAIN=local (was go1.30rc5) -TS_LINK_FAIL_REFLECT=0 (was )`, - wantArgv: []string{ - "gocross", "build", - "-trimpath", - "-tags=tailscale_go,osusergo,netgo", - "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", - "./cmd/tailcontrol", - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - getver := func() mkversion.VersionInfo { return fakeVersion } - env := newEnvironmentForTest(test.env, nil, nil) - - gotArgv, env, err := autoflagsForTest(test.argv, env, test.goroot, test.nativeGOOS, test.nativeGOARCH, getver) - if err != nil { - t.Fatalf("newAutoflagsForTest failed: %v", err) - } - - if diff := env.Diff(); diff != test.envDiff { - t.Errorf("wrong environment diff, got:\n%s\n\nwant:\n%s", diff, test.envDiff) - } - if !reflect.DeepEqual(gotArgv, test.wantArgv) { - t.Errorf("wrong argv:\n got : %s\n want: %s", formatArgv(gotArgv), formatArgv(test.wantArgv)) - } - }) - } -} - -func TestExtractTags(t *testing.T) { - s := func(ss ...string) []string { return ss } - tests := []struct { - name string - cmd string - in []string - filt []string // want filtered - tags []string // want tags - }{ - { - name: "one_hyphen_tags", - cmd: "build", - in: s("foo", "-tags=a,b", "bar"), - filt: s("foo", "bar"), - tags: s("a", "b"), - }, - { - name: "two_hyphen_tags", - cmd: "build", - in: s("foo", "--tags=a,b", "bar"), - filt: s("foo", "bar"), - tags: s("a", "b"), - }, - { - name: "one_hypen_separate_arg", - cmd: "build", - in: s("foo", "-tags", "a,b", "bar"), - filt: s("foo", "bar"), - tags: s("a", "b"), - }, - { - name: "two_hypen_separate_arg", - cmd: "build", - in: s("foo", "--tags", "a,b", "bar"), - filt: s("foo", "bar"), - tags: s("a", "b"), - }, - { - name: "equal_empty", - cmd: "build", - in: s("foo", "--tags=", "bar"), - filt: s("foo", "bar"), - tags: s(), - }, - { - name: "arg_empty", - cmd: "build", - in: s("foo", "--tags", "", "bar"), - filt: s("foo", "bar"), - tags: s(), - }, - { - name: "arg_empty_truncated", - cmd: "build", - in: s("foo", "--tags"), - filt: s("foo"), - tags: s(), - }, - { - name: "go_run_with_program_tags", - cmd: "run", - in: s("--foo", "--tags", "bar", "my/package/name", "--tags", "qux"), - filt: s("--foo", "my/package/name", "--tags", "qux"), - tags: s("bar"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - filt, tags := extractTags(tt.cmd, tt.in) - if !reflect.DeepEqual(filt, tt.filt) { - t.Errorf("extractTags(%q, %q) filtered = %q; want %q", tt.cmd, tt.in, filt, tt.filt) - } - if !reflect.DeepEqual(tags, tt.tags) { - t.Errorf("extractTags(%q, %q) tags = %q; want %q", tt.cmd, tt.in, tags, tt.tags) - } - }) - } -} diff --git a/tool/gocross/env_test.go b/tool/gocross/env_test.go deleted file mode 100644 index 001487bb8e1a6..0000000000000 --- a/tool/gocross/env_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestEnv(t *testing.T) { - - var ( - init = map[string]string{ - "FOO": "bar", - } - - wasSet = map[string]string{} - wasUnset = map[string]bool{} - - setenv = func(k, v string) error { - wasSet[k] = v - return nil - } - unsetenv = func(k string) error { - wasUnset[k] = true - return nil - } - ) - - env := newEnvironmentForTest(init, setenv, unsetenv) - - if got, want := env.Get("FOO", ""), "bar"; got != want { - t.Errorf(`env.Get("FOO") = %q, want %q`, got, want) - } - if got, want := env.IsSet("FOO"), true; got != want { - t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want) - } - - if got, want := env.Get("BAR", "defaultVal"), "defaultVal"; got != want { - t.Errorf(`env.Get("BAR") = %q, want %q`, got, want) - } - if got, want := env.IsSet("BAR"), false; got != want { - t.Errorf(`env.IsSet("BAR") = %v, want %v`, got, want) - } - - env.Set("BAR", "quux") - if got, want := env.Get("BAR", ""), "quux"; got != want { - t.Errorf(`env.Get("BAR") = %q, want %q`, got, want) - } - if got, want := env.IsSet("BAR"), true; got != want { - t.Errorf(`env.IsSet("BAR") = %v, want %v`, got, want) - } - diff := "BAR=quux (was )" - if got := env.Diff(); got != diff { - t.Errorf("env.Diff() = %q, want %q", got, diff) - } - - env.Set("FOO", "foo2") - if got, want := env.Get("FOO", ""), "foo2"; got != want { - t.Errorf(`env.Get("FOO") = %q, want %q`, got, want) - } - if got, want := env.IsSet("FOO"), true; got != want { - t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want) - } - diff = `BAR=quux (was ) -FOO=foo2 (was bar)` - if got := env.Diff(); got != diff { - t.Errorf("env.Diff() = %q, want %q", got, diff) - } - - env.Unset("FOO") - if got, want := env.Get("FOO", "default"), "default"; got != want { - t.Errorf(`env.Get("FOO") = %q, want %q`, got, want) - } - if got, want := env.IsSet("FOO"), false; got != want { - t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want) - } - diff = `BAR=quux (was ) -FOO= (was bar)` - if got := env.Diff(); got != diff { - t.Errorf("env.Diff() = %q, want %q", got, diff) - } - - if err := env.Apply(); err != nil { - t.Fatalf("env.Apply() failed: %v", err) - } - - wantSet := map[string]string{"BAR": "quux"} - wantUnset := map[string]bool{"FOO": true} - - if diff := cmp.Diff(wasSet, wantSet); diff != "" { - t.Errorf("env.Apply didn't set as expected (-got+want):\n%s", diff) - } - if diff := cmp.Diff(wasUnset, wantUnset); diff != "" { - t.Errorf("env.Apply didn't unset as expected (-got+want):\n%s", diff) - } -} diff --git a/tool/gocross/gocross.go b/tool/gocross/gocross.go index 8011c10956c05..d703b348cd39d 100644 --- a/tool/gocross/gocross.go +++ b/tool/gocross/gocross.go @@ -16,8 +16,8 @@ import ( "os" "path/filepath" - "tailscale.com/atomicfile" - "tailscale.com/version" + "github.com/sagernet/tailscale/atomicfile" + "github.com/sagernet/tailscale/version" ) func main() { diff --git a/tool/gocross/gocross_wrapper_test.go b/tool/gocross/gocross_wrapper_test.go deleted file mode 100644 index 2b0f016a29d57..0000000000000 --- a/tool/gocross/gocross_wrapper_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || darwin - -package main - -import ( - "os" - "os/exec" - "strings" - "testing" -) - -func TestGocrossWrapper(t *testing.T) { - for i := range 2 { // once to build gocross; second to test it's cached - cmd := exec.Command("./gocross-wrapper.sh", "version") - cmd.Env = append(os.Environ(), "CI=true", "NOBASHDEBUG=false") // for "set -x" verbosity - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("gocross-wrapper.sh failed: %v\n%s", err, out) - } - if i > 0 && !strings.Contains(string(out), "gocross_ok=1\n") { - t.Errorf("expected to find 'gocross-ok=1'; got output:\n%s", out) - } - } -} diff --git a/tsd/tsd.go b/tsd/tsd.go index acd09560c7601..3e9882bc3cf4a 100644 --- a/tsd/tsd.go +++ b/tsd/tsd.go @@ -21,21 +21,21 @@ import ( "fmt" "reflect" - "tailscale.com/control/controlknobs" - "tailscale.com/drive" - "tailscale.com/health" - "tailscale.com/ipn" - "tailscale.com/ipn/conffile" - "tailscale.com/net/dns" - "tailscale.com/net/netmon" - "tailscale.com/net/tsdial" - "tailscale.com/net/tstun" - "tailscale.com/proxymap" - "tailscale.com/types/netmap" - "tailscale.com/util/usermetric" - "tailscale.com/wgengine" - "tailscale.com/wgengine/magicsock" - "tailscale.com/wgengine/router" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/conffile" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/proxymap" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/util/usermetric" + "github.com/sagernet/tailscale/wgengine" + "github.com/sagernet/tailscale/wgengine/magicsock" + "github.com/sagernet/tailscale/wgengine/router" ) // System contains all the subsystems of a Tailscale node (tailscaled, etc.) diff --git a/tsnet/example/tshello/tshello.go b/tsnet/example/tshello/tshello.go index 0cadcdd837d99..380948cf9a5f4 100644 --- a/tsnet/example/tshello/tshello.go +++ b/tsnet/example/tshello/tshello.go @@ -13,7 +13,7 @@ import ( "net/http" "strings" - "tailscale.com/tsnet" + "github.com/sagernet/tailscale/tsnet" ) var ( diff --git a/tsnet/example/tsnet-funnel/tsnet-funnel.go b/tsnet/example/tsnet-funnel/tsnet-funnel.go index 1dac57a1ebf86..bc56827e477a1 100644 --- a/tsnet/example/tsnet-funnel/tsnet-funnel.go +++ b/tsnet/example/tsnet-funnel/tsnet-funnel.go @@ -15,7 +15,7 @@ import ( "log" "net/http" - "tailscale.com/tsnet" + "github.com/sagernet/tailscale/tsnet" ) func main() { diff --git a/tsnet/example/tsnet-http-client/tsnet-http-client.go b/tsnet/example/tsnet-http-client/tsnet-http-client.go index 9666fe9992745..41861a38447bb 100644 --- a/tsnet/example/tsnet-http-client/tsnet-http-client.go +++ b/tsnet/example/tsnet-http-client/tsnet-http-client.go @@ -11,7 +11,7 @@ import ( "os" "path/filepath" - "tailscale.com/tsnet" + "github.com/sagernet/tailscale/tsnet" ) func main() { diff --git a/tsnet/example/web-client/web-client.go b/tsnet/example/web-client/web-client.go index 541efbaedf3d3..0155500b74ef8 100644 --- a/tsnet/example/web-client/web-client.go +++ b/tsnet/example/web-client/web-client.go @@ -9,8 +9,8 @@ import ( "log" "net/http" - "tailscale.com/client/web" - "tailscale.com/tsnet" + "github.com/sagernet/tailscale/client/web" + "github.com/sagernet/tailscale/tsnet" ) var ( diff --git a/tsnet/example_tshello_test.go b/tsnet/example_tshello_test.go deleted file mode 100644 index d534bcfd1f1d4..0000000000000 --- a/tsnet/example_tshello_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsnet_test - -import ( - "flag" - "fmt" - "html" - "log" - "net/http" - "strings" - - "tailscale.com/tsnet" -) - -func firstLabel(s string) string { - s, _, _ = strings.Cut(s, ".") - return s -} - -// Example_tshello is a full example on using tsnet. When you run this program it will print -// an authentication link. Open it in your favorite web browser and add it to your tailnet -// like any other machine. Open another terminal window and try to ping it: -// -// $ ping tshello -c 2 -// PING tshello (100.105.183.159) 56(84) bytes of data. -// 64 bytes from tshello.your-tailnet.ts.net (100.105.183.159): icmp_seq=1 ttl=64 time=25.0 ms -// 64 bytes from tshello.your-tailnet.ts.net (100.105.183.159): icmp_seq=2 ttl=64 time=1.12 ms -// -// Then connect to it using curl: -// -// $ curl http://tshello -//

Hello, world!

-//

You are Xe from pneuma (100.78.40.86:49214)

-// -// From here you can do anything you want with the Go standard library HTTP stack, or anything -// that is compatible with it (Gin/Gonic, Gorilla/mux, etc.). -func Example_tshello() { - var ( - addr = flag.String("addr", ":80", "address to listen on") - hostname = flag.String("hostname", "tshello", "hostname to use on the tailnet") - ) - - flag.Parse() - s := new(tsnet.Server) - s.Hostname = *hostname - defer s.Close() - ln, err := s.Listen("tcp", *addr) - if err != nil { - log.Fatal(err) - } - defer ln.Close() - - lc, err := s.LocalClient() - if err != nil { - log.Fatal(err) - } - - log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - who, err := lc.WhoIs(r.Context(), r.RemoteAddr) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - fmt.Fprintf(w, "

Hello, tailnet!

\n") - fmt.Fprintf(w, "

You are %s from %s (%s)

", - html.EscapeString(who.UserProfile.LoginName), - html.EscapeString(firstLabel(who.Node.ComputedName)), - r.RemoteAddr) - }))) -} diff --git a/tsnet/example_tsnet_test.go b/tsnet/example_tsnet_test.go deleted file mode 100644 index c5a20ab77fcd5..0000000000000 --- a/tsnet/example_tsnet_test.go +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsnet_test - -import ( - "flag" - "fmt" - "log" - "net/http" - "os" - "path/filepath" - - "tailscale.com/tsnet" -) - -// ExampleServer shows you how to construct a ready-to-use tsnet instance. -func ExampleServer() { - srv := new(tsnet.Server) - if err := srv.Start(); err != nil { - log.Fatalf("can't start tsnet server: %v", err) - } - defer srv.Close() -} - -// ExampleServer_hostname shows you how to set a tsnet server's hostname. -// -// This setting lets you control the host name of your program on your -// tailnet. By default this will be the name of your program (such as foo -// for a program stored at /usr/local/bin/foo). You can also override this -// by setting the Hostname field. -func ExampleServer_hostname() { - srv := &tsnet.Server{ - Hostname: "kirito", - } - - // do something with srv - _ = srv -} - -// ExampleServer_dir shows you how to configure the persistent directory for -// a tsnet application. This is where the Tailscale node information is stored -// so that your application can reconnect to your tailnet when the application -// is restarted. -// -// By default, tsnet will store data in your user configuration directory based -// on the name of the binary. Note that this folder must already exist or tsnet -// calls will fail. -func ExampleServer_dir() { - dir := filepath.Join("/data", "tsnet") - - if err := os.MkdirAll(dir, 0700); err != nil { - log.Fatal(err) - } - - srv := &tsnet.Server{ - Dir: dir, - } - - // do something with srv - _ = srv -} - -// ExampleServer_multipleInstances shows you how to configure multiple instances -// of tsnet per program. This allows you to have multiple Tailscale nodes in the -// same process/container. -func ExampleServer_multipleInstances() { - baseDir := "/data" - var servers []*tsnet.Server - for _, hostname := range []string{"ichika", "nino", "miku", "yotsuba", "itsuki"} { - os.MkdirAll(filepath.Join(baseDir, hostname), 0700) - srv := &tsnet.Server{ - Hostname: hostname, - AuthKey: os.Getenv("TS_AUTHKEY"), - Ephemeral: true, - Dir: filepath.Join(baseDir, hostname), - } - if err := srv.Start(); err != nil { - log.Fatalf("can't start tsnet server: %v", err) - } - servers = append(servers, srv) - } - - // When you're done, close the instances - defer func() { - for _, srv := range servers { - srv.Close() - } - }() -} - -// ExampleServer_ignoreLogsSometimes shows you how to ignore all of the log messages -// written by a tsnet instance, but allows you to opt-into them if a command-line -// flag is set. -func ExampleServer_ignoreLogsSometimes() { - tsnetVerbose := flag.Bool("tsnet-verbose", false, "if set, verbosely log tsnet information") - hostname := flag.String("tsnet-hostname", "hikari", "hostname to use on the tailnet") - - srv := &tsnet.Server{ - Hostname: *hostname, - } - - if *tsnetVerbose { - srv.Logf = log.New(os.Stderr, fmt.Sprintf("[tsnet:%s] ", *hostname), log.LstdFlags).Printf - } -} - -// ExampleServer_HTTPClient shows you how to make HTTP requests over your tailnet. -// -// If you want to make outgoing HTTP connections to resources on your tailnet, use -// the HTTP client that the tsnet.Server exposes. -func ExampleServer_HTTPClient() { - srv := &tsnet.Server{} - cli := srv.HTTPClient() - - resp, err := cli.Get("https://hello.ts.net") - if resp == nil { - log.Fatal(err) - } - // do something with resp - _ = resp -} - -// ExampleServer_Start demonstrates the Start method, which should be called if -// you need to explicitly start it. Note that the Start method is implicitly -// called if needed. -func ExampleServer_Start() { - srv := new(tsnet.Server) - - if err := srv.Start(); err != nil { - log.Fatal(err) - } - - // Be sure to close the server instance at some point. It will stay open until - // either the OS process ends or the server is explicitly closed. - defer srv.Close() -} - -// ExampleServer_Listen shows you how to create a TCP listener on your tailnet and -// then makes an HTTP server on top of that. -func ExampleServer_Listen() { - srv := &tsnet.Server{ - Hostname: "tadaima", - } - - ln, err := srv.Listen("tcp", ":80") - if err != nil { - log.Fatal(err) - } - - log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Hi there! Welcome to the tailnet!") - }))) -} - -// ExampleServer_ListenTLS shows you how to create a TCP listener on your tailnet and -// then makes an HTTPS server on top of that. -func ExampleServer_ListenTLS() { - srv := &tsnet.Server{ - Hostname: "aegis", - } - - ln, err := srv.ListenTLS("tcp", ":443") - if err != nil { - log.Fatal(err) - } - - log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Hi there! Welcome to the tailnet!") - }))) -} - -// ExampleServer_ListenFunnel shows you how to create an HTTPS service on both your tailnet -// and the public internet via Funnel. -func ExampleServer_ListenFunnel() { - srv := &tsnet.Server{ - Hostname: "ophion", - } - - ln, err := srv.ListenFunnel("tcp", ":443") - if err != nil { - log.Fatal(err) - } - - log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Hi there! Welcome to the tailnet!") - }))) -} - -// ExampleServer_ListenFunnel_funnelOnly shows you how to create a funnel-only HTTPS service. -func ExampleServer_ListenFunnel_funnelOnly() { - srv := new(tsnet.Server) - srv.Hostname = "ophion" - ln, err := srv.ListenFunnel("tcp", ":443", tsnet.FunnelOnly()) - if err != nil { - log.Fatal(err) - } - - log.Fatal(http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Hi there! Welcome to the tailnet!") - }))) -} diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 34cab7385558b..ac940b3e8d8b4 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -26,35 +26,34 @@ import ( "sync" "time" - "tailscale.com/client/tailscale" - "tailscale.com/control/controlclient" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/ipnstate" - "tailscale.com/ipn/localapi" - "tailscale.com/ipn/store" - "tailscale.com/ipn/store/mem" - "tailscale.com/logpolicy" - "tailscale.com/logtail" - "tailscale.com/logtail/filch" - "tailscale.com/net/memnet" - "tailscale.com/net/netmon" - "tailscale.com/net/proxymux" - "tailscale.com/net/socks5" - "tailscale.com/net/tsdial" - "tailscale.com/tsd" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/nettype" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/util/testenv" - "tailscale.com/wgengine" - "tailscale.com/wgengine/netstack" + "github.com/sagernet/tailscale/client/tailscale" + "github.com/sagernet/tailscale/control/controlclient" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/ipn" + "github.com/sagernet/tailscale/ipn/ipnlocal" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/ipn/localapi" + "github.com/sagernet/tailscale/ipn/store" + "github.com/sagernet/tailscale/ipn/store/mem" + "github.com/sagernet/tailscale/logpolicy" + "github.com/sagernet/tailscale/logtail" + "github.com/sagernet/tailscale/logtail/filch" + "github.com/sagernet/tailscale/net/memnet" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/proxymux" + "github.com/sagernet/tailscale/net/socks5" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/tsd" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/wgengine" + "github.com/sagernet/tailscale/wgengine/netstack" ) // Server is an embedded Tailscale server. @@ -286,7 +285,6 @@ func (h *localSecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Start connects the server to the tailnet. // Optional: any calls to Dial/Listen will also call Start. func (s *Server) Start() error { - hostinfo.SetPackage("tsnet") s.initOnce.Do(s.doInit) return s.initErr } @@ -631,7 +629,7 @@ func (s *Server) start() (reterr error) { } else if authKey != "" { s.logf("Authkey is set; but state is %v. Ignoring authkey. Re-run with TSNET_FORCE_LOGIN=1 to force use of authkey.", st) } - go s.printAuthURLLoop() + //go s.printAuthURLLoop() // Run the localapi handler, to allow fetching LetsEncrypt certs. lah := localapi.NewHandler(lb, tsLogf, s.logid) diff --git a/tsnet/tsnet_export.go b/tsnet/tsnet_export.go new file mode 100644 index 0000000000000..fdc74265920ac --- /dev/null +++ b/tsnet/tsnet_export.go @@ -0,0 +1,14 @@ +package tsnet + +import ( + "github.com/sagernet/tailscale/ipn/ipnlocal" + "github.com/sagernet/tailscale/wgengine/netstack" +) + +func (s *Server) ExportNetstack() *netstack.Impl { + return s.netstack +} + +func (s *Server) ExportLocalBackend() *ipnlocal.LocalBackend { + return s.lb +} diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go deleted file mode 100644 index 7aebbdd4c39ca..0000000000000 --- a/tsnet/tsnet_test.go +++ /dev/null @@ -1,1178 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsnet - -import ( - "bufio" - "bytes" - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "errors" - "flag" - "fmt" - "io" - "log" - "math/big" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "os" - "path/filepath" - "reflect" - "runtime" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - dto "github.com/prometheus/client_model/go" - "github.com/prometheus/common/expfmt" - "golang.org/x/net/proxy" - "tailscale.com/client/tailscale" - "tailscale.com/cmd/testwrapper/flakytest" - "tailscale.com/health" - "tailscale.com/ipn" - "tailscale.com/ipn/store/mem" - "tailscale.com/net/netns" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/tstest/integration" - "tailscale.com/tstest/integration/testcontrol" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/must" -) - -// TestListener_Server ensures that the listener type always keeps the Server -// method, which is used by some external applications to identify a tsnet.Listener -// from other net.Listeners, as well as access the underlying Server. -func TestListener_Server(t *testing.T) { - s := &Server{} - ln := listener{s: s} - if ln.Server() != s { - t.Errorf("listener.Server() returned %v, want %v", ln.Server(), s) - } -} - -func TestListenerPort(t *testing.T) { - errNone := errors.New("sentinel start error") - - tests := []struct { - network string - addr string - wantErr bool - }{ - {"tcp", ":80", false}, - {"foo", ":80", true}, - {"tcp", ":http", false}, // built-in name to Go; doesn't require cgo, /etc/services - {"tcp", ":https", false}, // built-in name to Go; doesn't require cgo, /etc/services - {"tcp", ":gibberishsdlkfj", true}, - {"tcp", ":%!d(string=80)", true}, // issue 6201 - {"udp", ":80", false}, - {"udp", "100.102.104.108:80", false}, - {"udp", "not-an-ip:80", true}, - {"udp4", ":80", false}, - {"udp4", "100.102.104.108:80", false}, - {"udp4", "not-an-ip:80", true}, - - // Verify network type matches IP - {"tcp4", "1.2.3.4:80", false}, - {"tcp6", "1.2.3.4:80", true}, - {"tcp4", "[12::34]:80", true}, - {"tcp6", "[12::34]:80", false}, - } - for _, tt := range tests { - s := &Server{} - s.initOnce.Do(func() { s.initErr = errNone }) - _, err := s.Listen(tt.network, tt.addr) - gotErr := err != nil && err != errNone - if gotErr != tt.wantErr { - t.Errorf("Listen(%q, %q) error = %v, want %v", tt.network, tt.addr, gotErr, tt.wantErr) - } - } -} - -var verboseDERP = flag.Bool("verbose-derp", false, "if set, print DERP and STUN logs") -var verboseNodes = flag.Bool("verbose-nodes", false, "if set, print tsnet.Server logs") - -func startControl(t *testing.T) (controlURL string, control *testcontrol.Server) { - // Corp#4520: don't use netns for tests. - netns.SetEnabled(false) - t.Cleanup(func() { - netns.SetEnabled(true) - }) - - derpLogf := logger.Discard - if *verboseDERP { - derpLogf = t.Logf - } - derpMap := integration.RunDERPAndSTUN(t, derpLogf, "127.0.0.1") - control = &testcontrol.Server{ - DERPMap: derpMap, - DNSConfig: &tailcfg.DNSConfig{ - Proxied: true, - }, - MagicDNSDomain: "tail-scale.ts.net", - } - control.HTTPTestServer = httptest.NewUnstartedServer(control) - control.HTTPTestServer.Start() - t.Cleanup(control.HTTPTestServer.Close) - controlURL = control.HTTPTestServer.URL - t.Logf("testcontrol listening on %s", controlURL) - return controlURL, control -} - -type testCertIssuer struct { - mu sync.Mutex - certs map[string]*tls.Certificate - - root *x509.Certificate - rootKey *ecdsa.PrivateKey -} - -func newCertIssuer() *testCertIssuer { - rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - panic(err) - } - t := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - CommonName: "root", - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour), - IsCA: true, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - KeyUsage: x509.KeyUsageCertSign, - BasicConstraintsValid: true, - } - rootDER, err := x509.CreateCertificate(rand.Reader, t, t, &rootKey.PublicKey, rootKey) - if err != nil { - panic(err) - } - rootCA, err := x509.ParseCertificate(rootDER) - if err != nil { - panic(err) - } - return &testCertIssuer{ - certs: make(map[string]*tls.Certificate), - root: rootCA, - rootKey: rootKey, - } -} - -func (tci *testCertIssuer) getCert(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { - tci.mu.Lock() - defer tci.mu.Unlock() - cert, ok := tci.certs[chi.ServerName] - if ok { - return cert, nil - } - - certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - certTmpl := &x509.Certificate{ - SerialNumber: big.NewInt(1), - DNSNames: []string{chi.ServerName}, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour), - } - certDER, err := x509.CreateCertificate(rand.Reader, certTmpl, tci.root, &certPrivKey.PublicKey, tci.rootKey) - if err != nil { - return nil, err - } - cert = &tls.Certificate{ - Certificate: [][]byte{certDER, tci.root.Raw}, - PrivateKey: certPrivKey, - } - tci.certs[chi.ServerName] = cert - return cert, nil -} - -func (tci *testCertIssuer) Pool() *x509.CertPool { - p := x509.NewCertPool() - p.AddCert(tci.root) - return p -} - -var testCertRoot = newCertIssuer() - -func startServer(t *testing.T, ctx context.Context, controlURL, hostname string) (*Server, netip.Addr, key.NodePublic) { - t.Helper() - - tmp := filepath.Join(t.TempDir(), hostname) - os.MkdirAll(tmp, 0755) - s := &Server{ - Dir: tmp, - ControlURL: controlURL, - Hostname: hostname, - Store: new(mem.Store), - Ephemeral: true, - getCertForTesting: testCertRoot.getCert, - } - if *verboseNodes { - s.Logf = log.Printf - } - t.Cleanup(func() { s.Close() }) - - status, err := s.Up(ctx) - if err != nil { - t.Fatal(err) - } - return s, status.TailscaleIPs[0], status.Self.PublicKey -} - -func TestConn(t *testing.T) { - tstest.ResourceCheck(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - controlURL, c := startControl(t) - s1, s1ip, s1PubKey := startServer(t, ctx, controlURL, "s1") - s2, _, _ := startServer(t, ctx, controlURL, "s2") - - s1.lb.EditPrefs(&ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - AdvertiseRoutes: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, - }, - AdvertiseRoutesSet: true, - }) - c.SetSubnetRoutes(s1PubKey, []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}) - - lc2, err := s2.LocalClient() - if err != nil { - t.Fatal(err) - } - - // ping to make sure the connection is up. - res, err := lc2.Ping(ctx, s1ip, tailcfg.PingICMP) - if err != nil { - t.Fatal(err) - } - t.Logf("ping success: %#+v", res) - - // pass some data through TCP. - ln, err := s1.Listen("tcp", ":8081") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - - w, err := s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8081", s1ip)) - if err != nil { - t.Fatal(err) - } - - r, err := ln.Accept() - if err != nil { - t.Fatal(err) - } - - want := "hello" - if _, err := io.WriteString(w, want); err != nil { - t.Fatal(err) - } - - got := make([]byte, len(want)) - if _, err := io.ReadAtLeast(r, got, len(got)); err != nil { - t.Fatal(err) - } - t.Logf("got: %q", got) - if string(got) != want { - t.Errorf("got %q, want %q", got, want) - } - - _, err = s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8082", s1ip)) // some random port - if err == nil { - t.Fatalf("unexpected success; should have seen a connection refused error") - } - - // s1 is a subnet router for TEST-NET-1 (192.0.2.0/24). Lets dial to that - // subnet from s2 to ensure a listener without an IP address (i.e. ":8081") - // only matches destination IPs corresponding to the node's IP, and not - // to any random IP a subnet is routing. - _, err = s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8081", "192.0.2.1")) - if err == nil { - t.Fatalf("unexpected success; should have seen a connection refused error") - } -} - -func TestLoopbackLocalAPI(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/8557") - tstest.ResourceCheck(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - controlURL, _ := startControl(t) - s1, _, _ := startServer(t, ctx, controlURL, "s1") - - addr, proxyCred, localAPICred, err := s1.Loopback() - if err != nil { - t.Fatal(err) - } - if proxyCred == localAPICred { - t.Fatal("proxy password matches local API password, they should be different") - } - - url := "http://" + addr + "/localapi/v0/status" - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - t.Fatal(err) - } - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - res.Body.Close() - if res.StatusCode != 403 { - t.Errorf("GET %s returned %d, want 403 without Sec- header", url, res.StatusCode) - } - - req, err = http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Sec-Tailscale", "localapi") - res, err = http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - res.Body.Close() - if res.StatusCode != 401 { - t.Errorf("GET %s returned %d, want 401 without basic auth", url, res.StatusCode) - } - - req, err = http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - t.Fatal(err) - } - req.SetBasicAuth("", localAPICred) - res, err = http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - res.Body.Close() - if res.StatusCode != 403 { - t.Errorf("GET %s returned %d, want 403 without Sec- header", url, res.StatusCode) - } - - req, err = http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Sec-Tailscale", "localapi") - req.SetBasicAuth("", localAPICred) - res, err = http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - res.Body.Close() - if res.StatusCode != 200 { - t.Errorf("GET /status returned %d, want 200", res.StatusCode) - } -} - -func TestLoopbackSOCKS5(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/8198") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - controlURL, _ := startControl(t) - s1, s1ip, _ := startServer(t, ctx, controlURL, "s1") - s2, _, _ := startServer(t, ctx, controlURL, "s2") - - addr, proxyCred, _, err := s2.Loopback() - if err != nil { - t.Fatal(err) - } - - ln, err := s1.Listen("tcp", ":8081") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - - auth := &proxy.Auth{User: "tsnet", Password: proxyCred} - socksDialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct) - if err != nil { - t.Fatal(err) - } - - w, err := socksDialer.Dial("tcp", fmt.Sprintf("%s:8081", s1ip)) - if err != nil { - t.Fatal(err) - } - - r, err := ln.Accept() - if err != nil { - t.Fatal(err) - } - - want := "hello" - if _, err := io.WriteString(w, want); err != nil { - t.Fatal(err) - } - - got := make([]byte, len(want)) - if _, err := io.ReadAtLeast(r, got, len(got)); err != nil { - t.Fatal(err) - } - t.Logf("got: %q", got) - if string(got) != want { - t.Errorf("got %q, want %q", got, want) - } -} - -func TestTailscaleIPs(t *testing.T) { - controlURL, _ := startControl(t) - - tmp := t.TempDir() - tmps1 := filepath.Join(tmp, "s1") - os.MkdirAll(tmps1, 0755) - s1 := &Server{ - Dir: tmps1, - ControlURL: controlURL, - Hostname: "s1", - Store: new(mem.Store), - Ephemeral: true, - } - defer s1.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - s1status, err := s1.Up(ctx) - if err != nil { - t.Fatal(err) - } - - var upIp4, upIp6 netip.Addr - for _, ip := range s1status.TailscaleIPs { - if ip.Is6() { - upIp6 = ip - } - if ip.Is4() { - upIp4 = ip - } - } - - sIp4, sIp6 := s1.TailscaleIPs() - if !(upIp4 == sIp4 && upIp6 == sIp6) { - t.Errorf("s1.TailscaleIPs returned a different result than S1.Up, (%s, %s) != (%s, %s)", - sIp4, upIp4, sIp6, upIp6) - } -} - -// TestListenerCleanup is a regression test to verify that s.Close doesn't -// deadlock if a listener is still open. -func TestListenerCleanup(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - controlURL, _ := startControl(t) - s1, _, _ := startServer(t, ctx, controlURL, "s1") - - ln, err := s1.Listen("tcp", ":8081") - if err != nil { - t.Fatal(err) - } - - if err := s1.Close(); err != nil { - t.Fatal(err) - } - - if err := ln.Close(); !errors.Is(err, net.ErrClosed) { - t.Fatalf("second ln.Close error: %v, want net.ErrClosed", err) - } -} - -// tests https://github.com/tailscale/tailscale/issues/6973 -- that we can start a tsnet server, -// stop it, and restart it, even on Windows. -func TestStartStopStartGetsSameIP(t *testing.T) { - controlURL, _ := startControl(t) - - tmp := t.TempDir() - tmps1 := filepath.Join(tmp, "s1") - os.MkdirAll(tmps1, 0755) - - newServer := func() *Server { - return &Server{ - Dir: tmps1, - ControlURL: controlURL, - Hostname: "s1", - Logf: tstest.WhileTestRunningLogger(t), - } - } - s1 := newServer() - defer s1.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - s1status, err := s1.Up(ctx) - if err != nil { - t.Fatal(err) - } - - firstIPs := s1status.TailscaleIPs - t.Logf("IPs: %v", firstIPs) - - if err := s1.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - - s2 := newServer() - defer s2.Close() - - s2status, err := s2.Up(ctx) - if err != nil { - t.Fatalf("second Up: %v", err) - } - - secondIPs := s2status.TailscaleIPs - t.Logf("IPs: %v", secondIPs) - - if !reflect.DeepEqual(firstIPs, secondIPs) { - t.Fatalf("got %v but later %v", firstIPs, secondIPs) - } -} - -func TestFunnel(t *testing.T) { - ctx, dialCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer dialCancel() - - controlURL, _ := startControl(t) - s1, _, _ := startServer(t, ctx, controlURL, "s1") - s2, _, _ := startServer(t, ctx, controlURL, "s2") - - ln := must.Get(s1.ListenFunnel("tcp", ":443")) - defer ln.Close() - wantSrcAddrPort := netip.MustParseAddrPort("127.0.0.1:1234") - wantTarget := ipn.HostPort("s1.tail-scale.ts.net:443") - srv := &http.Server{ - ConnContext: func(ctx context.Context, c net.Conn) context.Context { - tc, ok := c.(*tls.Conn) - if !ok { - t.Errorf("ConnContext called with non-TLS conn: %T", c) - } - if fc, ok := tc.NetConn().(*ipn.FunnelConn); !ok { - t.Errorf("ConnContext called with non-FunnelConn: %T", c) - } else if fc.Src != wantSrcAddrPort { - t.Errorf("ConnContext called with wrong SrcAddrPort; got %v, want %v", fc.Src, wantSrcAddrPort) - } else if fc.Target != wantTarget { - t.Errorf("ConnContext called with wrong Target; got %q, want %q", fc.Target, wantTarget) - } - return ctx - }, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "hello") - }), - } - go srv.Serve(ln) - - c := &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialIngressConn(s2, s1, addr) - }, - TLSClientConfig: &tls.Config{ - RootCAs: testCertRoot.Pool(), - }, - }, - } - resp, err := c.Get("https://s1.tail-scale.ts.net:443") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Errorf("unexpected status code: %v", resp.StatusCode) - return - } - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatal(err) - } - if string(body) != "hello" { - t.Errorf("unexpected body: %q", body) - } -} - -func dialIngressConn(from, to *Server, target string) (net.Conn, error) { - toLC := must.Get(to.LocalClient()) - toStatus := must.Get(toLC.StatusWithoutPeers(context.Background())) - peer6 := toStatus.Self.PeerAPIURL[1] // IPv6 - toPeerAPI, ok := strings.CutPrefix(peer6, "http://") - if !ok { - return nil, fmt.Errorf("unexpected PeerAPIURL %q", peer6) - } - - dialCtx, dialCancel := context.WithTimeout(context.Background(), 30*time.Second) - outConn, err := from.Dial(dialCtx, "tcp", toPeerAPI) - dialCancel() - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", "/v0/ingress", nil) - if err != nil { - return nil, err - } - req.Host = toPeerAPI - req.Header.Set("Tailscale-Ingress-Src", "127.0.0.1:1234") - req.Header.Set("Tailscale-Ingress-Target", target) - if err := req.Write(outConn); err != nil { - return nil, err - } - - br := bufio.NewReader(outConn) - res, err := http.ReadResponse(br, req) - if err != nil { - return nil, err - } - defer res.Body.Close() // just to appease vet - if res.StatusCode != 101 { - return nil, fmt.Errorf("unexpected status code: %v", res.StatusCode) - } - return &bufferedConn{outConn, br}, nil -} - -type bufferedConn struct { - net.Conn - reader *bufio.Reader -} - -func (c *bufferedConn) Read(b []byte) (int, error) { - return c.reader.Read(b) -} - -func TestFallbackTCPHandler(t *testing.T) { - tstest.ResourceCheck(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - controlURL, _ := startControl(t) - s1, s1ip, _ := startServer(t, ctx, controlURL, "s1") - s2, _, _ := startServer(t, ctx, controlURL, "s2") - - lc2, err := s2.LocalClient() - if err != nil { - t.Fatal(err) - } - - // ping to make sure the connection is up. - res, err := lc2.Ping(ctx, s1ip, tailcfg.PingICMP) - if err != nil { - t.Fatal(err) - } - t.Logf("ping success: %#+v", res) - - var s1TcpConnCount atomic.Int32 - deregister := s1.RegisterFallbackTCPHandler(func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { - s1TcpConnCount.Add(1) - return nil, false - }) - - if _, err := s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8081", s1ip)); err == nil { - t.Fatal("Expected dial error because fallback handler did not intercept") - } - if got := s1TcpConnCount.Load(); got != 1 { - t.Errorf("s1TcpConnCount = %d, want %d", got, 1) - } - deregister() - if _, err := s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8081", s1ip)); err == nil { - t.Fatal("Expected dial error because nothing would intercept") - } - if got := s1TcpConnCount.Load(); got != 1 { - t.Errorf("s1TcpConnCount = %d, want %d", got, 1) - } -} - -func TestCapturePcap(t *testing.T) { - const timeLimit = 120 - ctx, cancel := context.WithTimeout(context.Background(), timeLimit*time.Second) - defer cancel() - - dir := t.TempDir() - s1Pcap := filepath.Join(dir, "s1.pcap") - s2Pcap := filepath.Join(dir, "s2.pcap") - - controlURL, _ := startControl(t) - s1, s1ip, _ := startServer(t, ctx, controlURL, "s1") - s2, _, _ := startServer(t, ctx, controlURL, "s2") - s1.CapturePcap(ctx, s1Pcap) - s2.CapturePcap(ctx, s2Pcap) - - lc2, err := s2.LocalClient() - if err != nil { - t.Fatal(err) - } - - // send a packet which both nodes will capture - res, err := lc2.Ping(ctx, s1ip, tailcfg.PingICMP) - if err != nil { - t.Fatal(err) - } - t.Logf("ping success: %#+v", res) - - fileSize := func(name string) int64 { - fi, err := os.Stat(name) - if err != nil { - return 0 - } - return fi.Size() - } - - const pcapHeaderSize = 24 - - // there is a lag before the io.Copy writes a packet to the pcap files - for range timeLimit * 10 { - time.Sleep(100 * time.Millisecond) - if (fileSize(s1Pcap) > pcapHeaderSize) && (fileSize(s2Pcap) > pcapHeaderSize) { - break - } - } - - if got := fileSize(s1Pcap); got <= pcapHeaderSize { - t.Errorf("s1 pcap file size = %d, want > pcapHeaderSize(%d)", got, pcapHeaderSize) - } - if got := fileSize(s2Pcap); got <= pcapHeaderSize { - t.Errorf("s2 pcap file size = %d, want > pcapHeaderSize(%d)", got, pcapHeaderSize) - } -} - -func TestUDPConn(t *testing.T) { - tstest.ResourceCheck(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - controlURL, _ := startControl(t) - s1, s1ip, _ := startServer(t, ctx, controlURL, "s1") - s2, s2ip, _ := startServer(t, ctx, controlURL, "s2") - - lc2, err := s2.LocalClient() - if err != nil { - t.Fatal(err) - } - - // ping to make sure the connection is up. - res, err := lc2.Ping(ctx, s1ip, tailcfg.PingICMP) - if err != nil { - t.Fatal(err) - } - t.Logf("ping success: %#+v", res) - - pc := must.Get(s1.ListenPacket("udp", fmt.Sprintf("%s:8081", s1ip))) - defer pc.Close() - - // Dial to s1 from s2 - w, err := s2.Dial(ctx, "udp", fmt.Sprintf("%s:8081", s1ip)) - if err != nil { - t.Fatal(err) - } - defer w.Close() - - // Send a packet from s2 to s1 - want := "hello" - if _, err := io.WriteString(w, want); err != nil { - t.Fatal(err) - } - - // Receive the packet on s1 - got := make([]byte, 1024) - n, from, err := pc.ReadFrom(got) - if err != nil { - t.Fatal(err) - } - got = got[:n] - t.Logf("got: %q", got) - if string(got) != want { - t.Errorf("got %q, want %q", got, want) - } - if from.(*net.UDPAddr).AddrPort().Addr() != s2ip { - t.Errorf("got from %v, want %v", from, s2ip) - } - - // Write a response back to s2 - if _, err := pc.WriteTo([]byte("world"), from); err != nil { - t.Fatal(err) - } - - // Receive the response on s2 - got = make([]byte, 1024) - n, err = w.Read(got) - if err != nil { - t.Fatal(err) - } - got = got[:n] - t.Logf("got: %q", got) - if string(got) != "world" { - t.Errorf("got %q, want world", got) - } -} - -// testWarnable is a Warnable that is used within this package for testing purposes only. -var testWarnable = health.Register(&health.Warnable{ - Code: "test-warnable-tsnet", - Title: "Test warnable", - Severity: health.SeverityLow, - Text: func(args health.Args) string { - return args[health.ArgError] - }, -}) - -func parseMetrics(m []byte) (map[string]float64, error) { - metrics := make(map[string]float64) - - var parser expfmt.TextParser - mf, err := parser.TextToMetricFamilies(bytes.NewReader(m)) - if err != nil { - return nil, err - } - - for _, f := range mf { - for _, ff := range f.Metric { - val := float64(0) - - switch f.GetType() { - case dto.MetricType_COUNTER: - val = ff.GetCounter().GetValue() - case dto.MetricType_GAUGE: - val = ff.GetGauge().GetValue() - } - - metrics[f.GetName()+promMetricLabelsStr(ff.GetLabel())] = val - } - } - - return metrics, nil -} - -func promMetricLabelsStr(labels []*dto.LabelPair) string { - if len(labels) == 0 { - return "" - } - var b strings.Builder - b.WriteString("{") - for i, l := range labels { - if i > 0 { - b.WriteString(",") - } - b.WriteString(fmt.Sprintf("%s=%q", l.GetName(), l.GetValue())) - } - b.WriteString("}") - return b.String() -} - -// sendData sends a given amount of bytes from s1 to s2. -func sendData(logf func(format string, args ...any), ctx context.Context, bytesCount int, s1, s2 *Server, s1ip, s2ip netip.Addr) error { - l := must.Get(s1.Listen("tcp", fmt.Sprintf("%s:8081", s1ip))) - defer l.Close() - - // Dial to s1 from s2 - w, err := s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8081", s1ip)) - if err != nil { - return err - } - defer w.Close() - - stopReceive := make(chan struct{}) - defer close(stopReceive) - allReceived := make(chan error) - defer close(allReceived) - - go func() { - conn, err := l.Accept() - if err != nil { - allReceived <- err - return - } - conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) - - total := 0 - recvStart := time.Now() - for { - got := make([]byte, bytesCount) - n, err := conn.Read(got) - if n != bytesCount { - logf("read %d bytes, want %d", n, bytesCount) - } - - select { - case <-stopReceive: - return - default: - } - - if err != nil { - allReceived <- fmt.Errorf("failed reading packet, %s", err) - return - } - - total += n - logf("received %d/%d bytes, %.2f %%", total, bytesCount, (float64(total) / (float64(bytesCount)) * 100)) - if total == bytesCount { - break - } - } - - logf("all received, took: %s", time.Since(recvStart).String()) - allReceived <- nil - }() - - sendStart := time.Now() - w.SetWriteDeadline(time.Now().Add(30 * time.Second)) - if _, err := w.Write(bytes.Repeat([]byte("A"), bytesCount)); err != nil { - stopReceive <- struct{}{} - return err - } - - logf("all sent (%s), waiting for all packets (%d) to be received", time.Since(sendStart).String(), bytesCount) - err, _ = <-allReceived - if err != nil { - return err - } - - return nil -} - -func TestUserMetrics(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/13420") - tstest.ResourceCheck(t) - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - controlURL, c := startControl(t) - s1, s1ip, s1PubKey := startServer(t, ctx, controlURL, "s1") - s2, s2ip, _ := startServer(t, ctx, controlURL, "s2") - - s1.lb.EditPrefs(&ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - AdvertiseRoutes: []netip.Prefix{ - netip.MustParsePrefix("192.0.2.0/24"), - netip.MustParsePrefix("192.0.3.0/24"), - netip.MustParsePrefix("192.0.5.1/32"), - netip.MustParsePrefix("0.0.0.0/0"), - }, - }, - AdvertiseRoutesSet: true, - }) - c.SetSubnetRoutes(s1PubKey, []netip.Prefix{ - netip.MustParsePrefix("192.0.2.0/24"), - netip.MustParsePrefix("192.0.5.1/32"), - netip.MustParsePrefix("0.0.0.0/0"), - }) - - lc1, err := s1.LocalClient() - if err != nil { - t.Fatal(err) - } - - lc2, err := s2.LocalClient() - if err != nil { - t.Fatal(err) - } - - // ping to make sure the connection is up. - res, err := lc2.Ping(ctx, s1ip, tailcfg.PingICMP) - if err != nil { - t.Fatalf("pinging: %s", err) - } - t.Logf("ping success: %#+v", res) - - ht := s1.lb.HealthTracker() - ht.SetUnhealthy(testWarnable, health.Args{"Text": "Hello world 1"}) - - // Force an update to the netmap to ensure that the metrics are up-to-date. - s1.lb.DebugForceNetmapUpdate() - s2.lb.DebugForceNetmapUpdate() - - wantRoutes := float64(2) - if runtime.GOOS == "windows" { - wantRoutes = 0 - } - - // Wait for the routes to be propagated to node 1 to ensure - // that the metrics are up-to-date. - waitForCondition(t, "primary routes available for node1", 90*time.Second, func() bool { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - status1, err := lc1.Status(ctx) - if err != nil { - t.Logf("getting status: %s", err) - return false - } - if runtime.GOOS == "windows" { - // Windows does not seem to support or report back routes when running in - // userspace via tsnet. So, we skip this check on Windows. - // TODO(kradalby): Figure out if this is correct. - return true - } - // Wait for the primary routes to reach our desired routes, which is wantRoutes + 1, because - // the PrimaryRoutes list will contain a exit node route, which the metric does not count. - return status1.Self.PrimaryRoutes != nil && status1.Self.PrimaryRoutes.Len() == int(wantRoutes)+1 - }) - - mustDirect(t, t.Logf, lc1, lc2) - - // 10 megabytes - bytesToSend := 10 * 1024 * 1024 - - // This asserts generates some traffic, it is factored out - // of TestUDPConn. - start := time.Now() - err = sendData(t.Logf, ctx, bytesToSend, s1, s2, s1ip, s2ip) - if err != nil { - t.Fatalf("Failed to send packets: %v", err) - } - t.Logf("Sent %d bytes from s1 to s2 in %s", bytesToSend, time.Since(start).String()) - - ctxLc, cancelLc := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelLc() - metrics1, err := lc1.UserMetrics(ctxLc) - if err != nil { - t.Fatal(err) - } - - status1, err := lc1.Status(ctxLc) - if err != nil { - t.Fatal(err) - } - - parsedMetrics1, err := parseMetrics(metrics1) - if err != nil { - t.Fatal(err) - } - - // Allow the metrics for the bytes sent to be off by 15%. - bytesSentTolerance := 1.15 - - t.Logf("Metrics1:\n%s\n", metrics1) - - // The node is advertising 4 routes: - // - 192.0.2.0/24 - // - 192.0.3.0/24 - // - 192.0.5.1/32 - if got, want := parsedMetrics1["tailscaled_advertised_routes"], 3.0; got != want { - t.Errorf("metrics1, tailscaled_advertised_routes: got %v, want %v", got, want) - } - - // The control has approved 2 routes: - // - 192.0.2.0/24 - // - 192.0.5.1/32 - if got, want := parsedMetrics1["tailscaled_approved_routes"], wantRoutes; got != want { - t.Errorf("metrics1, tailscaled_approved_routes: got %v, want %v", got, want) - } - - // Validate the health counter metric against the status of the node - if got, want := parsedMetrics1[`tailscaled_health_messages{type="warning"}`], float64(len(status1.Health)); got != want { - t.Errorf("metrics1, tailscaled_health_messages: got %v, want %v", got, want) - } - - // Verify that the amount of data recorded in bytes is higher or equal to the - // 10 megabytes sent. - inboundBytes1 := parsedMetrics1[`tailscaled_inbound_bytes_total{path="direct_ipv4"}`] - if inboundBytes1 < float64(bytesToSend) { - t.Errorf(`metrics1, tailscaled_inbound_bytes_total{path="direct_ipv4"}: expected higher (or equal) than %d, got: %f`, bytesToSend, inboundBytes1) - } - - // But ensure that it is not too much higher than the 10 megabytes sent. - if inboundBytes1 > float64(bytesToSend)*bytesSentTolerance { - t.Errorf(`metrics1, tailscaled_inbound_bytes_total{path="direct_ipv4"}: expected lower than %f, got: %f`, float64(bytesToSend)*bytesSentTolerance, inboundBytes1) - } - - metrics2, err := lc2.UserMetrics(ctx) - if err != nil { - t.Fatal(err) - } - - status2, err := lc2.Status(ctx) - if err != nil { - t.Fatal(err) - } - - parsedMetrics2, err := parseMetrics(metrics2) - if err != nil { - t.Fatal(err) - } - - t.Logf("Metrics2:\n%s\n", metrics2) - - // The node is advertising 0 routes - if got, want := parsedMetrics2["tailscaled_advertised_routes"], 0.0; got != want { - t.Errorf("metrics2, tailscaled_advertised_routes: got %v, want %v", got, want) - } - - // The control has approved 0 routes - if got, want := parsedMetrics2["tailscaled_approved_routes"], 0.0; got != want { - t.Errorf("metrics2, tailscaled_approved_routes: got %v, want %v", got, want) - } - - // Validate the health counter metric against the status of the node - if got, want := parsedMetrics2[`tailscaled_health_messages{type="warning"}`], float64(len(status2.Health)); got != want { - t.Errorf("metrics2, tailscaled_health_messages: got %v, want %v", got, want) - } - - // Verify that the amount of data recorded in bytes is higher or equal than the - // 10 megabytes sent. - outboundBytes2 := parsedMetrics2[`tailscaled_outbound_bytes_total{path="direct_ipv4"}`] - if outboundBytes2 < float64(bytesToSend) { - t.Errorf(`metrics2, tailscaled_outbound_bytes_total{path="direct_ipv4"}: expected higher (or equal) than %d, got: %f`, bytesToSend, outboundBytes2) - } - - // But ensure that it is not too much higher than the 10 megabytes sent. - if outboundBytes2 > float64(bytesToSend)*bytesSentTolerance { - t.Errorf(`metrics2, tailscaled_outbound_bytes_total{path="direct_ipv4"}: expected lower than %f, got: %f`, float64(bytesToSend)*bytesSentTolerance, outboundBytes2) - } -} - -func waitForCondition(t *testing.T, msg string, waitTime time.Duration, f func() bool) { - t.Helper() - for deadline := time.Now().Add(waitTime); time.Now().Before(deadline); time.Sleep(1 * time.Second) { - if f() { - return - } - } - t.Fatalf("waiting for condition: %s", msg) -} - -// mustDirect ensures there is a direct connection between LocalClient 1 and 2 -func mustDirect(t *testing.T, logf logger.Logf, lc1, lc2 *tailscale.LocalClient) { - t.Helper() - lastLog := time.Now().Add(-time.Minute) - // See https://github.com/tailscale/tailscale/issues/654 - // and https://github.com/tailscale/tailscale/issues/3247 for discussions of this deadline. - for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - status1, err := lc1.Status(ctx) - if err != nil { - continue - } - status2, err := lc2.Status(ctx) - if err != nil { - continue - } - pst := status1.Peer[status2.Self.PublicKey] - if pst.CurAddr != "" { - logf("direct link %s->%s found with addr %s", status1.Self.HostName, status2.Self.HostName, pst.CurAddr) - return - } - if now := time.Now(); now.Sub(lastLog) > time.Second { - logf("no direct path %s->%s yet, addrs %v", status1.Self.HostName, status2.Self.HostName, pst.Addrs) - lastLog = now - } - } - t.Error("magicsock did not find a direct path from lc1 to lc2") -} diff --git a/tstest/allocs.go b/tstest/allocs.go deleted file mode 100644 index f15a00508d87f..0000000000000 --- a/tstest/allocs.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import ( - "fmt" - "runtime" - "testing" - "time" -) - -// MinAllocsPerRun asserts that f can run with no more than target allocations. -// It runs f up to 1000 times or 5s, whichever happens first. -// If f has executed more than target allocations on every run, it returns a non-nil error. -// -// MinAllocsPerRun sets GOMAXPROCS to 1 during its measurement and restores -// it before returning. -func MinAllocsPerRun(t *testing.T, target uint64, f func()) error { - defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) - - var memstats runtime.MemStats - var min, max, sum uint64 - start := time.Now() - var iters int - for { - runtime.ReadMemStats(&memstats) - startMallocs := memstats.Mallocs - f() - runtime.ReadMemStats(&memstats) - mallocs := memstats.Mallocs - startMallocs - // TODO: if mallocs < target, return an error? See discussion in #3204. - if mallocs <= target { - return nil - } - if min == 0 || mallocs < min { - min = mallocs - } - if mallocs > max { - max = mallocs - } - sum += mallocs - iters++ - if iters == 1000 || time.Since(start) > 5*time.Second { - break - } - } - - return fmt.Errorf("min allocs = %d, max allocs = %d, avg allocs/run = %f, want run with <= %d allocs", min, max, float64(sum)/float64(iters), target) -} diff --git a/tstest/archtest/archtest_test.go b/tstest/archtest/archtest_test.go deleted file mode 100644 index 1aeca5c109073..0000000000000 --- a/tstest/archtest/archtest_test.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package archtest - -import ( - "runtime" - "testing" - - "gvisor.dev/gvisor/pkg/atomicbitops" -) - -// tests netstack's AlignedAtomicInt64. -func TestAlignedAtomicInt64(t *testing.T) { - type T struct { - A atomicbitops.Int64 - _ int32 - B atomicbitops.Int64 - } - - t.Logf("I am %v/%v\n", runtime.GOOS, runtime.GOARCH) - var x T - x.A.Store(1) - x.B.Store(2) - if got, want := x.A.Load(), int64(1); got != want { - t.Errorf("A = %v; want %v", got, want) - } - if got, want := x.B.Load(), int64(2); got != want { - t.Errorf("A = %v; want %v", got, want) - } -} diff --git a/tstest/archtest/qemu_test.go b/tstest/archtest/qemu_test.go deleted file mode 100644 index 8b59ae5d9fee1..0000000000000 --- a/tstest/archtest/qemu_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux && amd64 && !race - -package archtest - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "strings" - "testing" - - "tailscale.com/util/cibuild" -) - -func TestInQemu(t *testing.T) { - t.Parallel() - type Arch struct { - Goarch string // GOARCH value - Qarch string // qemu name - } - arches := []Arch{ - {"arm", "arm"}, - {"arm64", "aarch64"}, - {"mips", "mips"}, - {"mipsle", "mipsel"}, - {"mips64", "mips64"}, - {"mips64le", "mips64el"}, - {"386", "386"}, - } - inCI := cibuild.On() - for _, arch := range arches { - arch := arch - t.Run(arch.Goarch, func(t *testing.T) { - t.Parallel() - qemuUser := "qemu-" + arch.Qarch - execVia := qemuUser - if arch.Goarch == "386" { - execVia = "" // amd64 can run it fine - } else { - look, err := exec.LookPath(qemuUser) - if err != nil { - if inCI { - t.Fatalf("in CI and qemu not available: %v", err) - } - t.Skipf("%s not found; skipping test. error was: %v", qemuUser, err) - } - t.Logf("using %v", look) - } - cmd := exec.Command("go", - "test", - "--exec="+execVia, - "-v", - "tailscale.com/tstest/archtest", - ) - cmd.Env = append(os.Environ(), "GOARCH="+arch.Goarch) - out, err := cmd.CombinedOutput() - if err != nil { - if strings.Contains(string(out), "fatal error: sigaction failed") && !inCI { - t.Skip("skipping; qemu too old. use 5.x.") - } - t.Errorf("failed: %s", out) - } - sub := fmt.Sprintf("I am linux/%s", arch.Goarch) - if !bytes.Contains(out, []byte(sub)) { - t.Errorf("output didn't contain %q: %s", sub, out) - } - }) - } -} diff --git a/tstest/clock.go b/tstest/clock.go deleted file mode 100644 index ee7523430ff54..0000000000000 --- a/tstest/clock.go +++ /dev/null @@ -1,694 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import ( - "container/heap" - "sync" - "time" - - "tailscale.com/tstime" - "tailscale.com/util/mak" -) - -// ClockOpts is used to configure the initial settings for a Clock. Once the -// settings are configured as desired, call NewClock to get the resulting Clock. -type ClockOpts struct { - // Start is the starting time for the Clock. When FollowRealTime is false, - // Start is also the value that will be returned by the first call - // to Clock.Now. - Start time.Time - // Step is the amount of time the Clock will advance whenever Clock.Now is - // called. If set to zero, the Clock will only advance when Clock.Advance is - // called and/or if FollowRealTime is true. - // - // FollowRealTime and Step cannot be enabled at the same time. - Step time.Duration - - // TimerChannelSize configures the maximum buffered ticks that are - // permitted in the channel of any Timer and Ticker created by this Clock. - // The special value 0 means to use the default of 1. The buffer may need to - // be increased if time is advanced by more than a single tick and proper - // functioning of the test requires that the ticks are not lost. - TimerChannelSize int - - // FollowRealTime makes the simulated time increment along with real time. - // It is a compromise between determinism and the difficulty of explicitly - // managing the simulated time via Step or Clock.Advance. When - // FollowRealTime is set, calls to Now() and PeekNow() will add the - // elapsed real-world time to the simulated time. - // - // FollowRealTime and Step cannot be enabled at the same time. - FollowRealTime bool -} - -// NewClock creates a Clock with the specified settings. To create a -// Clock with only the default settings, new(Clock) is equivalent, except that -// the start time will not be computed until one of the receivers is called. -func NewClock(co ClockOpts) *Clock { - if co.FollowRealTime && co.Step != 0 { - panic("only one of FollowRealTime and Step are allowed in NewClock") - } - - return newClockInternal(co, nil) -} - -// newClockInternal creates a Clock with the specified settings and allows -// specifying a non-standard realTimeClock. -func newClockInternal(co ClockOpts, rtClock tstime.Clock) *Clock { - if !co.FollowRealTime && rtClock != nil { - panic("rtClock can only be set with FollowRealTime enabled") - } - - if co.FollowRealTime && rtClock == nil { - rtClock = new(tstime.StdClock) - } - - c := &Clock{ - start: co.Start, - realTimeClock: rtClock, - step: co.Step, - timerChannelSize: co.TimerChannelSize, - } - c.init() // init now to capture the current time when co.Start.IsZero() - return c -} - -// Clock is a testing clock that advances every time its Now method is -// called, beginning at its start time. If no start time is specified using -// ClockBuilder, an arbitrary start time will be selected when the Clock is -// created and can be retrieved by calling Clock.Start(). -type Clock struct { - // start is the first value returned by Now. It must not be modified after - // init is called. - start time.Time - - // realTimeClock, if not nil, indicates that the Clock shall move forward - // according to realTimeClock + the accumulated calls to Advance. This can - // make writing tests easier that require some control over the clock but do - // not need exact control over the clock. While step can also be used for - // this purpose, it is harder to control how quickly time moves using step. - realTimeClock tstime.Clock - - initOnce sync.Once - mu sync.Mutex - - // step is how much to advance with each Now call. - step time.Duration - // present is the last value returned by Now (and will be returned again by - // PeekNow). - present time.Time - // realTime is the time from realTimeClock corresponding to the current - // value of present. - realTime time.Time - // skipStep indicates that the next call to Now should not add step to - // present. This occurs after initialization and after Advance. - skipStep bool - // timerChannelSize is the buffer size to use for channels created by - // NewTimer and NewTicker. - timerChannelSize int - - events eventManager -} - -func (c *Clock) init() { - c.initOnce.Do(func() { - if c.realTimeClock != nil { - c.realTime = c.realTimeClock.Now() - } - if c.start.IsZero() { - if c.realTime.IsZero() { - c.start = time.Now() - } else { - c.start = c.realTime - } - } - if c.timerChannelSize == 0 { - c.timerChannelSize = 1 - } - c.present = c.start - c.skipStep = true - c.events.AdvanceTo(c.present) - }) -} - -// Now returns the virtual clock's current time, and advances it -// according to its step configuration. -func (c *Clock) Now() time.Time { - c.init() - rt := c.maybeGetRealTime() - - c.mu.Lock() - defer c.mu.Unlock() - - step := c.step - if c.skipStep { - step = 0 - c.skipStep = false - } - c.advanceLocked(rt, step) - - return c.present -} - -func (c *Clock) maybeGetRealTime() time.Time { - if c.realTimeClock == nil { - return time.Time{} - } - return c.realTimeClock.Now() -} - -func (c *Clock) advanceLocked(now time.Time, add time.Duration) { - if !now.IsZero() { - add += now.Sub(c.realTime) - c.realTime = now - } - if add == 0 { - return - } - c.present = c.present.Add(add) - c.events.AdvanceTo(c.present) -} - -// PeekNow returns the last time reported by Now. If Now has never been called, -// PeekNow returns the same value as GetStart. -func (c *Clock) PeekNow() time.Time { - c.init() - c.mu.Lock() - defer c.mu.Unlock() - return c.present -} - -// Advance moves simulated time forward or backwards by a relative amount. Any -// Timer or Ticker that is waiting will fire at the requested point in simulated -// time. Advance returns the new simulated time. If this Clock follows real time -// then the next call to Now will equal the return value of Advance + the -// elapsed time since calling Advance. Otherwise, the next call to Now will -// equal the return value of Advance, regardless of the current step. -func (c *Clock) Advance(d time.Duration) time.Time { - c.init() - rt := c.maybeGetRealTime() - - c.mu.Lock() - defer c.mu.Unlock() - c.skipStep = true - - c.advanceLocked(rt, d) - return c.present -} - -// AdvanceTo moves simulated time to a new absolute value. Any Timer or Ticker -// that is waiting will fire at the requested point in simulated time. If this -// Clock follows real time then the next call to Now will equal t + the elapsed -// time since calling Advance. Otherwise, the next call to Now will equal t, -// regardless of the configured step. -func (c *Clock) AdvanceTo(t time.Time) { - c.init() - rt := c.maybeGetRealTime() - - c.mu.Lock() - defer c.mu.Unlock() - c.skipStep = true - c.realTime = rt - c.present = t - c.events.AdvanceTo(c.present) -} - -// GetStart returns the initial simulated time when this Clock was created. -func (c *Clock) GetStart() time.Time { - c.init() - c.mu.Lock() - defer c.mu.Unlock() - return c.start -} - -// GetStep returns the amount that simulated time advances on every call to Now. -func (c *Clock) GetStep() time.Duration { - c.init() - c.mu.Lock() - defer c.mu.Unlock() - return c.step -} - -// SetStep updates the amount that simulated time advances on every call to Now. -func (c *Clock) SetStep(d time.Duration) { - c.init() - c.mu.Lock() - defer c.mu.Unlock() - c.step = d -} - -// SetTimerChannelSize changes the channel size for any Timer or Ticker created -// in the future. It does not affect those that were already created. -func (c *Clock) SetTimerChannelSize(n int) { - c.init() - c.mu.Lock() - defer c.mu.Unlock() - c.timerChannelSize = n -} - -// NewTicker returns a Ticker that uses this Clock for accessing the current -// time. -func (c *Clock) NewTicker(d time.Duration) (tstime.TickerController, <-chan time.Time) { - c.init() - rt := c.maybeGetRealTime() - - c.mu.Lock() - defer c.mu.Unlock() - - c.advanceLocked(rt, 0) - t := &Ticker{ - nextTrigger: c.present.Add(d), - period: d, - em: &c.events, - } - t.init(c.timerChannelSize) - return t, t.C -} - -// NewTimer returns a Timer that uses this Clock for accessing the current -// time. -func (c *Clock) NewTimer(d time.Duration) (tstime.TimerController, <-chan time.Time) { - c.init() - rt := c.maybeGetRealTime() - - c.mu.Lock() - defer c.mu.Unlock() - - c.advanceLocked(rt, 0) - t := &Timer{ - nextTrigger: c.present.Add(d), - em: &c.events, - } - t.init(c.timerChannelSize, nil) - return t, t.C -} - -// AfterFunc returns a Timer that calls f when it fires, using this Clock for -// accessing the current time. -func (c *Clock) AfterFunc(d time.Duration, f func()) tstime.TimerController { - c.init() - rt := c.maybeGetRealTime() - - c.mu.Lock() - defer c.mu.Unlock() - - c.advanceLocked(rt, 0) - t := &Timer{ - nextTrigger: c.present.Add(d), - em: &c.events, - } - t.init(c.timerChannelSize, f) - return t -} - -// Since subtracts specified duration from Now(). -func (c *Clock) Since(t time.Time) time.Duration { - return c.Now().Sub(t) -} - -// eventHandler offers a common interface for Timer and Ticker events to avoid -// code duplication in eventManager. -type eventHandler interface { - // Fire signals the event. The provided time is written to the event's - // channel as the current time. The return value is the next time this event - // should fire, otherwise if it is zero then the event will be removed from - // the eventManager. - Fire(time.Time) time.Time -} - -// event tracks details about an upcoming Timer or Ticker firing. -type event struct { - position int // The current index in the heap, needed for heap.Fix and heap.Remove. - when time.Time // A cache of the next time the event triggers to avoid locking issues if we were to get it from eh. - eh eventHandler -} - -// eventManager tracks pending events created by Timer and Ticker. eventManager -// implements heap.Interface for efficient lookups of the next event. -type eventManager struct { - // clock is a real time clock for scheduling events with. When clock is nil, - // events only fire when AdvanceTo is called by the simulated clock that - // this eventManager belongs to. When clock is not nil, events may fire when - // timer triggers. - clock tstime.Clock - - mu sync.Mutex - now time.Time - heap []*event - reverseLookup map[eventHandler]*event - - // timer is an AfterFunc that triggers at heap[0].when.Sub(now) relative to - // the time represented by clock. In other words, if clock is real world - // time, then if an event is scheduled 1 second into the future in the - // simulated time, then the event will trigger after 1 second of actual test - // execution time (unless the test advances simulated time, in which case - // the timer is updated accordingly). This makes tests easier to write in - // situations where the simulated time only needs to be partially - // controlled, and the test writer wishes for simulated time to pass with an - // offset but still synchronized with the real world. - // - // In the future, this could be extended to allow simulated time to run at a - // multiple of real world time. - timer tstime.TimerController -} - -func (em *eventManager) handleTimer() { - rt := em.clock.Now() - em.AdvanceTo(rt) -} - -// Push implements heap.Interface.Push and must only be called by heap funcs -// with em.mu already held. -func (em *eventManager) Push(x any) { - e, ok := x.(*event) - if !ok { - panic("incorrect event type") - } - if e == nil { - panic("nil event") - } - - mak.Set(&em.reverseLookup, e.eh, e) - e.position = len(em.heap) - em.heap = append(em.heap, e) -} - -// Pop implements heap.Interface.Pop and must only be called by heap funcs with -// em.mu already held. -func (em *eventManager) Pop() any { - e := em.heap[len(em.heap)-1] - em.heap = em.heap[:len(em.heap)-1] - delete(em.reverseLookup, e.eh) - return e -} - -// Len implements sort.Interface.Len and must only be called by heap funcs with -// em.mu already held. -func (em *eventManager) Len() int { - return len(em.heap) -} - -// Less implements sort.Interface.Less and must only be called by heap funcs -// with em.mu already held. -func (em *eventManager) Less(i, j int) bool { - return em.heap[i].when.Before(em.heap[j].when) -} - -// Swap implements sort.Interface.Swap and must only be called by heap funcs -// with em.mu already held. -func (em *eventManager) Swap(i, j int) { - em.heap[i], em.heap[j] = em.heap[j], em.heap[i] - em.heap[i].position = i - em.heap[j].position = j -} - -// Reschedule adds/updates/deletes an event in the heap, whichever -// operation is applicable (use a zero time to delete). -func (em *eventManager) Reschedule(eh eventHandler, t time.Time) { - em.mu.Lock() - defer em.mu.Unlock() - defer em.updateTimerLocked() - - e, ok := em.reverseLookup[eh] - if !ok { - if t.IsZero() { - // eh is not scheduled and also not active, so do nothing. - return - } - // eh is not scheduled but is active, so add it. - heap.Push(em, &event{ - when: t, - eh: eh, - }) - em.processEventsLocked(em.now) // This is always safe and required when !t.After(em.now). - return - } - - if t.IsZero() { - // e is scheduled but not active, so remove it. - heap.Remove(em, e.position) - return - } - - // e is scheduled and active, so update it. - e.when = t - heap.Fix(em, e.position) - em.processEventsLocked(em.now) // This is always safe and required when !t.After(em.now). -} - -// AdvanceTo updates the current time to tm and fires all events scheduled -// before or equal to tm. When an event fires, it may request rescheduling and -// the rescheduled events will be combined with the other existing events that -// are waiting, and will be run in the unified ordering. A poorly behaved event -// may theoretically prevent this from ever completing, but both Timer and -// Ticker require positive steps into the future. -func (em *eventManager) AdvanceTo(tm time.Time) { - em.mu.Lock() - defer em.mu.Unlock() - defer em.updateTimerLocked() - - em.processEventsLocked(tm) - em.now = tm -} - -// Now returns the cached current time. It is intended for use by a Timer or -// Ticker that needs to convert a relative time to an absolute time. -func (em *eventManager) Now() time.Time { - em.mu.Lock() - defer em.mu.Unlock() - return em.now -} - -func (em *eventManager) processEventsLocked(tm time.Time) { - for len(em.heap) > 0 && !em.heap[0].when.After(tm) { - // Ideally some jitter would be added here but it's difficult to do so - // in a deterministic fashion. - em.now = em.heap[0].when - - if nextFire := em.heap[0].eh.Fire(em.now); !nextFire.IsZero() { - em.heap[0].when = nextFire - heap.Fix(em, 0) - } else { - heap.Pop(em) - } - } -} - -func (em *eventManager) updateTimerLocked() { - if em.clock == nil { - return - } - if len(em.heap) == 0 { - if em.timer != nil { - em.timer.Stop() - } - return - } - - timeToEvent := em.heap[0].when.Sub(em.now) - if em.timer == nil { - em.timer = em.clock.AfterFunc(timeToEvent, em.handleTimer) - return - } - em.timer.Reset(timeToEvent) -} - -// Ticker is a time.Ticker lookalike for use in tests that need to control when -// events fire. Ticker could be made standalone in future but for now is -// expected to be paired with a Clock and created by Clock.NewTicker. -type Ticker struct { - C <-chan time.Time // The channel on which ticks are delivered. - - // em is the eventManager to be notified when nextTrigger changes. - // eventManager has its own mutex, and the pointer is immutable, therefore - // em can be accessed without holding mu. - em *eventManager - - c chan<- time.Time // The writer side of C. - - mu sync.Mutex - - // nextTrigger is the time of the ticker's next scheduled activation. When - // Fire activates the ticker, nextTrigger is the timestamp written to the - // channel. - nextTrigger time.Time - - // period is the duration that is added to nextTrigger when the ticker - // fires. - period time.Duration -} - -func (t *Ticker) init(channelSize int) { - if channelSize <= 0 { - panic("ticker channel size must be non-negative") - } - c := make(chan time.Time, channelSize) - t.c = c - t.C = c - t.em.Reschedule(t, t.nextTrigger) -} - -// Fire triggers the ticker. curTime is the timestamp to write to the channel. -// The next trigger time for the ticker is updated to the last computed trigger -// time + the ticker period (set at creation or using Reset). The next trigger -// time is computed this way to match standard time.Ticker behavior, which -// prevents accumulation of long term drift caused by delays in event execution. -func (t *Ticker) Fire(curTime time.Time) time.Time { - t.mu.Lock() - defer t.mu.Unlock() - - if t.nextTrigger.IsZero() { - return time.Time{} - } - select { - case t.c <- curTime: - default: - } - t.nextTrigger = t.nextTrigger.Add(t.period) - - return t.nextTrigger -} - -// Reset adjusts the Ticker's period to d and reschedules the next fire time to -// the current simulated time + d. -func (t *Ticker) Reset(d time.Duration) { - if d <= 0 { - // The standard time.Ticker requires a positive period. - panic("non-positive period for Ticker.Reset") - } - - now := t.em.Now() - - t.mu.Lock() - t.resetLocked(now.Add(d), d) - t.mu.Unlock() - - t.em.Reschedule(t, t.nextTrigger) -} - -// ResetAbsolute adjusts the Ticker's period to d and reschedules the next fire -// time to nextTrigger. -func (t *Ticker) ResetAbsolute(nextTrigger time.Time, d time.Duration) { - if nextTrigger.IsZero() { - panic("zero nextTrigger time for ResetAbsolute") - } - if d <= 0 { - panic("non-positive period for ResetAbsolute") - } - - t.mu.Lock() - t.resetLocked(nextTrigger, d) - t.mu.Unlock() - - t.em.Reschedule(t, t.nextTrigger) -} - -func (t *Ticker) resetLocked(nextTrigger time.Time, d time.Duration) { - t.nextTrigger = nextTrigger - t.period = d -} - -// Stop deactivates the Ticker. -func (t *Ticker) Stop() { - t.mu.Lock() - t.nextTrigger = time.Time{} - t.mu.Unlock() - - t.em.Reschedule(t, t.nextTrigger) -} - -// Timer is a time.Timer lookalike for use in tests that need to control when -// events fire. Timer could be made standalone in future but for now must be -// paired with a Clock and created by Clock.NewTimer. -type Timer struct { - C <-chan time.Time // The channel on which ticks are delivered. - - // em is the eventManager to be notified when nextTrigger changes. - // eventManager has its own mutex, and the pointer is immutable, therefore - // em can be accessed without holding mu. - em *eventManager - - f func(time.Time) // The function to call when the timer expires. - - mu sync.Mutex - - // nextTrigger is the time of the ticker's next scheduled activation. When - // Fire activates the ticker, nextTrigger is the timestamp written to the - // channel. - nextTrigger time.Time -} - -func (t *Timer) init(channelSize int, afterFunc func()) { - if channelSize <= 0 { - panic("ticker channel size must be non-negative") - } - c := make(chan time.Time, channelSize) - t.C = c - if afterFunc == nil { - t.f = func(curTime time.Time) { - select { - case c <- curTime: - default: - } - } - } else { - t.f = func(_ time.Time) { afterFunc() } - } - t.em.Reschedule(t, t.nextTrigger) -} - -// Fire triggers the ticker. curTime is the timestamp to write to the channel. -// The next trigger time for the ticker is updated to the last computed trigger -// time + the ticker period (set at creation or using Reset). The next trigger -// time is computed this way to match standard time.Ticker behavior, which -// prevents accumulation of long term drift caused by delays in event execution. -func (t *Timer) Fire(curTime time.Time) time.Time { - t.mu.Lock() - defer t.mu.Unlock() - - if t.nextTrigger.IsZero() { - return time.Time{} - } - t.nextTrigger = time.Time{} - t.f(curTime) - return time.Time{} -} - -// Reset reschedules the next fire time to the current simulated time + d. -// Reset reports whether the timer was still active before the reset. -func (t *Timer) Reset(d time.Duration) bool { - if d <= 0 { - // The standard time.Timer requires a positive delay. - panic("non-positive delay for Timer.Reset") - } - - return t.reset(t.em.Now().Add(d)) -} - -// ResetAbsolute reschedules the next fire time to nextTrigger. -// ResetAbsolute reports whether the timer was still active before the reset. -func (t *Timer) ResetAbsolute(nextTrigger time.Time) bool { - if nextTrigger.IsZero() { - panic("zero nextTrigger time for ResetAbsolute") - } - - return t.reset(nextTrigger) -} - -// Stop deactivates the Timer. Stop reports whether the timer was active before -// stopping. -func (t *Timer) Stop() bool { - return t.reset(time.Time{}) -} - -func (t *Timer) reset(nextTrigger time.Time) bool { - t.mu.Lock() - wasActive := !t.nextTrigger.IsZero() - t.nextTrigger = nextTrigger - t.mu.Unlock() - - t.em.Reschedule(t, t.nextTrigger) - return wasActive -} diff --git a/tstest/clock_test.go b/tstest/clock_test.go deleted file mode 100644 index d5816564a07f1..0000000000000 --- a/tstest/clock_test.go +++ /dev/null @@ -1,2483 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import ( - "slices" - "sync/atomic" - "testing" - "time" - - "tailscale.com/tstime" -) - -func TestClockWithDefinedStartTime(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - start time.Time - step time.Duration - wants []time.Time // The return values of sequential calls to Now(). - }{ - { - name: "increment ms", - start: time.Unix(12345, 1000), - step: 1000, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 2000), - time.Unix(12345, 3000), - time.Unix(12345, 4000), - }, - }, - { - name: "increment second", - start: time.Unix(12345, 1000), - step: time.Second, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12346, 1000), - time.Unix(12347, 1000), - time.Unix(12348, 1000), - }, - }, - { - name: "no increment", - start: time.Unix(12345, 1000), - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 1000), - time.Unix(12345, 1000), - time.Unix(12345, 1000), - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - clock := NewClock(ClockOpts{ - Start: tt.start, - Step: tt.step, - }) - - if start := clock.GetStart(); !start.Equal(tt.start) { - t.Errorf("clock has start %v, want %v", start, tt.start) - } - if step := clock.GetStep(); step != tt.step { - t.Errorf("clock has step %v, want %v", step, tt.step) - } - - for i := range tt.wants { - if got := clock.Now(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.Now() = %v, want %v", i, got, tt.wants[i]) - } - if got := clock.PeekNow(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.PeekNow() = %v, want %v", i, got, tt.wants[i]) - } - } - }) - } -} - -func TestClockWithDefaultStartTime(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - step time.Duration - wants []time.Duration // The return values of sequential calls to Now() after added to Start() - }{ - { - name: "increment ms", - step: 1000, - wants: []time.Duration{ - 0, - 1000, - 2000, - 3000, - }, - }, - { - name: "increment second", - step: time.Second, - wants: []time.Duration{ - 0 * time.Second, - 1 * time.Second, - 2 * time.Second, - 3 * time.Second, - }, - }, - { - name: "no increment", - wants: []time.Duration{0, 0, 0, 0}, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - clock := NewClock(ClockOpts{ - Step: tt.step, - }) - start := clock.GetStart() - - if step := clock.GetStep(); step != tt.step { - t.Errorf("clock has step %v, want %v", step, tt.step) - } - - for i := range tt.wants { - want := start.Add(tt.wants[i]) - if got := clock.Now(); !got.Equal(want) { - t.Errorf("step %v: clock.Now() = %v, want %v", i, got, tt.wants[i]) - } - if got := clock.PeekNow(); !got.Equal(want) { - t.Errorf("step %v: clock.PeekNow() = %v, want %v", i, got, tt.wants[i]) - } - } - }) - } -} - -func TestZeroInitClock(t *testing.T) { - t.Parallel() - - var clock Clock - start := clock.GetStart() - - if step := clock.GetStep(); step != 0 { - t.Errorf("clock has step %v, want 0", step) - } - - for i := range 10 { - if got := clock.Now(); !got.Equal(start) { - t.Errorf("step %v: clock.Now() = %v, want %v", i, got, start) - } - if got := clock.PeekNow(); !got.Equal(start) { - t.Errorf("step %v: clock.PeekNow() = %v, want %v", i, got, start) - } - } -} - -func TestClockSetStep(t *testing.T) { - t.Parallel() - - type stepInfo struct { - when int - step time.Duration - } - - tests := []struct { - name string - start time.Time - step time.Duration - stepChanges []stepInfo - wants []time.Time // The return values of sequential calls to Now(). - }{ - { - name: "increment ms then s", - start: time.Unix(12345, 1000), - step: 1000, - stepChanges: []stepInfo{ - { - when: 4, - step: time.Second, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 2000), - time.Unix(12345, 3000), - time.Unix(12345, 4000), - time.Unix(12346, 4000), - time.Unix(12347, 4000), - time.Unix(12348, 4000), - time.Unix(12349, 4000), - }, - }, - { - name: "multiple changes over time", - start: time.Unix(12345, 1000), - step: 1, - stepChanges: []stepInfo{ - { - when: 2, - step: time.Second, - }, - { - when: 4, - step: 0, - }, - { - when: 6, - step: 1000, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 1001), - time.Unix(12346, 1001), - time.Unix(12347, 1001), - time.Unix(12347, 1001), - time.Unix(12347, 1001), - time.Unix(12347, 2001), - time.Unix(12347, 3001), - }, - }, - { - name: "multiple changes at once", - start: time.Unix(12345, 1000), - step: 1, - stepChanges: []stepInfo{ - { - when: 2, - step: time.Second, - }, - { - when: 2, - step: 0, - }, - { - when: 2, - step: 1000, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 1001), - time.Unix(12345, 2001), - time.Unix(12345, 3001), - }, - }, - { - name: "changes at start", - start: time.Unix(12345, 1000), - step: 0, - stepChanges: []stepInfo{ - { - when: 0, - step: time.Second, - }, - { - when: 0, - step: 1000, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 2000), - time.Unix(12345, 3000), - time.Unix(12345, 4000), - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - clock := NewClock(ClockOpts{ - Start: tt.start, - Step: tt.step, - }) - wantStep := tt.step - changeIndex := 0 - - for i := range tt.wants { - for len(tt.stepChanges) > changeIndex && tt.stepChanges[changeIndex].when == i { - wantStep = tt.stepChanges[changeIndex].step - clock.SetStep(wantStep) - changeIndex++ - } - - if start := clock.GetStart(); !start.Equal(tt.start) { - t.Errorf("clock has start %v, want %v", start, tt.start) - } - if step := clock.GetStep(); step != wantStep { - t.Errorf("clock has step %v, want %v", step, tt.step) - } - - if got := clock.Now(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.Now() = %v, want %v", i, got, tt.wants[i]) - } - if got := clock.PeekNow(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.PeekNow() = %v, want %v", i, got, tt.wants[i]) - } - } - }) - } -} - -func TestClockAdvance(t *testing.T) { - t.Parallel() - - type advanceInfo struct { - when int - advance time.Duration - } - - tests := []struct { - name string - start time.Time - step time.Duration - advances []advanceInfo - wants []time.Time // The return values of sequential calls to Now(). - }{ - { - name: "increment ms then advance 1s", - start: time.Unix(12345, 1000), - step: 1000, - advances: []advanceInfo{ - { - when: 4, - advance: time.Second, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 2000), - time.Unix(12345, 3000), - time.Unix(12345, 4000), - time.Unix(12346, 4000), - time.Unix(12346, 5000), - time.Unix(12346, 6000), - time.Unix(12346, 7000), - }, - }, - { - name: "multiple advances over time", - start: time.Unix(12345, 1000), - step: 1, - advances: []advanceInfo{ - { - when: 2, - advance: time.Second, - }, - { - when: 4, - advance: 0, - }, - { - when: 6, - advance: 1000, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 1001), - time.Unix(12346, 1001), - time.Unix(12346, 1002), - time.Unix(12346, 1002), - time.Unix(12346, 1003), - time.Unix(12346, 2003), - time.Unix(12346, 2004), - }, - }, - { - name: "multiple advances at once", - start: time.Unix(12345, 1000), - step: 1, - advances: []advanceInfo{ - { - when: 2, - advance: time.Second, - }, - { - when: 2, - advance: 0, - }, - { - when: 2, - advance: 1000, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 1001), - time.Unix(12346, 2001), - time.Unix(12346, 2002), - }, - }, - { - name: "changes at start", - start: time.Unix(12345, 1000), - step: 5, - advances: []advanceInfo{ - { - when: 0, - advance: time.Second, - }, - { - when: 0, - advance: 1000, - }, - }, - wants: []time.Time{ - time.Unix(12346, 2000), - time.Unix(12346, 2005), - time.Unix(12346, 2010), - time.Unix(12346, 2015), - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - clock := NewClock(ClockOpts{ - Start: tt.start, - Step: tt.step, - }) - wantStep := tt.step - changeIndex := 0 - - for i := range tt.wants { - for len(tt.advances) > changeIndex && tt.advances[changeIndex].when == i { - clock.Advance(tt.advances[changeIndex].advance) - changeIndex++ - } - - if start := clock.GetStart(); !start.Equal(tt.start) { - t.Errorf("clock has start %v, want %v", start, tt.start) - } - if step := clock.GetStep(); step != wantStep { - t.Errorf("clock has step %v, want %v", step, tt.step) - } - - if got := clock.Now(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.Now() = %v, want %v", i, got, tt.wants[i]) - } - if got := clock.PeekNow(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.PeekNow() = %v, want %v", i, got, tt.wants[i]) - } - } - }) - } -} - -func expectNoTicks(t *testing.T, tickC <-chan time.Time) { - t.Helper() - select { - case tick := <-tickC: - t.Errorf("wanted no ticks, got %v", tick) - default: - } -} - -func TestSingleTicker(t *testing.T) { - t.Parallel() - - type testStep struct { - stop bool - reset time.Duration - resetAbsolute time.Time - setStep time.Duration - advance time.Duration - advanceRealTime time.Duration - wantTime time.Time - wantTicks []time.Time - } - - tests := []struct { - name string - realTimeOpts *ClockOpts - start time.Time - step time.Duration - period time.Duration - channelSize int - steps []testStep - }{ - { - name: "no tick advance", - start: time.Unix(12345, 0), - period: time.Second, - steps: []testStep{ - { - advance: time.Second - 1, - wantTime: time.Unix(12345, 999_999_999), - }, - }, - }, - { - name: "no tick step", - start: time.Unix(12345, 0), - step: time.Second - 1, - period: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12345, 999_999_999), - }, - }, - }, - { - name: "single tick advance exact", - start: time.Unix(12345, 0), - period: time.Second, - steps: []testStep{ - { - advance: time.Second, - wantTime: time.Unix(12346, 0), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - }, - }, - { - name: "single tick advance extra", - start: time.Unix(12345, 0), - period: time.Second, - steps: []testStep{ - { - advance: time.Second + 1, - wantTime: time.Unix(12346, 1), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - }, - }, - { - name: "single tick step exact", - start: time.Unix(12345, 0), - step: time.Second, - period: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - }, - }, - { - name: "single tick step extra", - start: time.Unix(12345, 0), - step: time.Second + 1, - period: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 1), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - }, - }, - { - name: "single tick per advance", - start: time.Unix(12345, 0), - period: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advance: 4 * time.Second, - wantTime: time.Unix(12349, 0), - wantTicks: []time.Time{time.Unix(12348, 0)}, - }, - { - advance: 2 * time.Second, - wantTime: time.Unix(12351, 0), - wantTicks: []time.Time{time.Unix(12351, 0)}, - }, - { - advance: 2 * time.Second, - wantTime: time.Unix(12353, 0), - }, - { - advance: 2 * time.Second, - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{time.Unix(12354, 0)}, - }, - }, - }, - { - name: "single tick per step", - start: time.Unix(12345, 0), - step: 2 * time.Second, - period: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - }, - { - wantTime: time.Unix(12349, 0), - wantTicks: []time.Time{time.Unix(12348, 0)}, - }, - { - wantTime: time.Unix(12351, 0), - wantTicks: []time.Time{time.Unix(12351, 0)}, - }, - { - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{time.Unix(12354, 0)}, - }, - }, - }, - { - name: "multiple tick per advance", - start: time.Unix(12345, 0), - period: time.Second, - channelSize: 3, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advance: 2 * time.Second, - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{ - time.Unix(12346, 0), - time.Unix(12347, 0), - }, - }, - { - advance: 4 * time.Second, - wantTime: time.Unix(12351, 0), - wantTicks: []time.Time{ - time.Unix(12348, 0), - time.Unix(12349, 0), - time.Unix(12350, 0), - // fourth tick dropped due to channel size - }, - }, - }, - }, - { - name: "multiple tick per step", - start: time.Unix(12345, 0), - step: 3 * time.Second, - period: 2 * time.Second, - channelSize: 3, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12348, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - }, - }, - { - wantTime: time.Unix(12351, 0), - wantTicks: []time.Time{ - time.Unix(12349, 0), - time.Unix(12351, 0), - }, - }, - { - wantTime: time.Unix(12354, 0), - wantTicks: []time.Time{ - time.Unix(12353, 0), - }, - }, - { - wantTime: time.Unix(12357, 0), - wantTicks: []time.Time{ - time.Unix(12355, 0), - time.Unix(12357, 0), - }, - }, - }, - }, - { - name: "stop", - start: time.Unix(12345, 0), - step: 2 * time.Second, - period: time.Second, - channelSize: 3, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{ - time.Unix(12346, 0), - time.Unix(12347, 0), - }, - }, - { - stop: true, - wantTime: time.Unix(12349, 0), - }, - { - wantTime: time.Unix(12351, 0), - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12361, 0), - }, - }, - }, - { - name: "reset while running", - start: time.Unix(12345, 0), - period: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advance: time.Second, - wantTime: time.Unix(12346, 0), - }, - { - advance: time.Second, - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - }, - }, - { - advance: time.Second, - reset: time.Second, - wantTime: time.Unix(12348, 0), - wantTicks: []time.Time{ - time.Unix(12348, 0), - }, - }, - { - setStep: 5 * time.Second, - reset: 10 * time.Second, - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12358, 0), - wantTicks: []time.Time{ - time.Unix(12358, 0), - }, - }, - }, - }, - { - name: "reset while stopped", - start: time.Unix(12345, 0), - step: time.Second, - period: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - }, - }, - { - stop: true, - wantTime: time.Unix(12348, 0), - }, - { - wantTime: time.Unix(12349, 0), - }, - { - reset: time.Second, - wantTime: time.Unix(12350, 0), - wantTicks: []time.Time{ - time.Unix(12350, 0), - }, - }, - { - wantTime: time.Unix(12351, 0), - wantTicks: []time.Time{ - time.Unix(12351, 0), - }, - }, - }, - }, - { - name: "reset absolute", - start: time.Unix(12345, 0), - step: time.Second, - period: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - }, - }, - { - reset: time.Second, - resetAbsolute: time.Unix(12354, 50), - advance: 7 * time.Second, - wantTime: time.Unix(12354, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{ - time.Unix(12354, 50), - }, - }, - { - wantTime: time.Unix(12356, 0), - wantTicks: []time.Time{ - time.Unix(12355, 50), - }, - }, - }, - }, - { - name: "follow real time", - realTimeOpts: new(ClockOpts), - start: time.Unix(12345, 0), - period: 2 * time.Second, - channelSize: 3, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advanceRealTime: 5 * time.Second, - wantTime: time.Unix(12350, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - time.Unix(12349, 0), - }, - }, - { - advance: 5 * time.Second, - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{ - time.Unix(12351, 0), - time.Unix(12353, 0), - time.Unix(12355, 0), - }, - }, - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var realTimeClockForTestClock tstime.Clock - var realTimeClock *Clock - if tt.realTimeOpts != nil { - realTimeClock = NewClock(*tt.realTimeOpts) - // Passing realTimeClock into newClockInternal results in a - // non-nil interface with a nil pointer, so this is necessary. - realTimeClockForTestClock = realTimeClock - } - - clock := newClockInternal(ClockOpts{ - Start: tt.start, - Step: tt.step, - TimerChannelSize: tt.channelSize, - FollowRealTime: realTimeClock != nil, - }, realTimeClockForTestClock) - tc, tickC := clock.NewTicker(tt.period) - tickControl := tc.(*Ticker) - - t.Cleanup(tickControl.Stop) - - expectNoTicks(t, tickC) - - for i, step := range tt.steps { - if step.stop { - tickControl.Stop() - } - - if !step.resetAbsolute.IsZero() { - tickControl.ResetAbsolute(step.resetAbsolute, step.reset) - } else if step.reset > 0 { - tickControl.Reset(step.reset) - } - - if step.setStep > 0 { - clock.SetStep(step.setStep) - } - - if step.advance > 0 { - clock.Advance(step.advance) - } - if step.advanceRealTime > 0 { - realTimeClock.Advance(step.advanceRealTime) - } - - if now := clock.Now(); !step.wantTime.IsZero() && !now.Equal(step.wantTime) { - t.Errorf("step %v now = %v, want %v", i, now, step.wantTime) - } - - for j, want := range step.wantTicks { - select { - case tick := <-tickC: - if tick.Equal(want) { - continue - } - t.Errorf("step %v tick %v = %v, want %v", i, j, tick, want) - default: - t.Errorf("step %v tick %v missing", i, j) - } - } - - expectNoTicks(t, tickC) - } - }) - } -} - -func TestSingleTimer(t *testing.T) { - t.Parallel() - - type testStep struct { - stop bool - stopReturn bool // The expected return value for Stop() if stop is true. - reset time.Duration - resetAbsolute time.Time - resetReturn bool // The expected return value for Reset() or ResetAbsolute(). - setStep time.Duration - advance time.Duration - advanceRealTime time.Duration - wantTime time.Time - wantTicks []time.Time - } - - tests := []struct { - name string - realTimeOpts *ClockOpts - start time.Time - step time.Duration - delay time.Duration - steps []testStep - }{ - { - name: "no tick advance", - start: time.Unix(12345, 0), - delay: time.Second, - steps: []testStep{ - { - advance: time.Second - 1, - wantTime: time.Unix(12345, 999_999_999), - }, - }, - }, - { - name: "no tick step", - start: time.Unix(12345, 0), - step: time.Second - 1, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12345, 999_999_999), - }, - }, - }, - { - name: "single tick advance exact", - start: time.Unix(12345, 0), - delay: time.Second, - steps: []testStep{ - { - advance: time.Second, - wantTime: time.Unix(12346, 0), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - { - advance: time.Second, - wantTime: time.Unix(12347, 0), - }, - }, - }, - { - name: "single tick advance extra", - start: time.Unix(12345, 0), - delay: time.Second, - steps: []testStep{ - { - advance: time.Second + 1, - wantTime: time.Unix(12346, 1), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - { - advance: time.Second, - wantTime: time.Unix(12347, 1), - }, - }, - }, - { - name: "single tick step exact", - start: time.Unix(12345, 0), - step: time.Second, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - { - wantTime: time.Unix(12347, 0), - }, - }, - }, - { - name: "single tick step extra", - start: time.Unix(12345, 0), - step: time.Second + 1, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 1), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - { - wantTime: time.Unix(12347, 2), - }, - }, - }, - { - name: "reset for single tick per advance", - start: time.Unix(12345, 0), - delay: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advance: 4 * time.Second, - wantTime: time.Unix(12349, 0), - wantTicks: []time.Time{time.Unix(12348, 0)}, - }, - { - resetAbsolute: time.Unix(12351, 0), - advance: 2 * time.Second, - wantTime: time.Unix(12351, 0), - wantTicks: []time.Time{time.Unix(12351, 0)}, - }, - { - reset: 3 * time.Second, - advance: 2 * time.Second, - wantTime: time.Unix(12353, 0), - }, - { - advance: 2 * time.Second, - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{time.Unix(12354, 0)}, - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12365, 0), - }, - }, - }, - { - name: "reset for single tick per step", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - }, - { - wantTime: time.Unix(12349, 0), - wantTicks: []time.Time{time.Unix(12348, 0)}, - }, - { - reset: time.Second, - wantTime: time.Unix(12351, 0), - wantTicks: []time.Time{time.Unix(12350, 0)}, - }, - { - resetAbsolute: time.Unix(12354, 0), - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{time.Unix(12354, 0)}, - }, - }, - }, - { - name: "reset while active", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - }, - { - reset: 3 * time.Second, - resetReturn: true, - wantTime: time.Unix(12349, 0), - }, - { - resetAbsolute: time.Unix(12354, 0), - resetReturn: true, - wantTime: time.Unix(12351, 0), - }, - { - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{time.Unix(12354, 0)}, - }, - }, - }, - { - name: "stop after fire", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - { - stop: true, - wantTime: time.Unix(12349, 0), - }, - { - wantTime: time.Unix(12351, 0), - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12361, 0), - }, - }, - }, - { - name: "stop before fire", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: time.Second, - steps: []testStep{ - { - stop: true, - stopReturn: true, - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - }, - { - wantTime: time.Unix(12349, 0), - }, - { - wantTime: time.Unix(12351, 0), - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12361, 0), - }, - }, - }, - { - name: "stop after reset", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{time.Unix(12346, 0)}, - }, - { - reset: 10 * time.Second, - wantTime: time.Unix(12349, 0), - }, - { - stop: true, - stopReturn: true, - wantTime: time.Unix(12351, 0), - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12361, 0), - }, - }, - }, - { - name: "reset while running", - start: time.Unix(12345, 0), - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advance: time.Second, - wantTime: time.Unix(12346, 0), - }, - { - advance: time.Second, - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - }, - }, - { - advance: time.Second, - reset: time.Second, - wantTime: time.Unix(12348, 0), - wantTicks: []time.Time{ - time.Unix(12348, 0), - }, - }, - { - setStep: 5 * time.Second, - reset: 10 * time.Second, - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12358, 0), - wantTicks: []time.Time{ - time.Unix(12358, 0), - }, - }, - }, - }, - { - name: "reset while stopped", - start: time.Unix(12345, 0), - step: time.Second, - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - }, - { - stop: true, - stopReturn: true, - wantTime: time.Unix(12347, 0), - }, - { - wantTime: time.Unix(12348, 0), - }, - { - wantTime: time.Unix(12349, 0), - }, - { - reset: time.Second, - wantTime: time.Unix(12350, 0), - wantTicks: []time.Time{ - time.Unix(12350, 0), - }, - }, - { - wantTime: time.Unix(12351, 0), - }, - }, - }, - { - name: "reset absolute", - start: time.Unix(12345, 0), - step: time.Second, - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - }, - }, - { - resetAbsolute: time.Unix(12354, 50), - advance: 7 * time.Second, - wantTime: time.Unix(12354, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{ - time.Unix(12354, 50), - }, - }, - { - wantTime: time.Unix(12356, 0), - }, - }, - }, - { - name: "follow real time", - realTimeOpts: new(ClockOpts), - start: time.Unix(12345, 0), - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advanceRealTime: 5 * time.Second, - wantTime: time.Unix(12350, 0), - wantTicks: []time.Time{ - time.Unix(12347, 0), - }, - }, - { - reset: 2 * time.Second, - advance: 5 * time.Second, - wantTime: time.Unix(12355, 0), - wantTicks: []time.Time{ - time.Unix(12352, 0), - }, - }, - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var realTimeClockForTestClock tstime.Clock - var realTimeClock *Clock - if tt.realTimeOpts != nil { - realTimeClock = NewClock(*tt.realTimeOpts) - // Passing realTimeClock into newClockInternal results in a - // non-nil interface with a nil pointer, so this is necessary. - realTimeClockForTestClock = realTimeClock - } - - clock := newClockInternal(ClockOpts{ - Start: tt.start, - Step: tt.step, - FollowRealTime: realTimeClock != nil, - }, realTimeClockForTestClock) - tc, tickC := clock.NewTimer(tt.delay) - timerControl := tc.(*Timer) - - t.Cleanup(func() { timerControl.Stop() }) - - expectNoTicks(t, tickC) - - for i, step := range tt.steps { - if step.stop { - if got := timerControl.Stop(); got != step.stopReturn { - t.Errorf("step %v Stop returned %v, want %v", i, got, step.stopReturn) - } - } - - if !step.resetAbsolute.IsZero() { - if got := timerControl.ResetAbsolute(step.resetAbsolute); got != step.resetReturn { - t.Errorf("step %v Reset returned %v, want %v", i, got, step.resetReturn) - } - } - - if step.reset > 0 { - if got := timerControl.Reset(step.reset); got != step.resetReturn { - t.Errorf("step %v Reset returned %v, want %v", i, got, step.resetReturn) - } - } - - if step.setStep > 0 { - clock.SetStep(step.setStep) - } - - if step.advance > 0 { - clock.Advance(step.advance) - } - if step.advanceRealTime > 0 { - realTimeClock.Advance(step.advanceRealTime) - } - - if now := clock.Now(); !step.wantTime.IsZero() && !now.Equal(step.wantTime) { - t.Errorf("step %v now = %v, want %v", i, now, step.wantTime) - } - - for j, want := range step.wantTicks { - select { - case tick := <-tickC: - if tick.Equal(want) { - continue - } - t.Errorf("step %v tick %v = %v, want %v", i, j, tick, want) - default: - t.Errorf("step %v tick %v missing", i, j) - } - } - - expectNoTicks(t, tickC) - } - }) - } -} - -type testEvent struct { - fireTimes []time.Time - scheduleTimes []time.Time -} - -func (te *testEvent) Fire(t time.Time) time.Time { - var ret time.Time - - te.fireTimes = append(te.fireTimes, t) - if len(te.scheduleTimes) > 0 { - ret = te.scheduleTimes[0] - te.scheduleTimes = te.scheduleTimes[1:] - } - return ret -} - -func TestEventManager(t *testing.T) { - t.Parallel() - - var em eventManager - - testEvents := []testEvent{ - { - scheduleTimes: []time.Time{ - time.Unix(12300, 0), // step 1 - time.Unix(12340, 0), // step 1 - time.Unix(12345, 0), // step 1 - time.Unix(12346, 0), // step 1 - time.Unix(12347, 0), // step 3 - time.Unix(12348, 0), // step 4 - time.Unix(12349, 0), // step 4 - }, - }, - { - scheduleTimes: []time.Time{ - time.Unix(12350, 0), // step 4 - time.Unix(12360, 0), // step 5 - time.Unix(12370, 0), // rescheduled - time.Unix(12380, 0), // step 6 - time.Unix(12381, 0), // step 6 - time.Unix(12382, 0), // step 6 - time.Unix(12393, 0), // stopped - }, - }, - { - scheduleTimes: []time.Time{ - time.Unix(12350, 1), // step 4 - time.Unix(12360, 1), // rescheduled - time.Unix(12370, 1), // step 6 - time.Unix(12380, 1), // step 6 - time.Unix(12381, 1), // step 6 - time.Unix(12382, 1), // step 6 - time.Unix(12383, 1), // step 6 - }, - }, - { - scheduleTimes: []time.Time{ - time.Unix(12355, 0), // step 5 - time.Unix(12365, 0), // step 5 - time.Unix(12370, 0), // step 6 - time.Unix(12390, 0), // step 6 - time.Unix(12391, 0), // step 7 - time.Unix(12392, 0), // step 7 - time.Unix(12393, 0), // step 7 - }, - }, - { - scheduleTimes: []time.Time{ - time.Unix(100000, 0), // step 7 - }, - }, - { - scheduleTimes: []time.Time{ - time.Unix(12346, 0), // step 1 - }, - }, - { - scheduleTimes: []time.Time{ - time.Unix(12305, 0), // step 5 - }, - }, - { - scheduleTimes: []time.Time{ - time.Unix(12372, 0), // step 6 - time.Unix(12374, 0), // step 6 - time.Unix(12376, 0), // step 6 - time.Unix(12386, 0), // step 6 - time.Unix(12396, 0), // step 7 - }, - }, - } - - steps := []struct { - reschedule []int - stop []int - advanceTo time.Time - want map[int][]time.Time - waitingEvents int - }{ - { - advanceTo: time.Unix(12345, 0), - }, - { - reschedule: []int{0, 1, 2, 3, 4, 5}, // add 0, 1, 2, 3, 4, 5 - advanceTo: time.Unix(12346, 0), - want: map[int][]time.Time{ - 0: { - time.Unix(12300, 0), - time.Unix(12340, 0), - time.Unix(12345, 0), - time.Unix(12346, 0), - }, - 5: { - time.Unix(12346, 0), - }, - }, - waitingEvents: 5, // scheduled 0, 1, 2, 3, 4, 5; retired 5 - }, - { - advanceTo: time.Unix(12346, 50), - waitingEvents: 5, // no change - }, - { - advanceTo: time.Unix(12347, 50), - want: map[int][]time.Time{ - 0: { - time.Unix(12347, 0), - }, - }, - waitingEvents: 5, // no change - }, - { - advanceTo: time.Unix(12350, 50), - want: map[int][]time.Time{ - 0: { - time.Unix(12348, 0), - time.Unix(12349, 0), - }, - 1: { - time.Unix(12350, 0), - }, - 2: { - time.Unix(12350, 1), - }, - }, - waitingEvents: 4, // retired 0 - }, - { - reschedule: []int{6, 7}, // add 6, 7 - stop: []int{2}, - advanceTo: time.Unix(12365, 0), - want: map[int][]time.Time{ - 1: { - time.Unix(12360, 0), - }, - 3: { - time.Unix(12355, 0), - time.Unix(12365, 0), - }, - 6: { - time.Unix(12305, 0), - }, - }, - waitingEvents: 4, // scheduled 6, 7; retired 2, 5 - }, - { - reschedule: []int{1, 2}, // update 1; add 2 - stop: []int{6}, - advanceTo: time.Unix(12390, 0), - want: map[int][]time.Time{ - 1: { - time.Unix(12380, 0), - time.Unix(12381, 0), - time.Unix(12382, 0), - }, - 2: { - time.Unix(12370, 1), - time.Unix(12380, 1), - time.Unix(12381, 1), - time.Unix(12382, 1), - time.Unix(12383, 1), - }, - 3: { - time.Unix(12370, 0), - time.Unix(12390, 0), - }, - 7: { - time.Unix(12372, 0), - time.Unix(12374, 0), - time.Unix(12376, 0), - time.Unix(12386, 0), - }, - }, - waitingEvents: 3, // scheduled 2, retired 2, stopped 6 - }, - { - stop: []int{1}, // no-op: already stopped - advanceTo: time.Unix(200000, 0), - want: map[int][]time.Time{ - 3: { - time.Unix(12391, 0), - time.Unix(12392, 0), - time.Unix(12393, 0), - }, - 4: { - time.Unix(100000, 0), - }, - 7: { - time.Unix(12396, 0), - }, - }, - waitingEvents: 0, // retired 3, 4, 7 - }, - { - advanceTo: time.Unix(300000, 0), - }, - } - - for i, step := range steps { - for _, idx := range step.reschedule { - ev := &testEvents[idx] - t := ev.scheduleTimes[0] - ev.scheduleTimes = ev.scheduleTimes[1:] - em.Reschedule(ev, t) - } - for _, idx := range step.stop { - ev := &testEvents[idx] - em.Reschedule(ev, time.Time{}) - } - em.AdvanceTo(step.advanceTo) - for j := range testEvents { - if !slices.Equal(testEvents[j].fireTimes, step.want[j]) { - t.Errorf("step %v event %v fire times = %v, want %v", i, j, testEvents[j].fireTimes, step.want[j]) - } - testEvents[j].fireTimes = nil - } - } -} - -func TestClockFollowRealTime(t *testing.T) { - t.Parallel() - - type advanceInfo struct { - when int - advanceTestClock time.Duration - advanceTestClockTo time.Time - advanceRealTimeClock time.Duration - } - - tests := []struct { - name string - start time.Time - wantStart time.Time // This may differ from start when start.IsZero(). - realTimeClockOpts ClockOpts - advances []advanceInfo - wants []time.Time // The return values of sequential calls to Now(). - }{ - { - name: "increment ms then advance 1s", - start: time.Unix(12345, 1000), - wantStart: time.Unix(12345, 1000), - advances: []advanceInfo{ - { - when: 1, - advanceRealTimeClock: 1000, - }, - { - when: 2, - advanceRealTimeClock: 1000, - }, - { - when: 3, - advanceRealTimeClock: 1000, - }, - { - when: 4, - advanceTestClock: time.Second, - }, - { - when: 5, - advanceRealTimeClock: 1000, - }, - { - when: 6, - advanceRealTimeClock: 1000, - }, - { - when: 7, - advanceRealTimeClock: 1000, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 2000), - time.Unix(12345, 3000), - time.Unix(12345, 4000), - time.Unix(12346, 4000), - time.Unix(12346, 5000), - time.Unix(12346, 6000), - time.Unix(12346, 7000), - }, - }, - { - name: "multiple advances over time", - start: time.Unix(12345, 1000), - wantStart: time.Unix(12345, 1000), - advances: []advanceInfo{ - { - when: 1, - advanceRealTimeClock: 1, - }, - { - when: 2, - advanceTestClock: time.Second, - }, - { - when: 3, - advanceRealTimeClock: 1, - }, - { - when: 4, - advanceTestClock: 0, - }, - { - when: 5, - advanceRealTimeClock: 1, - }, - { - when: 6, - advanceTestClock: 1000, - }, - { - when: 7, - advanceRealTimeClock: 1, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 1001), - time.Unix(12346, 1001), - time.Unix(12346, 1002), - time.Unix(12346, 1002), - time.Unix(12346, 1003), - time.Unix(12346, 2003), - time.Unix(12346, 2004), - }, - }, - { - name: "multiple advances at once", - start: time.Unix(12345, 1000), - wantStart: time.Unix(12345, 1000), - advances: []advanceInfo{ - { - when: 1, - advanceRealTimeClock: 1, - }, - { - when: 2, - advanceTestClock: time.Second, - }, - { - when: 2, - advanceTestClock: 0, - }, - { - when: 2, - advanceTestClock: 1000, - }, - { - when: 3, - advanceRealTimeClock: 1, - }, - }, - wants: []time.Time{ - time.Unix(12345, 1000), - time.Unix(12345, 1001), - time.Unix(12346, 2001), - time.Unix(12346, 2002), - }, - }, - { - name: "changes at start", - start: time.Unix(12345, 1000), - wantStart: time.Unix(12345, 1000), - advances: []advanceInfo{ - { - when: 0, - advanceTestClock: time.Second, - }, - { - when: 0, - advanceTestClock: 1000, - }, - { - when: 1, - advanceRealTimeClock: 5, - }, - { - when: 2, - advanceRealTimeClock: 5, - }, - { - when: 3, - advanceRealTimeClock: 5, - }, - }, - wants: []time.Time{ - time.Unix(12346, 2000), - time.Unix(12346, 2005), - time.Unix(12346, 2010), - time.Unix(12346, 2015), - }, - }, - { - name: "start from current time", - realTimeClockOpts: ClockOpts{ - Start: time.Unix(12345, 0), - }, - wantStart: time.Unix(12345, 0), - advances: []advanceInfo{ - { - when: 1, - advanceTestClock: time.Second, - }, - { - when: 2, - advanceRealTimeClock: 10 * time.Second, - }, - { - when: 3, - advanceTestClock: time.Minute, - }, - { - when: 4, - advanceRealTimeClock: time.Hour, - }, - { - when: 5, - advanceTestClockTo: time.Unix(100, 0), - }, - { - when: 6, - advanceRealTimeClock: time.Hour, - }, - }, - wants: []time.Time{ - time.Unix(12345, 0), - time.Unix(12346, 0), - time.Unix(12356, 0), - time.Unix(12416, 0), - time.Unix(16016, 0), - time.Unix(100, 0), - time.Unix(3700, 0), - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - realTimeClock := NewClock(tt.realTimeClockOpts) - clock := newClockInternal(ClockOpts{ - Start: tt.start, - FollowRealTime: true, - }, realTimeClock) - changeIndex := 0 - - for i := range tt.wants { - for len(tt.advances) > changeIndex && tt.advances[changeIndex].when == i { - advance := tt.advances[changeIndex] - if advance.advanceTestClockTo.IsZero() { - clock.Advance(advance.advanceTestClock) - } else { - clock.AdvanceTo(advance.advanceTestClockTo) - } - realTimeClock.Advance(advance.advanceRealTimeClock) - changeIndex++ - } - - if start := clock.GetStart(); !start.Equal(tt.wantStart) { - t.Errorf("clock has start %v, want %v", start, tt.wantStart) - } - - if got := clock.Now(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.Now() = %v, want %v", i, got, tt.wants[i]) - } - if got := clock.PeekNow(); !got.Equal(tt.wants[i]) { - t.Errorf("step %v: clock.PeekNow() = %v, want %v", i, got, tt.wants[i]) - } - } - }) - } -} - -func TestAfterFunc(t *testing.T) { - t.Parallel() - - type testStep struct { - stop bool - stopReturn bool // The expected return value for Stop() if stop is true. - reset time.Duration - resetAbsolute time.Time - resetReturn bool // The expected return value for Reset() or ResetAbsolute(). - setStep time.Duration - advance time.Duration - advanceRealTime time.Duration - wantTime time.Time - wantTick bool - } - - tests := []struct { - name string - realTimeOpts *ClockOpts - start time.Time - step time.Duration - delay time.Duration - steps []testStep - }{ - { - name: "no tick advance", - start: time.Unix(12345, 0), - delay: time.Second, - steps: []testStep{ - { - advance: time.Second - 1, - wantTime: time.Unix(12345, 999_999_999), - }, - }, - }, - { - name: "no tick step", - start: time.Unix(12345, 0), - step: time.Second - 1, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12345, 999_999_999), - }, - }, - }, - { - name: "single tick advance exact", - start: time.Unix(12345, 0), - delay: time.Second, - steps: []testStep{ - { - advance: time.Second, - wantTime: time.Unix(12346, 0), - wantTick: true, - }, - { - advance: time.Second, - wantTime: time.Unix(12347, 0), - }, - }, - }, - { - name: "single tick advance extra", - start: time.Unix(12345, 0), - delay: time.Second, - steps: []testStep{ - { - advance: time.Second + 1, - wantTime: time.Unix(12346, 1), - wantTick: true, - }, - { - advance: time.Second, - wantTime: time.Unix(12347, 1), - }, - }, - }, - { - name: "single tick step exact", - start: time.Unix(12345, 0), - step: time.Second, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - wantTick: true, - }, - { - wantTime: time.Unix(12347, 0), - }, - }, - }, - { - name: "single tick step extra", - start: time.Unix(12345, 0), - step: time.Second + 1, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 1), - wantTick: true, - }, - { - wantTime: time.Unix(12347, 2), - }, - }, - }, - { - name: "reset for single tick per advance", - start: time.Unix(12345, 0), - delay: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advance: 4 * time.Second, - wantTime: time.Unix(12349, 0), - wantTick: true, - }, - { - resetAbsolute: time.Unix(12351, 0), - advance: 2 * time.Second, - wantTime: time.Unix(12351, 0), - wantTick: true, - }, - { - reset: 3 * time.Second, - advance: 2 * time.Second, - wantTime: time.Unix(12353, 0), - }, - { - advance: 2 * time.Second, - wantTime: time.Unix(12355, 0), - wantTick: true, - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12365, 0), - }, - }, - }, - { - name: "reset for single tick per step", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - }, - { - wantTime: time.Unix(12349, 0), - wantTick: true, - }, - { - reset: time.Second, - wantTime: time.Unix(12351, 0), - wantTick: true, - }, - { - resetAbsolute: time.Unix(12354, 0), - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTick: true, - }, - }, - }, - { - name: "reset while active", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: 3 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - }, - { - reset: 3 * time.Second, - resetReturn: true, - wantTime: time.Unix(12349, 0), - }, - { - resetAbsolute: time.Unix(12354, 0), - resetReturn: true, - wantTime: time.Unix(12351, 0), - }, - { - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTick: true, - }, - }, - }, - { - name: "stop after fire", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTick: true, - }, - { - stop: true, - wantTime: time.Unix(12349, 0), - }, - { - wantTime: time.Unix(12351, 0), - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12361, 0), - }, - }, - }, - { - name: "stop before fire", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: time.Second, - steps: []testStep{ - { - stop: true, - stopReturn: true, - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - }, - { - wantTime: time.Unix(12349, 0), - }, - { - wantTime: time.Unix(12351, 0), - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12361, 0), - }, - }, - }, - { - name: "stop after reset", - start: time.Unix(12345, 0), - step: 2 * time.Second, - delay: time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTick: true, - }, - { - reset: 10 * time.Second, - wantTime: time.Unix(12349, 0), - }, - { - stop: true, - stopReturn: true, - wantTime: time.Unix(12351, 0), - }, - { - advance: 10 * time.Second, - wantTime: time.Unix(12361, 0), - }, - }, - }, - { - name: "reset while running", - start: time.Unix(12345, 0), - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advance: time.Second, - wantTime: time.Unix(12346, 0), - }, - { - advance: time.Second, - wantTime: time.Unix(12347, 0), - wantTick: true, - }, - { - advance: time.Second, - reset: time.Second, - wantTime: time.Unix(12348, 0), - wantTick: true, - }, - { - setStep: 5 * time.Second, - reset: 10 * time.Second, - wantTime: time.Unix(12353, 0), - }, - { - wantTime: time.Unix(12358, 0), - wantTick: true, - }, - }, - }, - { - name: "reset while stopped", - start: time.Unix(12345, 0), - step: time.Second, - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - }, - { - stop: true, - stopReturn: true, - wantTime: time.Unix(12347, 0), - }, - { - wantTime: time.Unix(12348, 0), - }, - { - wantTime: time.Unix(12349, 0), - }, - { - reset: time.Second, - wantTime: time.Unix(12350, 0), - wantTick: true, - }, - { - wantTime: time.Unix(12351, 0), - }, - }, - }, - { - name: "reset absolute", - start: time.Unix(12345, 0), - step: time.Second, - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - wantTime: time.Unix(12346, 0), - }, - { - wantTime: time.Unix(12347, 0), - wantTick: true, - }, - { - resetAbsolute: time.Unix(12354, 50), - advance: 7 * time.Second, - wantTime: time.Unix(12354, 0), - }, - { - wantTime: time.Unix(12355, 0), - wantTick: true, - }, - { - wantTime: time.Unix(12356, 0), - }, - }, - }, - { - name: "follow real time", - realTimeOpts: new(ClockOpts), - start: time.Unix(12345, 0), - delay: 2 * time.Second, - steps: []testStep{ - { - wantTime: time.Unix(12345, 0), - }, - { - advanceRealTime: 5 * time.Second, - wantTime: time.Unix(12350, 0), - wantTick: true, - }, - { - reset: 2 * time.Second, - advance: 5 * time.Second, - wantTime: time.Unix(12355, 0), - wantTick: true, - }, - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var realTimeClockForTestClock tstime.Clock - var realTimeClock *Clock - if tt.realTimeOpts != nil { - realTimeClock = NewClock(*tt.realTimeOpts) - // Passing realTimeClock into newClockInternal results in a - // non-nil interface with a nil pointer, so this is necessary. - realTimeClockForTestClock = realTimeClock - } - - var gotTick atomic.Bool - - clock := newClockInternal(ClockOpts{ - Start: tt.start, - Step: tt.step, - FollowRealTime: realTimeClock != nil, - }, realTimeClockForTestClock) - tc := clock.AfterFunc(tt.delay, func() { - if gotTick.Swap(true) == true { - t.Error("multiple ticks detected") - } - }) - timerControl := tc.(*Timer) - - t.Cleanup(func() { timerControl.Stop() }) - - if gotTick.Load() { - t.Error("initial tick detected, want none") - } - - for i, step := range tt.steps { - if step.stop { - if got := timerControl.Stop(); got != step.stopReturn { - t.Errorf("step %v Stop returned %v, want %v", i, got, step.stopReturn) - } - } - - if !step.resetAbsolute.IsZero() { - if got := timerControl.ResetAbsolute(step.resetAbsolute); got != step.resetReturn { - t.Errorf("step %v Reset returned %v, want %v", i, got, step.resetReturn) - } - } - - if step.reset > 0 { - if got := timerControl.Reset(step.reset); got != step.resetReturn { - t.Errorf("step %v Reset returned %v, want %v", i, got, step.resetReturn) - } - } - - if step.setStep > 0 { - clock.SetStep(step.setStep) - } - - if step.advance > 0 { - clock.Advance(step.advance) - } - if step.advanceRealTime > 0 { - realTimeClock.Advance(step.advanceRealTime) - } - - if now := clock.Now(); !step.wantTime.IsZero() && !now.Equal(step.wantTime) { - t.Errorf("step %v now = %v, want %v", i, now, step.wantTime) - } - - if got := gotTick.Swap(false); got != step.wantTick { - t.Errorf("step %v tick %v, want %v", i, got, step.wantTick) - } - } - }) - } -} - -func TestSince(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - start time.Time - since time.Time - want time.Duration - }{ - { - name: "positive", - start: time.Unix(12345, 1000), - since: time.Unix(11111, 1000), - want: 1234 * time.Second, - }, - { - name: "negative", - start: time.Unix(12345, 1000), - since: time.Unix(15436, 1000), - want: -3091 * time.Second, - }, - { - name: "zero", - start: time.Unix(12345, 1000), - since: time.Unix(12345, 1000), - want: 0, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - clock := NewClock(ClockOpts{ - Start: tt.start, - }) - got := clock.Since(tt.since) - if got != tt.want { - t.Errorf("Since duration %v, want %v", got, tt.want) - } - }) - } -} diff --git a/tstest/deptest/deptest.go b/tstest/deptest/deptest.go deleted file mode 100644 index 00faa8a386db8..0000000000000 --- a/tstest/deptest/deptest.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// The deptest package contains a shared implementation of negative -// dependency tests for other packages, making sure we don't start -// depending on certain packages. -package deptest - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "slices" - "strings" - "testing" - - "tailscale.com/util/set" -) - -type DepChecker struct { - GOOS string // optional - GOARCH string // optional - BadDeps map[string]string // package => why - WantDeps set.Set[string] // packages expected - Tags string // comma-separated -} - -func (c DepChecker) Check(t *testing.T) { - if runtime.GOOS == "windows" { - // Slow and avoid caring about "go.exe" etc. - t.Skip("skipping dep tests on windows hosts") - } - t.Helper() - cmd := exec.Command("go", "list", "-json", "-tags="+c.Tags, ".") - var extraEnv []string - if c.GOOS != "" { - extraEnv = append(extraEnv, "GOOS="+c.GOOS) - } - if c.GOARCH != "" { - extraEnv = append(extraEnv, "GOARCH="+c.GOARCH) - } - cmd.Env = append(os.Environ(), extraEnv...) - out, err := cmd.Output() - if err != nil { - t.Fatal(err) - } - var res struct { - Deps []string - } - if err := json.Unmarshal(out, &res); err != nil { - t.Fatal(err) - } - - for _, dep := range res.Deps { - if why, ok := c.BadDeps[dep]; ok { - t.Errorf("package %q is not allowed as a dependency (env: %q); reason: %s", dep, extraEnv, why) - } - } - for dep := range c.WantDeps { - if !slices.Contains(res.Deps, dep) { - t.Errorf("expected package %q to be a dependency (env: %q)", dep, extraEnv) - } - } - t.Logf("got %d dependencies", len(res.Deps)) -} - -// ImportAliasCheck checks that all packages are imported according to Tailscale -// conventions. -func ImportAliasCheck(t testing.TB, relDir string) { - dir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - dir = filepath.Join(dir, relDir) - - cmd := exec.Command("git", "grep", "-n", "-F", `"golang.org/x/exp/`) - cmd.Dir = dir - matches, err := cmd.CombinedOutput() - if err != nil { - t.Logf("ignoring error: %v, %s", err, matches) - return - } - badRx := regexp.MustCompile(`^([^:]+:\d+):\s+"golang\.org/x/exp/(slices|maps)"`) - if s := strings.TrimSpace(string(matches)); s != "" { - for _, line := range strings.Split(s, "\n") { - if m := badRx.FindStringSubmatch(line); m != nil { - t.Errorf("%s: the x/exp/%s package should be imported as x%s", m[1], m[2], m[2]) - } - } - } -} diff --git a/tstest/deptest/deptest_test.go b/tstest/deptest/deptest_test.go deleted file mode 100644 index ebafa56849efb..0000000000000 --- a/tstest/deptest/deptest_test.go +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package deptest - -import "testing" - -func TestImports(t *testing.T) { - ImportAliasCheck(t, "../../") -} diff --git a/tstest/integration/gen_deps.go b/tstest/integration/gen_deps.go deleted file mode 100644 index 23bb95ee56a9f..0000000000000 --- a/tstest/integration/gen_deps.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ignore - -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "log" - "os" - "os/exec" - "strings" -) - -func main() { - for _, goos := range []string{"windows", "linux", "darwin", "freebsd", "openbsd"} { - generate(goos) - } -} - -func generate(goos string) { - var x struct { - Imports []string - } - cmd := exec.Command("go", "list", "-json", "tailscale.com/cmd/tailscaled") - cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH=amd64") - j, err := cmd.Output() - if err != nil { - log.Fatalf("GOOS=%s GOARCH=amd64 %s: %v", goos, cmd, err) - } - if err := json.Unmarshal(j, &x); err != nil { - log.Fatal(err) - } - var out bytes.Buffer - out.WriteString(`// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by gen_deps.go; DO NOT EDIT. - -package integration - -import ( - // And depend on a bunch of tailscaled innards, for Go's test caching. - // Otherwise cmd/go never sees that we depend on these packages' - // transitive deps when we run "go install tailscaled" in a child - // process and can cache a prior success when a dependency changes. -`) - for _, dep := range x.Imports { - if !strings.Contains(dep, ".") { - // Omit standard library deps. - continue - } - fmt.Fprintf(&out, "\t_ %q\n", dep) - } - fmt.Fprintf(&out, ")\n") - - filename := fmt.Sprintf("tailscaled_deps_test_%s.go", goos) - err = os.WriteFile(filename, out.Bytes(), 0644) - if err != nil { - log.Fatal(err) - } -} diff --git a/tstest/integration/integration.go b/tstest/integration/integration.go deleted file mode 100644 index 36a92759f7dd4..0000000000000 --- a/tstest/integration/integration.go +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package integration contains Tailscale integration tests. -// -// This package is considered internal and the public API is subject -// to change without notice. -package integration - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" - - "go4.org/mem" - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/net/stun/stuntest" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/nettype" - "tailscale.com/util/zstdframe" - "tailscale.com/version" -) - -// CleanupBinaries cleans up any resources created by calls to BinaryDir, TailscaleBinary, or TailscaledBinary. -// It should be called from TestMain after all tests have completed. -func CleanupBinaries() { - buildOnce.Do(func() {}) - if binDir != "" { - os.RemoveAll(binDir) - } -} - -// BinaryDir returns a directory containing test tailscale and tailscaled binaries. -// If any test calls BinaryDir, there must be a TestMain function that calls -// CleanupBinaries after all tests are complete. -func BinaryDir(tb testing.TB) string { - buildOnce.Do(func() { - binDir, buildErr = buildTestBinaries() - }) - if buildErr != nil { - tb.Fatal(buildErr) - } - return binDir -} - -// TailscaleBinary returns the path to the test tailscale binary. -// If any test calls TailscaleBinary, there must be a TestMain function that calls -// CleanupBinaries after all tests are complete. -func TailscaleBinary(tb testing.TB) string { - return filepath.Join(BinaryDir(tb), "tailscale"+exe()) -} - -// TailscaledBinary returns the path to the test tailscaled binary. -// If any test calls TailscaleBinary, there must be a TestMain function that calls -// CleanupBinaries after all tests are complete. -func TailscaledBinary(tb testing.TB) string { - return filepath.Join(BinaryDir(tb), "tailscaled"+exe()) -} - -var ( - buildOnce sync.Once - buildErr error - binDir string -) - -// buildTestBinaries builds tailscale and tailscaled. -// It returns the dir containing the binaries. -func buildTestBinaries() (string, error) { - bindir, err := os.MkdirTemp("", "") - if err != nil { - return "", err - } - err = build(bindir, "tailscale.com/cmd/tailscaled", "tailscale.com/cmd/tailscale") - if err != nil { - os.RemoveAll(bindir) - return "", err - } - return bindir, nil -} - -func build(outDir string, targets ...string) error { - goBin, err := findGo() - if err != nil { - return err - } - cmd := exec.Command(goBin, "install") - if version.IsRace() { - cmd.Args = append(cmd.Args, "-race") - } - cmd.Args = append(cmd.Args, targets...) - cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH, "GOBIN="+outDir) - errOut, err := cmd.CombinedOutput() - if err == nil { - return nil - } - if strings.Contains(string(errOut), "when GOBIN is set") { - // Fallback slow path for cross-compiled binaries. - for _, target := range targets { - outFile := filepath.Join(outDir, path.Base(target)+exe()) - cmd := exec.Command(goBin, "build", "-o", outFile) - if version.IsRace() { - cmd.Args = append(cmd.Args, "-race") - } - cmd.Args = append(cmd.Args, target) - cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH) - if errOut, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to build %v with %v: %v, %s", target, goBin, err, errOut) - } - } - return nil - } - return fmt.Errorf("failed to build %v with %v: %v, %s", targets, goBin, err, errOut) -} - -func findGo() (string, error) { - // Go 1.19 attempted to be helpful by prepending $PATH with GOROOT/bin based - // on the executed go binary when invoked using `go test` or `go generate`, - // however, this doesn't cover cases when run otherwise, such as via `go run`. - // runtime.GOROOT() may often be empty these days, so the safe thing to do - // here is, in order: - // 1. Look for a go binary in $PATH[0]. - // 2. Look for a go binary in runtime.GOROOT()/bin if runtime.GOROOT() is non-empty. - // 3. Look for a go binary in $PATH. - - // For tests we want to run as root on GitHub actions, we run with -exec=sudo, - // but that results in this test running with a different PATH and picking the - // wrong Go. So hard code the GitHub Actions case. - if os.Getuid() == 0 && os.Getenv("GITHUB_ACTIONS") == "true" { - const sudoGithubGo = "/home/runner/.cache/tailscale-go/bin/go" - if _, err := os.Stat(sudoGithubGo); err == nil { - return sudoGithubGo, nil - } - } - - paths := strings.FieldsFunc(os.Getenv("PATH"), func(r rune) bool { return os.IsPathSeparator(uint8(r)) }) - if len(paths) > 0 { - candidate := filepath.Join(paths[0], "go"+exe()) - if path, err := exec.LookPath(candidate); err == nil { - return path, err - } - } - - if runtime.GOROOT() != "" { - candidate := filepath.Join(runtime.GOROOT(), "bin", "go"+exe()) - if path, err := exec.LookPath(candidate); err == nil { - return path, err - } - } - - return exec.LookPath("go") -} - -func exe() string { - if runtime.GOOS == "windows" { - return ".exe" - } - return "" -} - -// RunDERPAndSTUN runs a local DERP and STUN server for tests, returning the derpMap -// that clients should use. This creates resources that must be cleaned up with the -// returned cleanup function. -func RunDERPAndSTUN(t testing.TB, logf logger.Logf, ipAddress string) (derpMap *tailcfg.DERPMap) { - t.Helper() - - d := derp.NewServer(key.NewNode(), logf) - - ln, err := net.Listen("tcp", net.JoinHostPort(ipAddress, "0")) - if err != nil { - t.Fatal(err) - } - - httpsrv := httptest.NewUnstartedServer(derphttp.Handler(d)) - httpsrv.Listener.Close() - httpsrv.Listener = ln - httpsrv.Config.ErrorLog = logger.StdLogger(logf) - httpsrv.Config.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) - httpsrv.StartTLS() - - stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{}) - - m := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - RegionID: 1, - RegionCode: "test", - Nodes: []*tailcfg.DERPNode{ - { - Name: "t1", - RegionID: 1, - HostName: ipAddress, - IPv4: ipAddress, - IPv6: "none", - STUNPort: stunAddr.Port, - DERPPort: httpsrv.Listener.Addr().(*net.TCPAddr).Port, - InsecureForTests: true, - STUNTestIP: ipAddress, - }, - }, - }, - }, - } - - t.Logf("DERP httpsrv listener: %v", httpsrv.Listener.Addr()) - - t.Cleanup(func() { - httpsrv.CloseClientConnections() - httpsrv.Close() - d.Close() - stunCleanup() - ln.Close() - }) - - return m -} - -// LogCatcher is a minimal logcatcher for the logtail upload client. -type LogCatcher struct { - mu sync.Mutex - logf logger.Logf - buf bytes.Buffer - gotErr error - reqs int - raw bool // indicates whether to store the raw JSON logs uploaded, instead of just the text -} - -// UseLogf makes the logcatcher implementation use a given logf function -// to dump all logs to. -func (lc *LogCatcher) UseLogf(fn logger.Logf) { - lc.mu.Lock() - defer lc.mu.Unlock() - lc.logf = fn -} - -// StoreRawJSON instructs lc to save the raw JSON uploads, rather than just the text. -func (lc *LogCatcher) StoreRawJSON() { - lc.mu.Lock() - defer lc.mu.Unlock() - lc.raw = true -} - -func (lc *LogCatcher) logsContains(sub mem.RO) bool { - lc.mu.Lock() - defer lc.mu.Unlock() - return mem.Contains(mem.B(lc.buf.Bytes()), sub) -} - -func (lc *LogCatcher) numRequests() int { - lc.mu.Lock() - defer lc.mu.Unlock() - return lc.reqs -} - -func (lc *LogCatcher) logsString() string { - lc.mu.Lock() - defer lc.mu.Unlock() - return lc.buf.String() -} - -// Reset clears the buffered logs from memory. -func (lc *LogCatcher) Reset() { - lc.mu.Lock() - defer lc.mu.Unlock() - lc.buf.Reset() -} - -func (lc *LogCatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // POST /c// - if r.Method != "POST" { - log.Printf("bad logcatcher method: %v", r.Method) - http.Error(w, "only POST is supported", 400) - return - } - pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/c/"), "/") - if len(pathParts) != 2 { - log.Printf("bad logcatcher path: %q", r.URL.Path) - http.Error(w, "bad URL", 400) - return - } - // collectionName := pathPaths[0] - privID, err := logid.ParsePrivateID(pathParts[1]) - if err != nil { - log.Printf("bad log ID: %q: %v", r.URL.Path, err) - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - log.Printf("http.Request.Body.Read: %v", err) - return - } - if r.Header.Get("Content-Encoding") == "zstd" { - bodyBytes, err = zstdframe.AppendDecode(nil, bodyBytes) - if err != nil { - log.Printf("zstdframe.AppendDecode: %v", err) - http.Error(w, err.Error(), 400) - return - } - } - - type Entry struct { - Logtail struct { - ClientTime time.Time `json:"client_time"` - ServerTime time.Time `json:"server_time"` - Error struct { - BadData string `json:"bad_data"` - } `json:"error"` - } `json:"logtail"` - Text string `json:"text"` - } - var jreq []Entry - if len(bodyBytes) > 0 && bodyBytes[0] == '[' { - err = json.Unmarshal(bodyBytes, &jreq) - } else { - var ent Entry - err = json.Unmarshal(bodyBytes, &ent) - jreq = append(jreq, ent) - } - - lc.mu.Lock() - defer lc.mu.Unlock() - lc.reqs++ - if lc.gotErr == nil && err != nil { - lc.gotErr = err - } - if err != nil { - fmt.Fprintf(&lc.buf, "error from %s of %#q: %v\n", r.Method, bodyBytes, err) - if lc.logf != nil { - lc.logf("error from %s of %#q: %v\n", r.Method, bodyBytes, err) - } - } else { - id := privID.Public().String()[:3] // good enough for integration tests - for _, ent := range jreq { - if lc.raw { - lc.buf.Write(bodyBytes) - continue - } - fmt.Fprintf(&lc.buf, "%s\n", strings.TrimSpace(ent.Text)) - if lc.logf != nil { - lc.logf("logcatch:%s: %s", id, strings.TrimSpace(ent.Text)) - } - } - } - w.WriteHeader(200) // must have no content, but not a 204 -} diff --git a/tstest/integration/integration_test.go b/tstest/integration/integration_test.go deleted file mode 100644 index 70c5d68c336e0..0000000000000 --- a/tstest/integration/integration_test.go +++ /dev/null @@ -1,2074 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package integration - -//go:generate go run gen_deps.go - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/miekg/dns" - "go4.org/mem" - "tailscale.com/client/tailscale" - "tailscale.com/clientupdate" - "tailscale.com/cmd/testwrapper/flakytest" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/ipnstate" - "tailscale.com/ipn/store" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tstun" - "tailscale.com/safesocket" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/tstest/integration/testcontrol" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/util/dnsname" - "tailscale.com/util/must" - "tailscale.com/util/rands" - "tailscale.com/version" -) - -var ( - verboseTailscaled = flag.Bool("verbose-tailscaled", false, "verbose tailscaled logging") - verboseTailscale = flag.Bool("verbose-tailscale", false, "verbose tailscale CLI logging") -) - -var mainError syncs.AtomicValue[error] - -func TestMain(m *testing.M) { - // Have to disable UPnP which hits the network, otherwise it fails due to HTTP proxy. - os.Setenv("TS_DISABLE_UPNP", "true") - flag.Parse() - v := m.Run() - CleanupBinaries() - if v != 0 { - os.Exit(v) - } - if err := mainError.Load(); err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) - os.Exit(1) - } - os.Exit(0) -} - -// Tests that tailscaled starts up in TUN mode, and also without data races: -// https://github.com/tailscale/tailscale/issues/7894 -func TestTUNMode(t *testing.T) { - tstest.Shard(t) - if os.Getuid() != 0 { - t.Skip("skipping when not root") - } - tstest.Parallel(t) - env := newTestEnv(t) - env.tunMode = true - n1 := newTestNode(t, env) - d1 := n1.StartDaemon() - - n1.AwaitResponding() - n1.MustUp() - - t.Logf("Got IP: %v", n1.AwaitIP4()) - n1.AwaitRunning() - - d1.MustCleanShutdown(t) -} - -func TestOneNodeUpNoAuth(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - - d1 := n1.StartDaemon() - n1.AwaitResponding() - n1.MustUp() - - t.Logf("Got IP: %v", n1.AwaitIP4()) - n1.AwaitRunning() - - d1.MustCleanShutdown(t) - - t.Logf("number of HTTP logcatcher requests: %v", env.LogCatcher.numRequests()) -} - -func TestOneNodeExpiredKey(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - - d1 := n1.StartDaemon() - n1.AwaitResponding() - n1.MustUp() - n1.AwaitRunning() - - nodes := env.Control.AllNodes() - if len(nodes) != 1 { - t.Fatalf("expected 1 node, got %d nodes", len(nodes)) - } - - nodeKey := nodes[0].Key - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := env.Control.AwaitNodeInMapRequest(ctx, nodeKey); err != nil { - t.Fatal(err) - } - cancel() - - env.Control.SetExpireAllNodes(true) - n1.AwaitNeedsLogin() - ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) - if err := env.Control.AwaitNodeInMapRequest(ctx, nodeKey); err != nil { - t.Fatal(err) - } - cancel() - - env.Control.SetExpireAllNodes(false) - n1.AwaitRunning() - - d1.MustCleanShutdown(t) -} - -func TestControlKnobs(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - - d1 := n1.StartDaemon() - defer d1.MustCleanShutdown(t) - n1.AwaitResponding() - n1.MustUp() - - t.Logf("Got IP: %v", n1.AwaitIP4()) - n1.AwaitRunning() - - cmd := n1.Tailscale("debug", "control-knobs") - cmd.Stdout = nil // in case --verbose-tailscale was set - cmd.Stderr = nil // in case --verbose-tailscale was set - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatal(err) - } - t.Logf("control-knobs output:\n%s", out) - var m map[string]any - if err := json.Unmarshal(out, &m); err != nil { - t.Fatal(err) - } - if got, want := m["DisableUPnP"], true; got != want { - t.Errorf("control-knobs DisableUPnP = %v; want %v", got, want) - } -} - -func TestCollectPanic(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n := newTestNode(t, env) - - cmd := exec.Command(env.daemon, "--cleanup") - cmd.Env = append(os.Environ(), - "TS_PLEASE_PANIC=1", - "TS_LOG_TARGET="+n.env.LogCatcherServer.URL, - ) - got, _ := cmd.CombinedOutput() // we expect it to fail, ignore err - t.Logf("initial run: %s", got) - - // Now we run it again, and on start, it will upload the logs to logcatcher. - cmd = exec.Command(env.daemon, "--cleanup") - cmd.Env = append(os.Environ(), "TS_LOG_TARGET="+n.env.LogCatcherServer.URL) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("cleanup failed: %v: %q", err, out) - } - if err := tstest.WaitFor(20*time.Second, func() error { - const sub = `panic` - if !n.env.LogCatcher.logsContains(mem.S(sub)) { - return fmt.Errorf("log catcher didn't see %#q; got %s", sub, n.env.LogCatcher.logsString()) - } - return nil - }); err != nil { - t.Fatal(err) - } -} - -func TestControlTimeLogLine(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - env.LogCatcher.StoreRawJSON() - n := newTestNode(t, env) - - n.StartDaemon() - n.AwaitResponding() - n.MustUp() - n.AwaitRunning() - - if err := tstest.WaitFor(20*time.Second, func() error { - const sub = `"controltime":"2020-08-03T00:00:00.000000001Z"` - if !n.env.LogCatcher.logsContains(mem.S(sub)) { - return fmt.Errorf("log catcher didn't see %#q; got %s", sub, n.env.LogCatcher.logsString()) - } - return nil - }); err != nil { - t.Fatal(err) - } -} - -// test Issue 2321: Start with UpdatePrefs should save prefs to disk -func TestStateSavedOnStart(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - - d1 := n1.StartDaemon() - n1.AwaitResponding() - n1.MustUp() - - t.Logf("Got IP: %v", n1.AwaitIP4()) - n1.AwaitRunning() - - p1 := n1.diskPrefs() - t.Logf("Prefs1: %v", p1.Pretty()) - - // Bring it down, to prevent an EditPrefs call in the - // subsequent "up", as we want to test the bug when - // cmd/tailscale implements "up" via LocalBackend.Start. - n1.MustDown() - - // And change the hostname to something: - if err := n1.Tailscale("up", "--login-server="+n1.env.controlURL(), "--hostname=foo").Run(); err != nil { - t.Fatalf("up: %v", err) - } - - p2 := n1.diskPrefs() - if pretty := p1.Pretty(); pretty == p2.Pretty() { - t.Errorf("Prefs didn't change on disk after 'up', still: %s", pretty) - } - if p2.Hostname != "foo" { - t.Errorf("Prefs.Hostname = %q; want foo", p2.Hostname) - } - - d1.MustCleanShutdown(t) -} - -func TestOneNodeUpAuth(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t, configureControl(func(control *testcontrol.Server) { - control.RequireAuth = true - })) - - n1 := newTestNode(t, env) - d1 := n1.StartDaemon() - - n1.AwaitListening() - - st := n1.MustStatus() - t.Logf("Status: %s", st.BackendState) - - t.Logf("Running up --login-server=%s ...", env.controlURL()) - - cmd := n1.Tailscale("up", "--login-server="+env.controlURL()) - var authCountAtomic int32 - cmd.Stdout = &authURLParserWriter{fn: func(urlStr string) error { - if env.Control.CompleteAuth(urlStr) { - atomic.AddInt32(&authCountAtomic, 1) - t.Logf("completed auth path %s", urlStr) - return nil - } - err := fmt.Errorf("Failed to complete auth path to %q", urlStr) - t.Log(err) - return err - }} - cmd.Stderr = cmd.Stdout - if err := cmd.Run(); err != nil { - t.Fatalf("up: %v", err) - } - t.Logf("Got IP: %v", n1.AwaitIP4()) - - n1.AwaitRunning() - - if n := atomic.LoadInt32(&authCountAtomic); n != 1 { - t.Errorf("Auth URLs completed = %d; want 1", n) - } - - d1.MustCleanShutdown(t) -} - -func TestConfigFileAuthKey(t *testing.T) { - tstest.SkipOnUnshardedCI(t) - tstest.Shard(t) - t.Parallel() - const authKey = "opensesame" - env := newTestEnv(t, configureControl(func(control *testcontrol.Server) { - control.RequireAuthKey = authKey - })) - - n1 := newTestNode(t, env) - n1.configFile = filepath.Join(n1.dir, "config.json") - authKeyFile := filepath.Join(n1.dir, "my-auth-key") - must.Do(os.WriteFile(authKeyFile, fmt.Appendf(nil, "%s\n", authKey), 0666)) - must.Do(os.WriteFile(n1.configFile, must.Get(json.Marshal(ipn.ConfigVAlpha{ - Version: "alpha0", - AuthKey: ptr.To("file:" + authKeyFile), - ServerURL: ptr.To(n1.env.ControlServer.URL), - })), 0644)) - d1 := n1.StartDaemon() - - n1.AwaitListening() - t.Logf("Got IP: %v", n1.AwaitIP4()) - n1.AwaitRunning() - - d1.MustCleanShutdown(t) -} - -func TestTwoNodes(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - - // Create two nodes: - n1 := newTestNode(t, env) - n1SocksAddrCh := n1.socks5AddrChan() - d1 := n1.StartDaemon() - - n2 := newTestNode(t, env) - n2SocksAddrCh := n2.socks5AddrChan() - d2 := n2.StartDaemon() - - // Drop some logs to disk on test failure. - // - // TODO(bradfitz): make all nodes for all tests do this? give each node a - // unique integer within the test? But for now only do this test because - // this is what we often saw flaking. - t.Cleanup(func() { - if !t.Failed() { - return - } - n1.mu.Lock() - n2.mu.Lock() - defer n1.mu.Unlock() - defer n2.mu.Unlock() - - rxNoDates := regexp.MustCompile(`(?m)^\d{4}.\d{2}.\d{2}.\d{2}:\d{2}:\d{2}`) - cleanLog := func(n *testNode) []byte { - b := n.tailscaledParser.allBuf.Bytes() - b = rxNoDates.ReplaceAll(b, nil) - return b - } - - t.Logf("writing tailscaled logs to n1.log and n2.log") - os.WriteFile("n1.log", cleanLog(n1), 0666) - os.WriteFile("n2.log", cleanLog(n2), 0666) - }) - - n1Socks := n1.AwaitSocksAddr(n1SocksAddrCh) - n2Socks := n1.AwaitSocksAddr(n2SocksAddrCh) - t.Logf("node1 SOCKS5 addr: %v", n1Socks) - t.Logf("node2 SOCKS5 addr: %v", n2Socks) - - n1.AwaitListening() - t.Logf("n1 is listening") - n2.AwaitListening() - t.Logf("n2 is listening") - n1.MustUp() - t.Logf("n1 is up") - n2.MustUp() - t.Logf("n2 is up") - n1.AwaitRunning() - t.Logf("n1 is running") - n2.AwaitRunning() - t.Logf("n2 is running") - - if err := tstest.WaitFor(2*time.Second, func() error { - st := n1.MustStatus() - if len(st.Peer) == 0 { - return errors.New("no peers") - } - if len(st.Peer) > 1 { - return fmt.Errorf("got %d peers; want 1", len(st.Peer)) - } - peer := st.Peer[st.Peers()[0]] - if peer.ID == st.Self.ID { - return errors.New("peer is self") - } - - if len(st.TailscaleIPs) == 0 { - return errors.New("no Tailscale IPs") - } - - return nil - }); err != nil { - t.Error(err) - } - - d1.MustCleanShutdown(t) - d2.MustCleanShutdown(t) -} - -// tests two nodes where the first gets a incremental MapResponse (with only -// PeersRemoved set) saying that the second node disappeared. -func TestIncrementalMapUpdatePeersRemoved(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - - // Create one node: - n1 := newTestNode(t, env) - d1 := n1.StartDaemon() - n1.AwaitListening() - n1.MustUp() - n1.AwaitRunning() - - all := env.Control.AllNodes() - if len(all) != 1 { - t.Fatalf("expected 1 node, got %d nodes", len(all)) - } - tnode1 := all[0] - - n2 := newTestNode(t, env) - d2 := n2.StartDaemon() - n2.AwaitListening() - n2.MustUp() - n2.AwaitRunning() - - all = env.Control.AllNodes() - if len(all) != 2 { - t.Fatalf("expected 2 node, got %d nodes", len(all)) - } - var tnode2 *tailcfg.Node - for _, n := range all { - if n.ID != tnode1.ID { - tnode2 = n - break - } - } - if tnode2 == nil { - t.Fatalf("failed to find second node ID (two dups?)") - } - - t.Logf("node1=%v, node2=%v", tnode1.ID, tnode2.ID) - - if err := tstest.WaitFor(2*time.Second, func() error { - st := n1.MustStatus() - if len(st.Peer) == 0 { - return errors.New("no peers") - } - if len(st.Peer) > 1 { - return fmt.Errorf("got %d peers; want 1", len(st.Peer)) - } - peer := st.Peer[st.Peers()[0]] - if peer.ID == st.Self.ID { - return errors.New("peer is self") - } - return nil - }); err != nil { - t.Fatal(err) - } - - t.Logf("node1 saw node2") - - // Now tell node1 that node2 is removed. - if !env.Control.AddRawMapResponse(tnode1.Key, &tailcfg.MapResponse{ - PeersRemoved: []tailcfg.NodeID{tnode2.ID}, - }) { - t.Fatalf("failed to add map response") - } - - // And see that node1 saw that. - if err := tstest.WaitFor(2*time.Second, func() error { - st := n1.MustStatus() - if len(st.Peer) == 0 { - return nil - } - return fmt.Errorf("got %d peers; want 0", len(st.Peer)) - }); err != nil { - t.Fatal(err) - } - - t.Logf("node1 saw node2 disappear") - - d1.MustCleanShutdown(t) - d2.MustCleanShutdown(t) -} - -func TestNodeAddressIPFields(t *testing.T) { - tstest.Shard(t) - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/7008") - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - d1 := n1.StartDaemon() - - n1.AwaitListening() - n1.MustUp() - n1.AwaitRunning() - - testNodes := env.Control.AllNodes() - - if len(testNodes) != 1 { - t.Errorf("Expected %d nodes, got %d", 1, len(testNodes)) - } - node := testNodes[0] - if len(node.Addresses) == 0 { - t.Errorf("Empty Addresses field in node") - } - if len(node.AllowedIPs) == 0 { - t.Errorf("Empty AllowedIPs field in node") - } - - d1.MustCleanShutdown(t) -} - -func TestAddPingRequest(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - n1.StartDaemon() - - n1.AwaitListening() - n1.MustUp() - n1.AwaitRunning() - - gotPing := make(chan bool, 1) - waitPing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPing <- true - })) - defer waitPing.Close() - - nodes := env.Control.AllNodes() - if len(nodes) != 1 { - t.Fatalf("expected 1 node, got %d nodes", len(nodes)) - } - - nodeKey := nodes[0].Key - - // Check that we get at least one ping reply after 10 tries. - for try := 1; try <= 10; try++ { - t.Logf("ping %v ...", try) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := env.Control.AwaitNodeInMapRequest(ctx, nodeKey); err != nil { - t.Fatal(err) - } - cancel() - - pr := &tailcfg.PingRequest{URL: fmt.Sprintf("%s/ping-%d", waitPing.URL, try), Log: true} - if !env.Control.AddPingRequest(nodeKey, pr) { - t.Logf("failed to AddPingRequest") - continue - } - - // Wait for PingRequest to come back - pingTimeout := time.NewTimer(2 * time.Second) - defer pingTimeout.Stop() - select { - case <-gotPing: - t.Logf("got ping; success") - return - case <-pingTimeout.C: - // Try again. - } - } - t.Error("all ping attempts failed") -} - -func TestC2NPingRequest(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - - env := newTestEnv(t) - - gotPing := make(chan bool, 1) - env.Control.HandleC2N = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("unexpected ping method %q", r.Method) - } - got, err := io.ReadAll(r.Body) - if err != nil { - t.Errorf("ping body read error: %v", err) - } - const want = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nabc" - if string(got) != want { - t.Errorf("body error\n got: %q\nwant: %q", got, want) - } - gotPing <- true - }) - - n1 := newTestNode(t, env) - n1.StartDaemon() - - n1.AwaitListening() - n1.MustUp() - n1.AwaitRunning() - - nodes := env.Control.AllNodes() - if len(nodes) != 1 { - t.Fatalf("expected 1 node, got %d nodes", len(nodes)) - } - - nodeKey := nodes[0].Key - - // Check that we get at least one ping reply after 10 tries. - for try := 1; try <= 10; try++ { - t.Logf("ping %v ...", try) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := env.Control.AwaitNodeInMapRequest(ctx, nodeKey); err != nil { - t.Fatal(err) - } - cancel() - - pr := &tailcfg.PingRequest{ - URL: fmt.Sprintf("https://unused/some-c2n-path/ping-%d", try), - Log: true, - Types: "c2n", - Payload: []byte("POST /echo HTTP/1.0\r\nContent-Length: 3\r\n\r\nabc"), - } - if !env.Control.AddPingRequest(nodeKey, pr) { - t.Logf("failed to AddPingRequest") - continue - } - - // Wait for PingRequest to come back - pingTimeout := time.NewTimer(2 * time.Second) - defer pingTimeout.Stop() - select { - case <-gotPing: - t.Logf("got ping; success") - return - case <-pingTimeout.C: - // Try again. - } - } - t.Error("all ping attempts failed") -} - -// Issue 2434: when "down" (WantRunning false), tailscaled shouldn't -// be connected to control. -func TestNoControlConnWhenDown(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - - d1 := n1.StartDaemon() - n1.AwaitResponding() - - // Come up the first time. - n1.MustUp() - ip1 := n1.AwaitIP4() - n1.AwaitRunning() - - // Then bring it down and stop the daemon. - n1.MustDown() - d1.MustCleanShutdown(t) - - env.LogCatcher.Reset() - d2 := n1.StartDaemon() - n1.AwaitResponding() - - n1.AwaitBackendState("Stopped") - - ip2 := n1.AwaitIP4() - if ip1 != ip2 { - t.Errorf("IPs different: %q vs %q", ip1, ip2) - } - - // The real test: verify our daemon doesn't have an HTTP request open. - if n := env.Control.InServeMap(); n != 0 { - t.Errorf("in serve map = %d; want 0", n) - } - - d2.MustCleanShutdown(t) -} - -// Issue 2137: make sure Windows tailscaled works with the CLI alone, -// without the GUI to kick off a Start. -func TestOneNodeUpWindowsStyle(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - n1 := newTestNode(t, env) - n1.upFlagGOOS = "windows" - - d1 := n1.StartDaemonAsIPNGOOS("windows") - n1.AwaitResponding() - n1.MustUp("--unattended") - - t.Logf("Got IP: %v", n1.AwaitIP4()) - n1.AwaitRunning() - - d1.MustCleanShutdown(t) -} - -// TestClientSideJailing tests that when one node is jailed for another, the -// jailed node cannot initiate connections to the other node however the other -// node can initiate connections to the jailed node. -func TestClientSideJailing(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - registerNode := func() (*testNode, key.NodePublic) { - n := newTestNode(t, env) - n.StartDaemon() - n.AwaitListening() - n.MustUp() - n.AwaitRunning() - k := n.MustStatus().Self.PublicKey - return n, k - } - n1, k1 := registerNode() - n2, k2 := registerNode() - - ln, err := net.Listen("tcp", "localhost:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - port := uint16(ln.Addr().(*net.TCPAddr).Port) - - lc1 := &tailscale.LocalClient{ - Socket: n1.sockFile, - UseSocketOnly: true, - } - lc2 := &tailscale.LocalClient{ - Socket: n2.sockFile, - UseSocketOnly: true, - } - - ip1 := n1.AwaitIP4() - ip2 := n2.AwaitIP4() - - tests := []struct { - name string - n1JailedForN2 bool - n2JailedForN1 bool - }{ - { - name: "not_jailed", - n1JailedForN2: false, - n2JailedForN1: false, - }, - { - name: "uni_jailed", - n1JailedForN2: true, - n2JailedForN1: false, - }, - { - name: "bi_jailed", // useless config? - n1JailedForN2: true, - n2JailedForN1: true, - }, - } - - testDial := func(t *testing.T, lc *tailscale.LocalClient, ip netip.Addr, port uint16, shouldFail bool) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - c, err := lc.DialTCP(ctx, ip.String(), port) - failed := err != nil - if failed != shouldFail { - t.Errorf("failed = %v; want %v", failed, shouldFail) - } - if c != nil { - c.Close() - } - } - - b1, err := lc1.WatchIPNBus(context.Background(), 0) - if err != nil { - t.Fatal(err) - } - b2, err := lc2.WatchIPNBus(context.Background(), 0) - if err != nil { - t.Fatal(err) - } - waitPeerIsJailed := func(t *testing.T, b *tailscale.IPNBusWatcher, jailed bool) { - t.Helper() - for { - n, err := b.Next() - if err != nil { - t.Fatal(err) - } - if n.NetMap == nil { - continue - } - if len(n.NetMap.Peers) == 0 { - continue - } - if j := n.NetMap.Peers[0].IsJailed(); j == jailed { - break - } - } - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - env.Control.SetJailed(k1, k2, tc.n2JailedForN1) - env.Control.SetJailed(k2, k1, tc.n1JailedForN2) - - // Wait for the jailed status to propagate. - waitPeerIsJailed(t, b1, tc.n2JailedForN1) - waitPeerIsJailed(t, b2, tc.n1JailedForN2) - - testDial(t, lc1, ip2, port, tc.n1JailedForN2) - testDial(t, lc2, ip1, port, tc.n2JailedForN1) - }) - } -} - -// TestNATPing creates two nodes, n1 and n2, sets up masquerades for both and -// tries to do bi-directional pings between them. -func TestNATPing(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/12169") - tstest.Shard(t) - tstest.Parallel(t) - for _, v6 := range []bool{false, true} { - env := newTestEnv(t) - registerNode := func() (*testNode, key.NodePublic) { - n := newTestNode(t, env) - n.StartDaemon() - n.AwaitListening() - n.MustUp() - n.AwaitRunning() - k := n.MustStatus().Self.PublicKey - return n, k - } - n1, k1 := registerNode() - n2, k2 := registerNode() - - var n1IP, n2IP netip.Addr - if v6 { - n1IP = n1.AwaitIP6() - n2IP = n2.AwaitIP6() - } else { - n1IP = n1.AwaitIP4() - n2IP = n2.AwaitIP4() - } - - n1ExternalIP := netip.MustParseAddr("100.64.1.1") - n2ExternalIP := netip.MustParseAddr("100.64.2.1") - if v6 { - n1ExternalIP = netip.MustParseAddr("fd7a:115c:a1e0::1a") - n2ExternalIP = netip.MustParseAddr("fd7a:115c:a1e0::1b") - } - - tests := []struct { - name string - pairs []testcontrol.MasqueradePair - n1SeesN2IP netip.Addr - n2SeesN1IP netip.Addr - }{ - { - name: "no_nat", - n1SeesN2IP: n2IP, - n2SeesN1IP: n1IP, - }, - { - name: "n1_has_external_ip", - pairs: []testcontrol.MasqueradePair{ - { - Node: k1, - Peer: k2, - NodeMasqueradesAs: n1ExternalIP, - }, - }, - n1SeesN2IP: n2IP, - n2SeesN1IP: n1ExternalIP, - }, - { - name: "n2_has_external_ip", - pairs: []testcontrol.MasqueradePair{ - { - Node: k2, - Peer: k1, - NodeMasqueradesAs: n2ExternalIP, - }, - }, - n1SeesN2IP: n2ExternalIP, - n2SeesN1IP: n1IP, - }, - { - name: "both_have_external_ips", - pairs: []testcontrol.MasqueradePair{ - { - Node: k1, - Peer: k2, - NodeMasqueradesAs: n1ExternalIP, - }, - { - Node: k2, - Peer: k1, - NodeMasqueradesAs: n2ExternalIP, - }, - }, - n1SeesN2IP: n2ExternalIP, - n2SeesN1IP: n1ExternalIP, - }, - } - - for _, tc := range tests { - t.Run(fmt.Sprintf("v6=%t/%v", v6, tc.name), func(t *testing.T) { - env.Control.SetMasqueradeAddresses(tc.pairs) - - ipIdx := 0 - if v6 { - ipIdx = 1 - } - - s1 := n1.MustStatus() - n2AsN1Peer := s1.Peer[k2] - if got := n2AsN1Peer.TailscaleIPs[ipIdx]; got != tc.n1SeesN2IP { - t.Fatalf("n1 sees n2 as %v; want %v", got, tc.n1SeesN2IP) - } - - s2 := n2.MustStatus() - n1AsN2Peer := s2.Peer[k1] - if got := n1AsN2Peer.TailscaleIPs[ipIdx]; got != tc.n2SeesN1IP { - t.Fatalf("n2 sees n1 as %v; want %v", got, tc.n2SeesN1IP) - } - - if err := n1.Tailscale("ping", tc.n1SeesN2IP.String()).Run(); err != nil { - t.Fatal(err) - } - - if err := n1.Tailscale("ping", "-peerapi", tc.n1SeesN2IP.String()).Run(); err != nil { - t.Fatal(err) - } - - if err := n2.Tailscale("ping", tc.n2SeesN1IP.String()).Run(); err != nil { - t.Fatal(err) - } - - if err := n2.Tailscale("ping", "-peerapi", tc.n2SeesN1IP.String()).Run(); err != nil { - t.Fatal(err) - } - }) - } - } -} - -func TestLogoutRemovesAllPeers(t *testing.T) { - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - // Spin up some nodes. - nodes := make([]*testNode, 2) - for i := range nodes { - nodes[i] = newTestNode(t, env) - nodes[i].StartDaemon() - nodes[i].AwaitResponding() - nodes[i].MustUp() - nodes[i].AwaitIP4() - nodes[i].AwaitRunning() - } - expectedPeers := len(nodes) - 1 - - // Make every node ping every other node. - // This makes sure magicsock is fully populated. - for i := range nodes { - for j := range nodes { - if i <= j { - continue - } - if err := tstest.WaitFor(20*time.Second, func() error { - return nodes[i].Ping(nodes[j]) - }); err != nil { - t.Fatalf("ping %v -> %v: %v", nodes[i].AwaitIP4(), nodes[j].AwaitIP4(), err) - } - } - } - - // wantNode0PeerCount waits until node[0] status includes exactly want peers. - wantNode0PeerCount := func(want int) { - if err := tstest.WaitFor(20*time.Second, func() error { - s := nodes[0].MustStatus() - if peers := s.Peers(); len(peers) != want { - return fmt.Errorf("want %d peer(s) in status, got %v", want, peers) - } - return nil - }); err != nil { - t.Fatal(err) - } - } - - wantNode0PeerCount(expectedPeers) // all other nodes are peers - nodes[0].MustLogOut() - wantNode0PeerCount(0) // node[0] is logged out, so it should not have any peers - - nodes[0].MustUp() // This will create a new node - expectedPeers++ - - nodes[0].AwaitIP4() - wantNode0PeerCount(expectedPeers) // all existing peers and the new node -} - -func TestAutoUpdateDefaults(t *testing.T) { - if !clientupdate.CanAutoUpdate() { - t.Skip("auto-updates not supported on this platform") - } - tstest.Shard(t) - tstest.Parallel(t) - env := newTestEnv(t) - - checkDefault := func(n *testNode, want bool) error { - enabled, ok := n.diskPrefs().AutoUpdate.Apply.Get() - if !ok { - return fmt.Errorf("auto-update for node is unset, should be set as %v", want) - } - if enabled != want { - return fmt.Errorf("auto-update for node is %v, should be set as %v", enabled, want) - } - return nil - } - - sendAndCheckDefault := func(t *testing.T, n *testNode, send, want bool) { - t.Helper() - if !env.Control.AddRawMapResponse(n.MustStatus().Self.PublicKey, &tailcfg.MapResponse{ - DefaultAutoUpdate: opt.NewBool(send), - }) { - t.Fatal("failed to send MapResponse to node") - } - if err := tstest.WaitFor(2*time.Second, func() error { - return checkDefault(n, want) - }); err != nil { - t.Fatal(err) - } - } - - tests := []struct { - desc string - run func(t *testing.T, n *testNode) - }{ - { - desc: "tailnet-default-false", - run: func(t *testing.T, n *testNode) { - // First received default "false". - sendAndCheckDefault(t, n, false, false) - // Should not be changed even if sent "true" later. - sendAndCheckDefault(t, n, true, false) - // But can be changed explicitly by the user. - if out, err := n.TailscaleForOutput("set", "--auto-update").CombinedOutput(); err != nil { - t.Fatalf("failed to enable auto-update on node: %v\noutput: %s", err, out) - } - sendAndCheckDefault(t, n, false, true) - }, - }, - { - desc: "tailnet-default-true", - run: func(t *testing.T, n *testNode) { - // First received default "true". - sendAndCheckDefault(t, n, true, true) - // Should not be changed even if sent "false" later. - sendAndCheckDefault(t, n, false, true) - // But can be changed explicitly by the user. - if out, err := n.TailscaleForOutput("set", "--auto-update=false").CombinedOutput(); err != nil { - t.Fatalf("failed to disable auto-update on node: %v\noutput: %s", err, out) - } - sendAndCheckDefault(t, n, true, false) - }, - }, - { - desc: "user-sets-first", - run: func(t *testing.T, n *testNode) { - // User sets auto-update first, before receiving defaults. - if out, err := n.TailscaleForOutput("set", "--auto-update=false").CombinedOutput(); err != nil { - t.Fatalf("failed to disable auto-update on node: %v\noutput: %s", err, out) - } - // Defaults sent from control should be ignored. - sendAndCheckDefault(t, n, true, false) - sendAndCheckDefault(t, n, false, false) - }, - }, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - n := newTestNode(t, env) - d := n.StartDaemon() - defer d.MustCleanShutdown(t) - - n.AwaitResponding() - n.MustUp() - n.AwaitRunning() - - tt.run(t, n) - }) - } -} - -// TestDNSOverTCPIntervalResolver tests that the quad-100 resolver successfully -// serves TCP queries. It exercises the host's TCP stack, a TUN device, and -// gVisor/netstack. -// https://github.com/tailscale/corp/issues/22511 -func TestDNSOverTCPIntervalResolver(t *testing.T) { - tstest.Shard(t) - if os.Getuid() != 0 { - t.Skip("skipping when not root") - } - env := newTestEnv(t) - env.tunMode = true - n1 := newTestNode(t, env) - d1 := n1.StartDaemon() - - n1.AwaitResponding() - n1.MustUp() - - wantIP4 := n1.AwaitIP4() - n1.AwaitRunning() - - status, err := n1.Status() - if err != nil { - t.Fatalf("failed to get node status: %v", err) - } - selfDNSName, err := dnsname.ToFQDN(status.Self.DNSName) - if err != nil { - t.Fatalf("error converting self dns name to fqdn: %v", err) - } - - cases := []struct { - network string - serviceAddr netip.Addr - }{ - { - "tcp4", - tsaddr.TailscaleServiceIP(), - }, - { - "tcp6", - tsaddr.TailscaleServiceIPv6(), - }, - } - for _, c := range cases { - err = tstest.WaitFor(time.Second*5, func() error { - m := new(dns.Msg) - m.SetQuestion(selfDNSName.WithTrailingDot(), dns.TypeA) - conn, err := net.DialTimeout(c.network, net.JoinHostPort(c.serviceAddr.String(), "53"), time.Second*1) - if err != nil { - return err - } - defer conn.Close() - dnsConn := &dns.Conn{ - Conn: conn, - } - dnsClient := &dns.Client{} - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - resp, _, err := dnsClient.ExchangeWithConnContext(ctx, m, dnsConn) - if err != nil { - return err - } - if len(resp.Answer) != 1 { - return fmt.Errorf("unexpected DNS resp: %s", resp) - } - var gotAddr net.IP - answer, ok := resp.Answer[0].(*dns.A) - if !ok { - return fmt.Errorf("unexpected answer type: %s", resp.Answer[0]) - } - gotAddr = answer.A - if !bytes.Equal(gotAddr, wantIP4.AsSlice()) { - return fmt.Errorf("got (%s) != want (%s)", gotAddr, wantIP4) - } - return nil - }) - if err != nil { - t.Fatal(err) - } - } - - d1.MustCleanShutdown(t) -} - -// TestNetstackTCPLoopback tests netstack loopback of a TCP stream, in both -// directions. -func TestNetstackTCPLoopback(t *testing.T) { - tstest.Shard(t) - if os.Getuid() != 0 { - t.Skip("skipping when not root") - } - - env := newTestEnv(t) - env.tunMode = true - loopbackPort := 5201 - env.loopbackPort = &loopbackPort - loopbackPortStr := strconv.Itoa(loopbackPort) - n1 := newTestNode(t, env) - d1 := n1.StartDaemon() - - n1.AwaitResponding() - n1.MustUp() - - n1.AwaitIP4() - n1.AwaitRunning() - - cases := []struct { - lisAddr string - network string - dialAddr string - }{ - { - lisAddr: net.JoinHostPort("127.0.0.1", loopbackPortStr), - network: "tcp4", - dialAddr: net.JoinHostPort(tsaddr.TailscaleServiceIPString, loopbackPortStr), - }, - { - lisAddr: net.JoinHostPort("::1", loopbackPortStr), - network: "tcp6", - dialAddr: net.JoinHostPort(tsaddr.TailscaleServiceIPv6String, loopbackPortStr), - }, - } - - writeBufSize := 128 << 10 // 128KiB, exercise GSO if enabled - writeBufIterations := 100 // allow TCP send window to open up - wantTotal := writeBufSize * writeBufIterations - - for _, c := range cases { - lis, err := net.Listen(c.network, c.lisAddr) - if err != nil { - t.Fatal(err) - } - defer lis.Close() - - writeFn := func(conn net.Conn) error { - for i := 0; i < writeBufIterations; i++ { - toWrite := make([]byte, writeBufSize) - var wrote int - for { - n, err := conn.Write(toWrite) - if err != nil { - return err - } - wrote += n - if wrote == len(toWrite) { - break - } - } - } - return nil - } - - readFn := func(conn net.Conn) error { - var read int - for { - b := make([]byte, writeBufSize) - n, err := conn.Read(b) - if err != nil { - return err - } - read += n - if read == wantTotal { - return nil - } - } - } - - lisStepCh := make(chan error) - go func() { - conn, err := lis.Accept() - if err != nil { - lisStepCh <- err - return - } - lisStepCh <- readFn(conn) - lisStepCh <- writeFn(conn) - }() - - var conn net.Conn - err = tstest.WaitFor(time.Second*5, func() error { - conn, err = net.DialTimeout(c.network, c.dialAddr, time.Second*1) - if err != nil { - return err - } - return nil - }) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - - dialerStepCh := make(chan error) - go func() { - dialerStepCh <- writeFn(conn) - dialerStepCh <- readFn(conn) - }() - - var ( - dialerSteps int - lisSteps int - ) - for { - select { - case lisErr := <-lisStepCh: - if lisErr != nil { - t.Fatal(err) - } - lisSteps++ - if dialerSteps == 2 && lisSteps == 2 { - return - } - case dialerErr := <-dialerStepCh: - if dialerErr != nil { - t.Fatal(err) - } - dialerSteps++ - if dialerSteps == 2 && lisSteps == 2 { - return - } - } - } - } - - d1.MustCleanShutdown(t) -} - -// TestNetstackUDPLoopback tests netstack loopback of UDP packets, in both -// directions. -func TestNetstackUDPLoopback(t *testing.T) { - tstest.Shard(t) - if os.Getuid() != 0 { - t.Skip("skipping when not root") - } - - env := newTestEnv(t) - env.tunMode = true - loopbackPort := 5201 - env.loopbackPort = &loopbackPort - n1 := newTestNode(t, env) - d1 := n1.StartDaemon() - - n1.AwaitResponding() - n1.MustUp() - - ip4 := n1.AwaitIP4() - ip6 := n1.AwaitIP6() - n1.AwaitRunning() - - cases := []struct { - pingerLAddr *net.UDPAddr - pongerLAddr *net.UDPAddr - network string - dialAddr *net.UDPAddr - }{ - { - pingerLAddr: &net.UDPAddr{IP: ip4.AsSlice(), Port: loopbackPort + 1}, - pongerLAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: loopbackPort}, - network: "udp4", - dialAddr: &net.UDPAddr{IP: tsaddr.TailscaleServiceIP().AsSlice(), Port: loopbackPort}, - }, - { - pingerLAddr: &net.UDPAddr{IP: ip6.AsSlice(), Port: loopbackPort + 1}, - pongerLAddr: &net.UDPAddr{IP: net.ParseIP("::1"), Port: loopbackPort}, - network: "udp6", - dialAddr: &net.UDPAddr{IP: tsaddr.TailscaleServiceIPv6().AsSlice(), Port: loopbackPort}, - }, - } - - writeBufSize := int(tstun.DefaultTUNMTU()) - 40 - 8 // mtu - ipv6 header - udp header - wantPongs := 100 - - for _, c := range cases { - pongerConn, err := net.ListenUDP(c.network, c.pongerLAddr) - if err != nil { - t.Fatal(err) - } - defer pongerConn.Close() - - var pingerConn *net.UDPConn - err = tstest.WaitFor(time.Second*5, func() error { - pingerConn, err = net.DialUDP(c.network, c.pingerLAddr, c.dialAddr) - return err - }) - if err != nil { - t.Fatal(err) - } - defer pingerConn.Close() - - pingerFn := func(conn *net.UDPConn) error { - b := make([]byte, writeBufSize) - n, err := conn.Write(b) - if err != nil { - return err - } - if n != len(b) { - return fmt.Errorf("bad write size: %d", n) - } - err = conn.SetReadDeadline(time.Now().Add(time.Millisecond * 500)) - if err != nil { - return err - } - n, err = conn.Read(b) - if err != nil { - return err - } - if n != len(b) { - return fmt.Errorf("bad read size: %d", n) - } - return nil - } - - pongerFn := func(conn *net.UDPConn) error { - for { - b := make([]byte, writeBufSize) - n, from, err := conn.ReadFromUDP(b) - if err != nil { - return err - } - if n != len(b) { - return fmt.Errorf("bad read size: %d", n) - } - n, err = conn.WriteToUDP(b, from) - if err != nil { - return err - } - if n != len(b) { - return fmt.Errorf("bad write size: %d", n) - } - } - } - - pongerErrCh := make(chan error, 1) - go func() { - pongerErrCh <- pongerFn(pongerConn) - }() - - err = tstest.WaitFor(time.Second*5, func() error { - err = pingerFn(pingerConn) - if err != nil { - return err - } - return nil - }) - if err != nil { - t.Fatal(err) - } - - var pongsRX int - for { - pingerErrCh := make(chan error) - go func() { - pingerErrCh <- pingerFn(pingerConn) - }() - - select { - case err := <-pongerErrCh: - t.Fatal(err) - case err := <-pingerErrCh: - if err != nil { - t.Fatal(err) - } - } - - pongsRX++ - if pongsRX == wantPongs { - break - } - } - } - - d1.MustCleanShutdown(t) -} - -// testEnv contains the test environment (set of servers) used by one -// or more nodes. -type testEnv struct { - t testing.TB - tunMode bool - cli string - daemon string - loopbackPort *int - - LogCatcher *LogCatcher - LogCatcherServer *httptest.Server - - Control *testcontrol.Server - ControlServer *httptest.Server - - TrafficTrap *trafficTrap - TrafficTrapServer *httptest.Server -} - -// controlURL returns e.ControlServer.URL, panicking if it's the empty string, -// which it should never be in tests. -func (e *testEnv) controlURL() string { - s := e.ControlServer.URL - if s == "" { - panic("control server not set") - } - return s -} - -type testEnvOpt interface { - modifyTestEnv(*testEnv) -} - -type configureControl func(*testcontrol.Server) - -func (f configureControl) modifyTestEnv(te *testEnv) { - f(te.Control) -} - -// newTestEnv starts a bunch of services and returns a new test environment. -// newTestEnv arranges for the environment's resources to be cleaned up on exit. -func newTestEnv(t testing.TB, opts ...testEnvOpt) *testEnv { - if runtime.GOOS == "windows" { - t.Skip("not tested/working on Windows yet") - } - derpMap := RunDERPAndSTUN(t, logger.Discard, "127.0.0.1") - logc := new(LogCatcher) - control := &testcontrol.Server{ - DERPMap: derpMap, - } - control.HTTPTestServer = httptest.NewUnstartedServer(control) - trafficTrap := new(trafficTrap) - e := &testEnv{ - t: t, - cli: TailscaleBinary(t), - daemon: TailscaledBinary(t), - LogCatcher: logc, - LogCatcherServer: httptest.NewServer(logc), - Control: control, - ControlServer: control.HTTPTestServer, - TrafficTrap: trafficTrap, - TrafficTrapServer: httptest.NewServer(trafficTrap), - } - for _, o := range opts { - o.modifyTestEnv(e) - } - control.HTTPTestServer.Start() - t.Cleanup(func() { - // Shut down e. - if err := e.TrafficTrap.Err(); err != nil { - e.t.Errorf("traffic trap: %v", err) - e.t.Logf("logs: %s", e.LogCatcher.logsString()) - } - e.LogCatcherServer.Close() - e.TrafficTrapServer.Close() - e.ControlServer.Close() - }) - t.Logf("control URL: %v", e.controlURL()) - return e -} - -// testNode is a machine with a tailscale & tailscaled. -// Currently, the test is simplistic and user==node==machine. -// That may grow complexity later to test more. -type testNode struct { - env *testEnv - tailscaledParser *nodeOutputParser - - dir string // temp dir for sock & state - configFile string // or empty for none - sockFile string - stateFile string - upFlagGOOS string // if non-empty, sets TS_DEBUG_UP_FLAG_GOOS for cmd/tailscale CLI - - mu sync.Mutex - onLogLine []func([]byte) -} - -// newTestNode allocates a temp directory for a new test node. -// The node is not started automatically. -func newTestNode(t *testing.T, env *testEnv) *testNode { - dir := t.TempDir() - sockFile := filepath.Join(dir, "tailscale.sock") - if len(sockFile) >= 104 { - // Maximum length for a unix socket on darwin. Try something else. - sockFile = filepath.Join(os.TempDir(), rands.HexString(8)+".sock") - t.Cleanup(func() { os.Remove(sockFile) }) - } - n := &testNode{ - env: env, - dir: dir, - sockFile: sockFile, - stateFile: filepath.Join(dir, "tailscale.state"), - } - - // Look for a data race. Once we see the start marker, start logging the rest. - var sawRace bool - var sawPanic bool - n.addLogLineHook(func(line []byte) { - lineB := mem.B(line) - if mem.Contains(lineB, mem.S("WARNING: DATA RACE")) { - sawRace = true - } - if mem.HasPrefix(lineB, mem.S("panic: ")) { - sawPanic = true - } - if sawRace || sawPanic { - t.Logf("%s", line) - } - }) - - return n -} - -func (n *testNode) diskPrefs() *ipn.Prefs { - t := n.env.t - t.Helper() - if _, err := os.ReadFile(n.stateFile); err != nil { - t.Fatalf("reading prefs: %v", err) - } - fs, err := store.NewFileStore(nil, n.stateFile) - if err != nil { - t.Fatalf("reading prefs, NewFileStore: %v", err) - } - p, err := ipnlocal.ReadStartupPrefsForTest(t.Logf, fs) - if err != nil { - t.Fatalf("reading prefs, ReadDiskPrefsForTest: %v", err) - } - return p.AsStruct() -} - -// AwaitResponding waits for n's tailscaled to be up enough to be -// responding, but doesn't wait for any particular state. -func (n *testNode) AwaitResponding() { - t := n.env.t - t.Helper() - n.AwaitListening() - - st := n.MustStatus() - t.Logf("Status: %s", st.BackendState) - - if err := tstest.WaitFor(20*time.Second, func() error { - const sub = `Program starting: ` - if !n.env.LogCatcher.logsContains(mem.S(sub)) { - return fmt.Errorf("log catcher didn't see %#q; got %s", sub, n.env.LogCatcher.logsString()) - } - return nil - }); err != nil { - t.Fatal(err) - } -} - -// addLogLineHook registers a hook f to be called on each tailscaled -// log line output. -func (n *testNode) addLogLineHook(f func([]byte)) { - n.mu.Lock() - defer n.mu.Unlock() - n.onLogLine = append(n.onLogLine, f) -} - -// socks5AddrChan returns a channel that receives the address (e.g. "localhost:23874") -// of the node's SOCKS5 listener, once started. -func (n *testNode) socks5AddrChan() <-chan string { - ch := make(chan string, 1) - n.addLogLineHook(func(line []byte) { - const sub = "SOCKS5 listening on " - i := mem.Index(mem.B(line), mem.S(sub)) - if i == -1 { - return - } - addr := strings.TrimSpace(string(line)[i+len(sub):]) - select { - case ch <- addr: - default: - } - }) - return ch -} - -func (n *testNode) AwaitSocksAddr(ch <-chan string) string { - t := n.env.t - t.Helper() - timer := time.NewTimer(10 * time.Second) - defer timer.Stop() - select { - case v := <-ch: - return v - case <-timer.C: - t.Fatal("timeout waiting for node to log its SOCK5 listening address") - panic("unreachable") - } -} - -// nodeOutputParser parses stderr of tailscaled processes, calling the -// per-line callbacks previously registered via -// testNode.addLogLineHook. -type nodeOutputParser struct { - allBuf bytes.Buffer - pendLineBuf bytes.Buffer - n *testNode -} - -func (op *nodeOutputParser) Write(p []byte) (n int, err error) { - tn := op.n - tn.mu.Lock() - defer tn.mu.Unlock() - - op.allBuf.Write(p) - n, err = op.pendLineBuf.Write(p) - op.parseLinesLocked() - return -} - -func (op *nodeOutputParser) parseLinesLocked() { - n := op.n - buf := op.pendLineBuf.Bytes() - for len(buf) > 0 { - nl := bytes.IndexByte(buf, '\n') - if nl == -1 { - break - } - line := buf[:nl+1] - buf = buf[nl+1:] - - for _, f := range n.onLogLine { - f(line) - } - } - if len(buf) == 0 { - op.pendLineBuf.Reset() - } else { - io.CopyN(io.Discard, &op.pendLineBuf, int64(op.pendLineBuf.Len()-len(buf))) - } -} - -type Daemon struct { - Process *os.Process -} - -func (d *Daemon) MustCleanShutdown(t testing.TB) { - d.Process.Signal(os.Interrupt) - ps, err := d.Process.Wait() - if err != nil { - t.Fatalf("tailscaled Wait: %v", err) - } - if ps.ExitCode() != 0 { - t.Errorf("tailscaled ExitCode = %d; want 0", ps.ExitCode()) - } -} - -// StartDaemon starts the node's tailscaled, failing if it fails to start. -// StartDaemon ensures that the process will exit when the test completes. -func (n *testNode) StartDaemon() *Daemon { - return n.StartDaemonAsIPNGOOS(runtime.GOOS) -} - -func (n *testNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon { - t := n.env.t - cmd := exec.Command(n.env.daemon) - cmd.Args = append(cmd.Args, - "--state="+n.stateFile, - "--socket="+n.sockFile, - "--socks5-server=localhost:0", - ) - if *verboseTailscaled { - cmd.Args = append(cmd.Args, "-verbose=2") - } - if !n.env.tunMode { - cmd.Args = append(cmd.Args, - "--tun=userspace-networking", - ) - } - if n.configFile != "" { - cmd.Args = append(cmd.Args, "--config="+n.configFile) - } - cmd.Env = append(os.Environ(), - "TS_CONTROL_IS_PLAINTEXT_HTTP=1", - "TS_DEBUG_PERMIT_HTTP_C2N=1", - "TS_LOG_TARGET="+n.env.LogCatcherServer.URL, - "HTTP_PROXY="+n.env.TrafficTrapServer.URL, - "HTTPS_PROXY="+n.env.TrafficTrapServer.URL, - "TS_DEBUG_FAKE_GOOS="+ipnGOOS, - "TS_LOGS_DIR="+t.TempDir(), - "TS_NETCHECK_GENERATE_204_URL="+n.env.ControlServer.URL+"/generate_204", - "TS_ASSUME_NETWORK_UP_FOR_TEST=1", // don't pause control client in airplane mode (no wifi, etc) - "TS_PANIC_IF_HIT_MAIN_CONTROL=1", - "TS_DISABLE_PORTMAPPER=1", // shouldn't be needed; test is all localhost - "TS_DEBUG_LOG_RATE=all", - ) - if n.env.loopbackPort != nil { - cmd.Env = append(cmd.Env, "TS_DEBUG_NETSTACK_LOOPBACK_PORT="+strconv.Itoa(*n.env.loopbackPort)) - } - if version.IsRace() { - cmd.Env = append(cmd.Env, "GORACE=halt_on_error=1") - } - n.tailscaledParser = &nodeOutputParser{n: n} - cmd.Stderr = n.tailscaledParser - if *verboseTailscaled { - cmd.Stdout = os.Stdout - cmd.Stderr = io.MultiWriter(cmd.Stderr, os.Stderr) - } - if runtime.GOOS != "windows" { - pr, pw, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { pw.Close() }) - cmd.ExtraFiles = append(cmd.ExtraFiles, pr) - cmd.Env = append(cmd.Env, "TS_PARENT_DEATH_FD=3") - } - if err := cmd.Start(); err != nil { - t.Fatalf("starting tailscaled: %v", err) - } - t.Cleanup(func() { cmd.Process.Kill() }) - return &Daemon{ - Process: cmd.Process, - } -} - -func (n *testNode) MustUp(extraArgs ...string) { - t := n.env.t - t.Helper() - args := []string{ - "up", - "--login-server=" + n.env.controlURL(), - "--reset", - } - args = append(args, extraArgs...) - cmd := n.Tailscale(args...) - t.Logf("Running %v ...", cmd) - cmd.Stdout = nil // in case --verbose-tailscale was set - cmd.Stderr = nil // in case --verbose-tailscale was set - if b, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("up: %v, %v", string(b), err) - } -} - -func (n *testNode) MustDown() { - t := n.env.t - t.Logf("Running down ...") - if err := n.Tailscale("down", "--accept-risk=all").Run(); err != nil { - t.Fatalf("down: %v", err) - } -} - -func (n *testNode) MustLogOut() { - t := n.env.t - t.Logf("Running logout ...") - if err := n.Tailscale("logout").Run(); err != nil { - t.Fatalf("logout: %v", err) - } -} - -func (n *testNode) Ping(otherNode *testNode) error { - t := n.env.t - ip := otherNode.AwaitIP4().String() - t.Logf("Running ping %v (from %v)...", ip, n.AwaitIP4()) - return n.Tailscale("ping", ip).Run() -} - -// AwaitListening waits for the tailscaled to be serving local clients -// over its localhost IPC mechanism. (Unix socket, etc) -func (n *testNode) AwaitListening() { - t := n.env.t - if err := tstest.WaitFor(20*time.Second, func() (err error) { - c, err := safesocket.ConnectContext(context.Background(), n.sockFile) - if err == nil { - c.Close() - } - return err - }); err != nil { - t.Fatal(err) - } -} - -func (n *testNode) AwaitIPs() []netip.Addr { - t := n.env.t - t.Helper() - var addrs []netip.Addr - if err := tstest.WaitFor(20*time.Second, func() error { - cmd := n.Tailscale("ip") - cmd.Stdout = nil // in case --verbose-tailscale was set - cmd.Stderr = nil // in case --verbose-tailscale was set - out, err := cmd.Output() - if err != nil { - return err - } - ips := string(out) - ipslice := strings.Fields(ips) - addrs = make([]netip.Addr, len(ipslice)) - - for i, ip := range ipslice { - netIP, err := netip.ParseAddr(ip) - if err != nil { - t.Fatal(err) - } - addrs[i] = netIP - } - return nil - }); err != nil { - t.Fatalf("awaiting an IP address: %v", err) - } - if len(addrs) == 0 { - t.Fatalf("returned IP address was blank") - } - return addrs -} - -// AwaitIP4 returns the IPv4 address of n. -func (n *testNode) AwaitIP4() netip.Addr { - t := n.env.t - t.Helper() - ips := n.AwaitIPs() - return ips[0] -} - -// AwaitIP6 returns the IPv6 address of n. -func (n *testNode) AwaitIP6() netip.Addr { - t := n.env.t - t.Helper() - ips := n.AwaitIPs() - return ips[1] -} - -// AwaitRunning waits for n to reach the IPN state "Running". -func (n *testNode) AwaitRunning() { - n.AwaitBackendState("Running") -} - -func (n *testNode) AwaitBackendState(state string) { - t := n.env.t - t.Helper() - if err := tstest.WaitFor(20*time.Second, func() error { - st, err := n.Status() - if err != nil { - return err - } - if st.BackendState != state { - return fmt.Errorf("in state %q; want %q", st.BackendState, state) - } - return nil - }); err != nil { - t.Fatalf("failure/timeout waiting for transition to Running status: %v", err) - } -} - -// AwaitNeedsLogin waits for n to reach the IPN state "NeedsLogin". -func (n *testNode) AwaitNeedsLogin() { - t := n.env.t - t.Helper() - if err := tstest.WaitFor(20*time.Second, func() error { - st, err := n.Status() - if err != nil { - return err - } - if st.BackendState != "NeedsLogin" { - return fmt.Errorf("in state %q", st.BackendState) - } - return nil - }); err != nil { - t.Fatalf("failure/timeout waiting for transition to NeedsLogin status: %v", err) - } -} - -func (n *testNode) TailscaleForOutput(arg ...string) *exec.Cmd { - cmd := n.Tailscale(arg...) - cmd.Stdout = nil - cmd.Stderr = nil - return cmd -} - -// Tailscale returns a command that runs the tailscale CLI with the provided arguments. -// It does not start the process. -func (n *testNode) Tailscale(arg ...string) *exec.Cmd { - cmd := exec.Command(n.env.cli) - cmd.Args = append(cmd.Args, "--socket="+n.sockFile) - cmd.Args = append(cmd.Args, arg...) - cmd.Dir = n.dir - cmd.Env = append(os.Environ(), - "TS_DEBUG_UP_FLAG_GOOS="+n.upFlagGOOS, - "TS_LOGS_DIR="+n.env.t.TempDir(), - ) - if *verboseTailscale { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } - return cmd -} - -func (n *testNode) Status() (*ipnstate.Status, error) { - cmd := n.Tailscale("status", "--json") - cmd.Stdout = nil // in case --verbose-tailscale was set - cmd.Stderr = nil // in case --verbose-tailscale was set - out, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("running tailscale status: %v, %s", err, out) - } - st := new(ipnstate.Status) - if err := json.Unmarshal(out, st); err != nil { - return nil, fmt.Errorf("decoding tailscale status JSON: %w", err) - } - return st, nil -} - -func (n *testNode) MustStatus() *ipnstate.Status { - tb := n.env.t - tb.Helper() - st, err := n.Status() - if err != nil { - tb.Fatal(err) - } - return st -} - -// trafficTrap is an HTTP proxy handler to note whether any -// HTTP traffic tries to leave localhost from tailscaled. We don't -// expect any, so any request triggers a failure. -type trafficTrap struct { - atomicErr syncs.AtomicValue[error] -} - -func (tt *trafficTrap) Err() error { - return tt.atomicErr.Load() -} - -func (tt *trafficTrap) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var got bytes.Buffer - r.Write(&got) - err := fmt.Errorf("unexpected HTTP request via proxy: %s", got.Bytes()) - mainError.Store(err) - if tt.Err() == nil { - // Best effort at remembering the first request. - tt.atomicErr.Store(err) - } - log.Printf("Error: %v", err) - w.WriteHeader(403) -} - -type authURLParserWriter struct { - buf bytes.Buffer - fn func(urlStr string) error -} - -var authURLRx = regexp.MustCompile(`(https?://\S+/auth/\S+)`) - -func (w *authURLParserWriter) Write(p []byte) (n int, err error) { - n, err = w.buf.Write(p) - m := authURLRx.FindSubmatch(w.buf.Bytes()) - if m != nil { - urlStr := string(m[1]) - w.buf.Reset() // so it's not matched again - if err := w.fn(urlStr); err != nil { - return 0, err - } - } - return n, err -} diff --git a/tstest/integration/nat/nat_test.go b/tstest/integration/nat/nat_test.go deleted file mode 100644 index 5355155882cd7..0000000000000 --- a/tstest/integration/nat/nat_test.go +++ /dev/null @@ -1,677 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package nat - -import ( - "bytes" - "cmp" - "context" - "errors" - "flag" - "fmt" - "io" - "net" - "net/http" - "net/netip" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "golang.org/x/mod/modfile" - "golang.org/x/sync/errgroup" - "tailscale.com/client/tailscale" - "tailscale.com/ipn/ipnstate" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstest/natlab/vnet" -) - -var ( - logTailscaled = flag.Bool("log-tailscaled", false, "log tailscaled output") - pcapFile = flag.String("pcap", "", "write pcap to file") -) - -type natTest struct { - tb testing.TB - base string // base image - tempDir string // for qcow2 images - vnet *vnet.Server - kernel string // linux kernel path - - gotRoute pingRoute -} - -func newNatTest(tb testing.TB) *natTest { - root, err := os.Getwd() - if err != nil { - tb.Fatal(err) - } - modRoot := filepath.Join(root, "../../..") - - nt := &natTest{ - tb: tb, - tempDir: tb.TempDir(), - base: filepath.Join(modRoot, "gokrazy/natlabapp.qcow2"), - } - - if _, err := os.Stat(nt.base); err != nil { - tb.Skipf("skipping test; base image %q not found", nt.base) - } - - nt.kernel, err = findKernelPath(filepath.Join(modRoot, "gokrazy/natlabapp/builddir/github.com/tailscale/gokrazy-kernel/go.mod")) - if err != nil { - tb.Skipf("skipping test; kernel not found: %v", err) - } - tb.Logf("found kernel: %v", nt.kernel) - - return nt -} - -func findKernelPath(goMod string) (string, error) { - b, err := os.ReadFile(goMod) - if err != nil { - return "", err - } - mf, err := modfile.Parse("go.mod", b, nil) - if err != nil { - return "", err - } - goModB, err := exec.Command("go", "env", "GOMODCACHE").CombinedOutput() - if err != nil { - return "", err - } - for _, r := range mf.Require { - if r.Mod.Path == "github.com/tailscale/gokrazy-kernel" { - return strings.TrimSpace(string(goModB)) + "/" + r.Mod.String() + "/vmlinuz", nil - } - } - return "", fmt.Errorf("failed to find kernel in %v", goMod) -} - -type addNodeFunc func(c *vnet.Config) *vnet.Node // returns nil to omit test - -func v6cidr(n int) string { - return fmt.Sprintf("2000:%d::1/64", n) -} - -func easy(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT)) -} - -func easyAnd6(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), - v6cidr(n), - vnet.EasyNAT)) -} - -func v6AndBlackholedIPv4(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - nw := c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), - v6cidr(n), - vnet.EasyNAT) - nw.SetBlackholedIPv4(true) - return c.AddNode(nw) -} - -func just6(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork(v6cidr(n))) // public IPv6 prefix -} - -// easy + host firewall -func easyFW(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(vnet.HostFirewall, c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT)) -} - -func easyAF(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyAFNAT)) -} - -func sameLAN(c *vnet.Config) *vnet.Node { - nw := c.FirstNetwork() - if nw == nil { - return nil - } - if !nw.CanTakeMoreNodes() { - return nil - } - return c.AddNode(nw) -} - -func one2one(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("172.16.%d.1/24", n), vnet.One2OneNAT)) -} - -func easyPMP(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT, vnet.NATPMP)) -} - -// easy + port mapping + host firewall + BPF -func easyPMPFWPlusBPF(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode( - vnet.HostFirewall, - vnet.TailscaledEnv{ - Key: "TS_ENABLE_RAW_DISCO", - Value: "true", - }, - vnet.TailscaledEnv{ - Key: "TS_DEBUG_RAW_DISCO", - Value: "1", - }, - vnet.TailscaledEnv{ - Key: "TS_DEBUG_DISCO", - Value: "1", - }, - vnet.TailscaledEnv{ - Key: "TS_LOG_VERBOSITY", - Value: "2", - }, - c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT, vnet.NATPMP)) -} - -// easy + port mapping + host firewall - BPF -func easyPMPFWNoBPF(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode( - vnet.HostFirewall, - vnet.TailscaledEnv{ - Key: "TS_ENABLE_RAW_DISCO", - Value: "false", - }, - c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("192.168.%d.1/24", n), vnet.EasyNAT, vnet.NATPMP)) -} - -func hard(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("10.0.%d.1/24", n), vnet.HardNAT)) -} - -func hardPMP(c *vnet.Config) *vnet.Node { - n := c.NumNodes() + 1 - return c.AddNode(c.AddNetwork( - fmt.Sprintf("2.%d.%d.%d", n, n, n), // public IP - fmt.Sprintf("10.7.%d.1/24", n), vnet.HardNAT, vnet.NATPMP)) -} - -func (nt *natTest) runTest(addNode ...addNodeFunc) pingRoute { - if len(addNode) < 1 || len(addNode) > 2 { - nt.tb.Fatalf("runTest: invalid number of nodes %v; want 1 or 2", len(addNode)) - } - t := nt.tb - - var c vnet.Config - c.SetPCAPFile(*pcapFile) - nodes := []*vnet.Node{} - for _, fn := range addNode { - node := fn(&c) - if node == nil { - t.Skip("skipping test; not applicable combination") - } - nodes = append(nodes, node) - if *logTailscaled { - node.SetVerboseSyslog(true) - } - } - - var err error - nt.vnet, err = vnet.New(&c) - if err != nil { - t.Fatalf("newServer: %v", err) - } - nt.tb.Cleanup(func() { - nt.vnet.Close() - }) - - var wg sync.WaitGroup // waiting for srv.Accept goroutine - defer wg.Wait() - - sockAddr := filepath.Join(nt.tempDir, "qemu.sock") - srv, err := net.Listen("unix", sockAddr) - if err != nil { - t.Fatalf("Listen: %v", err) - } - defer srv.Close() - - wg.Add(1) - go func() { - defer wg.Done() - for { - c, err := srv.Accept() - if err != nil { - return - } - go nt.vnet.ServeUnixConn(c.(*net.UnixConn), vnet.ProtocolQEMU) - } - }() - - for i, node := range nodes { - disk := fmt.Sprintf("%s/node-%d.qcow2", nt.tempDir, i) - out, err := exec.Command("qemu-img", "create", - "-f", "qcow2", - "-F", "qcow2", - "-b", nt.base, - disk).CombinedOutput() - if err != nil { - t.Fatalf("qemu-img create: %v, %s", err, out) - } - - var envBuf bytes.Buffer - for _, e := range node.Env() { - fmt.Fprintf(&envBuf, " tailscaled.env=%s=%s", e.Key, e.Value) - } - sysLogAddr := net.JoinHostPort(vnet.FakeSyslogIPv4().String(), "995") - if node.IsV6Only() { - fmt.Fprintf(&envBuf, " tta.nameserver=%s", vnet.FakeDNSIPv6()) - sysLogAddr = net.JoinHostPort(vnet.FakeSyslogIPv6().String(), "995") - } - envStr := envBuf.String() - - cmd := exec.Command("qemu-system-x86_64", - "-M", "microvm,isa-serial=off", - "-m", "384M", - "-nodefaults", "-no-user-config", "-nographic", - "-kernel", nt.kernel, - "-append", "console=hvc0 root=PARTUUID=60c24cc1-f3f9-427a-8199-76baa2d60001/PARTNROFF=1 ro init=/gokrazy/init panic=10 oops=panic pci=off nousb tsc=unstable clocksource=hpet gokrazy.remote_syslog.target="+sysLogAddr+" tailscale-tta=1"+envStr, - "-drive", "id=blk0,file="+disk+",format=qcow2", - "-device", "virtio-blk-device,drive=blk0", - "-netdev", "stream,id=net0,addr.type=unix,addr.path="+sockAddr, - "-device", "virtio-serial-device", - "-device", "virtio-rng-device", - "-device", "virtio-net-device,netdev=net0,mac="+node.MAC().String(), - "-chardev", "stdio,id=virtiocon0,mux=on", - "-device", "virtconsole,chardev=virtiocon0", - "-mon", "chardev=virtiocon0,mode=readline", - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - t.Fatalf("qemu: %v", err) - } - nt.tb.Cleanup(func() { - cmd.Process.Kill() - cmd.Wait() - }) - } - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - var clients []*vnet.NodeAgentClient - for _, n := range nodes { - clients = append(clients, nt.vnet.NodeAgentClient(n)) - } - sts := make([]*ipnstate.Status, len(nodes)) - - var eg errgroup.Group - for i, c := range clients { - i, c := i, c - eg.Go(func() error { - node := nodes[i] - t.Logf("%v calling Status...", node) - st, err := c.Status(ctx) - if err != nil { - return fmt.Errorf("%v status: %w", node, err) - } - t.Logf("%v status: %v", node, st.BackendState) - - if node.HostFirewall() { - if err := c.EnableHostFirewall(ctx); err != nil { - return fmt.Errorf("%v firewall: %w", node, err) - } - t.Logf("%v firewalled", node) - } - - if err := up(ctx, c); err != nil { - return fmt.Errorf("%v up: %w", node, err) - } - t.Logf("%v up!", node) - - st, err = c.Status(ctx) - if err != nil { - return fmt.Errorf("%v status: %w", node, err) - } - sts[i] = st - - if st.BackendState != "Running" { - return fmt.Errorf("%v state = %q", node, st.BackendState) - } - t.Logf("%v up with %v", node, sts[i].Self.TailscaleIPs) - return nil - }) - } - if err := eg.Wait(); err != nil { - t.Fatalf("initial setup: %v", err) - } - - defer nt.vnet.Close() - - if len(nodes) < 2 { - return "" - } - - pingRes, err := ping(ctx, clients[0], sts[1].Self.TailscaleIPs[0]) - if err != nil { - t.Fatalf("ping failure: %v", err) - } - nt.gotRoute = classifyPing(pingRes) - t.Logf("ping route: %v", nt.gotRoute) - - return nt.gotRoute -} - -func classifyPing(pr *ipnstate.PingResult) pingRoute { - if pr == nil { - return routeNil - } - if pr.Endpoint != "" { - ap, err := netip.ParseAddrPort(pr.Endpoint) - if err == nil { - if ap.Addr().IsPrivate() { - return routeLocal - } - return routeDirect - } - } - return routeDERP // presumably -} - -type pingRoute string - -const ( - routeDERP pingRoute = "derp" - routeLocal pingRoute = "local" - routeDirect pingRoute = "direct" - routeNil pingRoute = "nil" // *ipnstate.PingResult is nil -) - -func ping(ctx context.Context, c *vnet.NodeAgentClient, target netip.Addr) (*ipnstate.PingResult, error) { - n := 0 - var res *ipnstate.PingResult - anyPong := false - for n < 10 { - n++ - pr, err := c.PingWithOpts(ctx, target, tailcfg.PingDisco, tailscale.PingOpts{}) - if err != nil { - if anyPong { - return res, nil - } - return nil, err - } - if pr.Err != "" { - return nil, errors.New(pr.Err) - } - if pr.DERPRegionID == 0 { - return pr, nil - } - res = pr - select { - case <-ctx.Done(): - case <-time.After(time.Second): - } - } - if res == nil { - return nil, errors.New("no ping response") - } - return res, nil -} - -func up(ctx context.Context, c *vnet.NodeAgentClient) error { - req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/up", nil) - if err != nil { - return err - } - res, err := c.HTTPClient.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - all, _ := io.ReadAll(res.Body) - if res.StatusCode != 200 { - return fmt.Errorf("unexpected status code %v: %s", res.Status, all) - } - return nil -} - -type nodeType struct { - name string - fn addNodeFunc -} - -var types = []nodeType{ - {"easy", easy}, - {"easyAF", easyAF}, - {"hard", hard}, - {"easyPMP", easyPMP}, - {"hardPMP", hardPMP}, - {"one2one", one2one}, - {"sameLAN", sameLAN}, -} - -// want sets the expected ping route for the test. -func (nt *natTest) want(r pingRoute) { - if nt.gotRoute != r { - nt.tb.Errorf("ping route = %v; want %v", nt.gotRoute, r) - } -} - -func TestEasyEasy(t *testing.T) { - nt := newNatTest(t) - nt.runTest(easy, easy) - nt.want(routeDirect) -} - -func TestSingleJustIPv6(t *testing.T) { - nt := newNatTest(t) - nt.runTest(just6) -} - -var knownBroken = flag.Bool("known-broken", false, "run known-broken tests") - -// TestSingleDualStackButBrokenIPv4 tests a dual-stack node with broken -// (blackholed) IPv4. -// -// See https://github.com/tailscale/tailscale/issues/13346 -func TestSingleDualBrokenIPv4(t *testing.T) { - if !*knownBroken { - t.Skip("skipping known-broken test; set --known-broken to run; see https://github.com/tailscale/tailscale/issues/13346") - } - nt := newNatTest(t) - nt.runTest(v6AndBlackholedIPv4) -} - -func TestJustIPv6(t *testing.T) { - nt := newNatTest(t) - nt.runTest(just6, just6) - nt.want(routeDirect) -} - -func TestEasy4AndJust6(t *testing.T) { - nt := newNatTest(t) - nt.runTest(easyAnd6, just6) - nt.want(routeDirect) -} - -func TestSameLAN(t *testing.T) { - nt := newNatTest(t) - nt.runTest(easy, sameLAN) - nt.want(routeLocal) -} - -// TestBPFDisco tests https://github.com/tailscale/tailscale/issues/3824 ... -// * server behind a Hard NAT -// * client behind a NAT with UPnP support -// * client machine has a stateful host firewall (e.g. ufw) -func TestBPFDisco(t *testing.T) { - nt := newNatTest(t) - nt.runTest(easyPMPFWPlusBPF, hard) - nt.want(routeDirect) -} - -func TestHostFWNoBPF(t *testing.T) { - nt := newNatTest(t) - nt.runTest(easyPMPFWNoBPF, hard) - nt.want(routeDERP) -} - -func TestHostFWPair(t *testing.T) { - nt := newNatTest(t) - nt.runTest(easyFW, easyFW) - nt.want(routeDirect) -} - -func TestOneHostFW(t *testing.T) { - nt := newNatTest(t) - nt.runTest(easy, easyFW) - nt.want(routeDirect) -} - -var pair = flag.String("pair", "", "comma-separated pair of types to test (easy, easyAF, hard, easyPMP, hardPMP, one2one, sameLAN)") - -func TestPair(t *testing.T) { - t1, t2, ok := strings.Cut(*pair, ",") - if !ok { - t.Skipf("skipping test without --pair=type1,type2 set") - } - find := func(name string) addNodeFunc { - for _, nt := range types { - if nt.name == name { - return nt.fn - } - } - t.Fatalf("unknown type %q", name) - return nil - } - - nt := newNatTest(t) - nt.runTest(find(t1), find(t2)) -} - -var runGrid = flag.Bool("run-grid", false, "run grid test") - -func TestGrid(t *testing.T) { - if !*runGrid { - t.Skip("skipping grid test; set --run-grid to run") - } - t.Parallel() - - sem := syncs.NewSemaphore(2) - var ( - mu sync.Mutex - res = make(map[string]pingRoute) - ) - for _, a := range types { - for _, b := range types { - key := a.name + "-" + b.name - keyBack := b.name + "-" + a.name - t.Run(key, func(t *testing.T) { - t.Parallel() - - sem.Acquire() - defer sem.Release() - - filename := key + ".cache" - contents, _ := os.ReadFile(filename) - if len(contents) == 0 { - filename2 := keyBack + ".cache" - contents, _ = os.ReadFile(filename2) - } - route := pingRoute(strings.TrimSpace(string(contents))) - - if route == "" { - nt := newNatTest(t) - route = nt.runTest(a.fn, b.fn) - if err := os.WriteFile(filename, []byte(string(route)), 0666); err != nil { - t.Fatalf("writeFile: %v", err) - } - } - - mu.Lock() - defer mu.Unlock() - res[key] = route - t.Logf("results: %v", res) - }) - } - } - - t.Cleanup(func() { - mu.Lock() - defer mu.Unlock() - var hb bytes.Buffer - pf := func(format string, args ...any) { - fmt.Fprintf(&hb, format, args...) - } - rewrite := func(s string) string { - return strings.ReplaceAll(s, "PMP", "+pm") - } - pf("") - pf("") - for _, a := range types { - pf("", rewrite(a.name)) - } - pf("\n") - - for _, a := range types { - if a.name == "sameLAN" { - continue - } - pf("", rewrite(a.name)) - for _, b := range types { - key := a.name + "-" + b.name - key2 := b.name + "-" + a.name - v := cmp.Or(res[key], res[key2], "-") - if v == "derp" { - pf("", v) - } else if v == "local" { - pf("", v) - } else { - pf("", v) - } - } - pf("\n") - } - pf("
%s
%s
%s
%s
%s
") - pf("easy: Endpoint-Independent Mapping, Address and Port-Dependent Filtering (e.g. Linux, Google Wifi, Unifi, eero)
") - pf("easyAF: Endpoint-Independent Mapping, Address-Dependent Filtering (James says telephony things or Zyxel type things)
") - pf("hard: Address and Port-Dependent Mapping, Address and Port-Dependent Filtering (FreeBSD, OPNSense, pfSense)
") - pf("one2one: One-to-One NAT (e.g. an EC2 instance with a public IPv4)
") - pf("x+pm: x, with port mapping (NAT-PMP, PCP, UPnP, etc)
") - pf("sameLAN: a second node in the same LAN as the first
") - pf("") - - if err := os.WriteFile("grid.html", hb.Bytes(), 0666); err != nil { - t.Fatalf("writeFile: %v", err) - } - }) -} diff --git a/tstest/integration/tailscaled_deps_test_darwin.go b/tstest/integration/tailscaled_deps_test_darwin.go deleted file mode 100644 index 6676ee22cbd1c..0000000000000 --- a/tstest/integration/tailscaled_deps_test_darwin.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by gen_deps.go; DO NOT EDIT. - -package integration - -import ( - // And depend on a bunch of tailscaled innards, for Go's test caching. - // Otherwise cmd/go never sees that we depend on these packages' - // transitive deps when we run "go install tailscaled" in a child - // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" - _ "tailscale.com/client/tailscale" - _ "tailscale.com/cmd/tailscaled/childproc" - _ "tailscale.com/control/controlclient" - _ "tailscale.com/derp/derphttp" - _ "tailscale.com/drive/driveimpl" - _ "tailscale.com/envknob" - _ "tailscale.com/health" - _ "tailscale.com/hostinfo" - _ "tailscale.com/ipn" - _ "tailscale.com/ipn/conffile" - _ "tailscale.com/ipn/ipnlocal" - _ "tailscale.com/ipn/ipnserver" - _ "tailscale.com/ipn/store" - _ "tailscale.com/logpolicy" - _ "tailscale.com/logtail" - _ "tailscale.com/net/dns" - _ "tailscale.com/net/dnsfallback" - _ "tailscale.com/net/netmon" - _ "tailscale.com/net/netns" - _ "tailscale.com/net/proxymux" - _ "tailscale.com/net/socks5" - _ "tailscale.com/net/tsdial" - _ "tailscale.com/net/tshttpproxy" - _ "tailscale.com/net/tstun" - _ "tailscale.com/paths" - _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" - _ "tailscale.com/syncs" - _ "tailscale.com/tailcfg" - _ "tailscale.com/tsd" - _ "tailscale.com/tsweb/varz" - _ "tailscale.com/types/flagtype" - _ "tailscale.com/types/key" - _ "tailscale.com/types/logger" - _ "tailscale.com/types/logid" - _ "tailscale.com/util/clientmetric" - _ "tailscale.com/util/multierr" - _ "tailscale.com/util/osshare" - _ "tailscale.com/version" - _ "tailscale.com/version/distro" - _ "tailscale.com/wgengine" - _ "tailscale.com/wgengine/netstack" - _ "tailscale.com/wgengine/router" -) diff --git a/tstest/integration/tailscaled_deps_test_freebsd.go b/tstest/integration/tailscaled_deps_test_freebsd.go deleted file mode 100644 index 6676ee22cbd1c..0000000000000 --- a/tstest/integration/tailscaled_deps_test_freebsd.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by gen_deps.go; DO NOT EDIT. - -package integration - -import ( - // And depend on a bunch of tailscaled innards, for Go's test caching. - // Otherwise cmd/go never sees that we depend on these packages' - // transitive deps when we run "go install tailscaled" in a child - // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" - _ "tailscale.com/client/tailscale" - _ "tailscale.com/cmd/tailscaled/childproc" - _ "tailscale.com/control/controlclient" - _ "tailscale.com/derp/derphttp" - _ "tailscale.com/drive/driveimpl" - _ "tailscale.com/envknob" - _ "tailscale.com/health" - _ "tailscale.com/hostinfo" - _ "tailscale.com/ipn" - _ "tailscale.com/ipn/conffile" - _ "tailscale.com/ipn/ipnlocal" - _ "tailscale.com/ipn/ipnserver" - _ "tailscale.com/ipn/store" - _ "tailscale.com/logpolicy" - _ "tailscale.com/logtail" - _ "tailscale.com/net/dns" - _ "tailscale.com/net/dnsfallback" - _ "tailscale.com/net/netmon" - _ "tailscale.com/net/netns" - _ "tailscale.com/net/proxymux" - _ "tailscale.com/net/socks5" - _ "tailscale.com/net/tsdial" - _ "tailscale.com/net/tshttpproxy" - _ "tailscale.com/net/tstun" - _ "tailscale.com/paths" - _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" - _ "tailscale.com/syncs" - _ "tailscale.com/tailcfg" - _ "tailscale.com/tsd" - _ "tailscale.com/tsweb/varz" - _ "tailscale.com/types/flagtype" - _ "tailscale.com/types/key" - _ "tailscale.com/types/logger" - _ "tailscale.com/types/logid" - _ "tailscale.com/util/clientmetric" - _ "tailscale.com/util/multierr" - _ "tailscale.com/util/osshare" - _ "tailscale.com/version" - _ "tailscale.com/version/distro" - _ "tailscale.com/wgengine" - _ "tailscale.com/wgengine/netstack" - _ "tailscale.com/wgengine/router" -) diff --git a/tstest/integration/tailscaled_deps_test_linux.go b/tstest/integration/tailscaled_deps_test_linux.go deleted file mode 100644 index 6676ee22cbd1c..0000000000000 --- a/tstest/integration/tailscaled_deps_test_linux.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by gen_deps.go; DO NOT EDIT. - -package integration - -import ( - // And depend on a bunch of tailscaled innards, for Go's test caching. - // Otherwise cmd/go never sees that we depend on these packages' - // transitive deps when we run "go install tailscaled" in a child - // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" - _ "tailscale.com/client/tailscale" - _ "tailscale.com/cmd/tailscaled/childproc" - _ "tailscale.com/control/controlclient" - _ "tailscale.com/derp/derphttp" - _ "tailscale.com/drive/driveimpl" - _ "tailscale.com/envknob" - _ "tailscale.com/health" - _ "tailscale.com/hostinfo" - _ "tailscale.com/ipn" - _ "tailscale.com/ipn/conffile" - _ "tailscale.com/ipn/ipnlocal" - _ "tailscale.com/ipn/ipnserver" - _ "tailscale.com/ipn/store" - _ "tailscale.com/logpolicy" - _ "tailscale.com/logtail" - _ "tailscale.com/net/dns" - _ "tailscale.com/net/dnsfallback" - _ "tailscale.com/net/netmon" - _ "tailscale.com/net/netns" - _ "tailscale.com/net/proxymux" - _ "tailscale.com/net/socks5" - _ "tailscale.com/net/tsdial" - _ "tailscale.com/net/tshttpproxy" - _ "tailscale.com/net/tstun" - _ "tailscale.com/paths" - _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" - _ "tailscale.com/syncs" - _ "tailscale.com/tailcfg" - _ "tailscale.com/tsd" - _ "tailscale.com/tsweb/varz" - _ "tailscale.com/types/flagtype" - _ "tailscale.com/types/key" - _ "tailscale.com/types/logger" - _ "tailscale.com/types/logid" - _ "tailscale.com/util/clientmetric" - _ "tailscale.com/util/multierr" - _ "tailscale.com/util/osshare" - _ "tailscale.com/version" - _ "tailscale.com/version/distro" - _ "tailscale.com/wgengine" - _ "tailscale.com/wgengine/netstack" - _ "tailscale.com/wgengine/router" -) diff --git a/tstest/integration/tailscaled_deps_test_openbsd.go b/tstest/integration/tailscaled_deps_test_openbsd.go deleted file mode 100644 index 6676ee22cbd1c..0000000000000 --- a/tstest/integration/tailscaled_deps_test_openbsd.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by gen_deps.go; DO NOT EDIT. - -package integration - -import ( - // And depend on a bunch of tailscaled innards, for Go's test caching. - // Otherwise cmd/go never sees that we depend on these packages' - // transitive deps when we run "go install tailscaled" in a child - // process and can cache a prior success when a dependency changes. - _ "tailscale.com/chirp" - _ "tailscale.com/client/tailscale" - _ "tailscale.com/cmd/tailscaled/childproc" - _ "tailscale.com/control/controlclient" - _ "tailscale.com/derp/derphttp" - _ "tailscale.com/drive/driveimpl" - _ "tailscale.com/envknob" - _ "tailscale.com/health" - _ "tailscale.com/hostinfo" - _ "tailscale.com/ipn" - _ "tailscale.com/ipn/conffile" - _ "tailscale.com/ipn/ipnlocal" - _ "tailscale.com/ipn/ipnserver" - _ "tailscale.com/ipn/store" - _ "tailscale.com/logpolicy" - _ "tailscale.com/logtail" - _ "tailscale.com/net/dns" - _ "tailscale.com/net/dnsfallback" - _ "tailscale.com/net/netmon" - _ "tailscale.com/net/netns" - _ "tailscale.com/net/proxymux" - _ "tailscale.com/net/socks5" - _ "tailscale.com/net/tsdial" - _ "tailscale.com/net/tshttpproxy" - _ "tailscale.com/net/tstun" - _ "tailscale.com/paths" - _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" - _ "tailscale.com/syncs" - _ "tailscale.com/tailcfg" - _ "tailscale.com/tsd" - _ "tailscale.com/tsweb/varz" - _ "tailscale.com/types/flagtype" - _ "tailscale.com/types/key" - _ "tailscale.com/types/logger" - _ "tailscale.com/types/logid" - _ "tailscale.com/util/clientmetric" - _ "tailscale.com/util/multierr" - _ "tailscale.com/util/osshare" - _ "tailscale.com/version" - _ "tailscale.com/version/distro" - _ "tailscale.com/wgengine" - _ "tailscale.com/wgengine/netstack" - _ "tailscale.com/wgengine/router" -) diff --git a/tstest/integration/tailscaled_deps_test_windows.go b/tstest/integration/tailscaled_deps_test_windows.go deleted file mode 100644 index bbf46d8c21938..0000000000000 --- a/tstest/integration/tailscaled_deps_test_windows.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by gen_deps.go; DO NOT EDIT. - -package integration - -import ( - // And depend on a bunch of tailscaled innards, for Go's test caching. - // Otherwise cmd/go never sees that we depend on these packages' - // transitive deps when we run "go install tailscaled" in a child - // process and can cache a prior success when a dependency changes. - _ "github.com/dblohm7/wingoes/com" - _ "github.com/tailscale/wireguard-go/tun" - _ "golang.org/x/sys/windows" - _ "golang.org/x/sys/windows/svc" - _ "golang.org/x/sys/windows/svc/eventlog" - _ "golang.org/x/sys/windows/svc/mgr" - _ "golang.zx2c4.com/wintun" - _ "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - _ "tailscale.com/client/tailscale" - _ "tailscale.com/cmd/tailscaled/childproc" - _ "tailscale.com/control/controlclient" - _ "tailscale.com/derp/derphttp" - _ "tailscale.com/drive/driveimpl" - _ "tailscale.com/envknob" - _ "tailscale.com/health" - _ "tailscale.com/hostinfo" - _ "tailscale.com/ipn" - _ "tailscale.com/ipn/conffile" - _ "tailscale.com/ipn/ipnlocal" - _ "tailscale.com/ipn/ipnserver" - _ "tailscale.com/ipn/store" - _ "tailscale.com/logpolicy" - _ "tailscale.com/logtail" - _ "tailscale.com/logtail/backoff" - _ "tailscale.com/net/dns" - _ "tailscale.com/net/dnsfallback" - _ "tailscale.com/net/netmon" - _ "tailscale.com/net/netns" - _ "tailscale.com/net/proxymux" - _ "tailscale.com/net/socks5" - _ "tailscale.com/net/tsdial" - _ "tailscale.com/net/tshttpproxy" - _ "tailscale.com/net/tstun" - _ "tailscale.com/paths" - _ "tailscale.com/safesocket" - _ "tailscale.com/syncs" - _ "tailscale.com/tailcfg" - _ "tailscale.com/tsd" - _ "tailscale.com/tsweb/varz" - _ "tailscale.com/types/flagtype" - _ "tailscale.com/types/key" - _ "tailscale.com/types/logger" - _ "tailscale.com/types/logid" - _ "tailscale.com/util/clientmetric" - _ "tailscale.com/util/multierr" - _ "tailscale.com/util/osdiag" - _ "tailscale.com/util/osshare" - _ "tailscale.com/util/syspolicy" - _ "tailscale.com/util/winutil" - _ "tailscale.com/version" - _ "tailscale.com/version/distro" - _ "tailscale.com/wf" - _ "tailscale.com/wgengine" - _ "tailscale.com/wgengine/netstack" - _ "tailscale.com/wgengine/router" -) diff --git a/tstest/integration/testcontrol/testcontrol.go b/tstest/integration/testcontrol/testcontrol.go deleted file mode 100644 index a6b2e1828b8fe..0000000000000 --- a/tstest/integration/testcontrol/testcontrol.go +++ /dev/null @@ -1,1161 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package testcontrol contains a minimal control plane server for testing purposes. -package testcontrol - -import ( - "bytes" - "context" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "maps" - "math/rand/v2" - "net/http" - "net/http/httptest" - "net/netip" - "net/url" - "slices" - "sort" - "strings" - "sync" - "time" - - "golang.org/x/net/http2" - "tailscale.com/control/controlhttp/controlhttpserver" - "tailscale.com/net/netaddr" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/ptr" - "tailscale.com/util/mak" - "tailscale.com/util/must" - "tailscale.com/util/rands" - "tailscale.com/util/set" - "tailscale.com/util/zstdframe" -) - -const msgLimit = 1 << 20 // encrypted message length limit - -// Server is a control plane server. Its zero value is ready for use. -// Everything is stored in-memory in one tailnet. -type Server struct { - Logf logger.Logf // nil means to use the log package - DERPMap *tailcfg.DERPMap // nil means to use prod DERP map - RequireAuth bool - RequireAuthKey string // required authkey for all nodes - Verbose bool - DNSConfig *tailcfg.DNSConfig // nil means no DNS config - MagicDNSDomain string - HandleC2N http.Handler // if non-nil, used for /some-c2n-path/ in tests - - // ExplicitBaseURL or HTTPTestServer must be set. - ExplicitBaseURL string // e.g. "http://127.0.0.1:1234" with no trailing URL - HTTPTestServer *httptest.Server // if non-nil, used to get BaseURL - - initMuxOnce sync.Once - mux *http.ServeMux - - mu sync.Mutex - inServeMap int - cond *sync.Cond // lazily initialized by condLocked - pubKey key.MachinePublic - privKey key.ControlPrivate // not strictly needed vs. MachinePrivate, but handy to test type interactions. - - // nodeSubnetRoutes is a list of subnet routes that are served - // by the specified node. - nodeSubnetRoutes map[key.NodePublic][]netip.Prefix - - // peerIsJailed is the set of peers that are jailed for a node. - peerIsJailed map[key.NodePublic]map[key.NodePublic]bool // node => peer => isJailed - - // masquerades is the set of masquerades that should be applied to - // MapResponses sent to clients. It is keyed by the requesting nodes - // public key, and then the peer node's public key. The value is the - // masquerade address to use for that peer. - masquerades map[key.NodePublic]map[key.NodePublic]netip.Addr // node => peer => SelfNodeV{4,6}MasqAddrForThisPeer IP - - // nodeCapMaps overrides the capability map sent down to a client. - nodeCapMaps map[key.NodePublic]tailcfg.NodeCapMap - - // suppressAutoMapResponses is the set of nodes that should not be sent - // automatic map responses from serveMap. (They should only get manually sent ones) - suppressAutoMapResponses set.Set[key.NodePublic] - - noisePubKey key.MachinePublic - noisePrivKey key.MachinePrivate - - nodes map[key.NodePublic]*tailcfg.Node - users map[key.NodePublic]*tailcfg.User - logins map[key.NodePublic]*tailcfg.Login - updates map[tailcfg.NodeID]chan updateType - authPath map[string]*AuthPath - nodeKeyAuthed map[key.NodePublic]bool // key => true once authenticated - msgToSend map[key.NodePublic]any // value is *tailcfg.PingRequest or entire *tailcfg.MapResponse - allExpired bool // All nodes will be told their node key is expired. -} - -// BaseURL returns the server's base URL, without trailing slash. -func (s *Server) BaseURL() string { - if e := s.ExplicitBaseURL; e != "" { - return e - } - if hs := s.HTTPTestServer; hs != nil { - if hs.URL != "" { - return hs.URL - } - panic("Server.HTTPTestServer not started") - } - panic("Server ExplicitBaseURL and HTTPTestServer both unset") -} - -// NumNodes returns the number of nodes in the testcontrol server. -// -// This is useful when connecting a bunch of virtual machines to a testcontrol -// server to see how many of them connected successfully. -func (s *Server) NumNodes() int { - s.mu.Lock() - defer s.mu.Unlock() - - return len(s.nodes) -} - -// condLocked lazily initializes and returns s.cond. -// s.mu must be held. -func (s *Server) condLocked() *sync.Cond { - if s.cond == nil { - s.cond = sync.NewCond(&s.mu) - } - return s.cond -} - -// AwaitNodeInMapRequest waits for node k to be stuck in a map poll. -// It returns an error if and only if the context is done first. -func (s *Server) AwaitNodeInMapRequest(ctx context.Context, k key.NodePublic) error { - s.mu.Lock() - defer s.mu.Unlock() - cond := s.condLocked() - - done := make(chan struct{}) - defer close(done) - go func() { - select { - case <-done: - case <-ctx.Done(): - cond.Broadcast() - } - }() - - for { - node := s.nodeLocked(k) - if node == nil { - return errors.New("unknown node key") - } - if _, ok := s.updates[node.ID]; ok { - return nil - } - cond.Wait() - if err := ctx.Err(); err != nil { - return err - } - } -} - -// AddPingRequest sends the ping pr to nodeKeyDst. -// -// It reports whether the message was enqueued. That is, it reports whether -// nodeKeyDst was connected. -func (s *Server) AddPingRequest(nodeKeyDst key.NodePublic, pr *tailcfg.PingRequest) bool { - return s.addDebugMessage(nodeKeyDst, pr) -} - -// AddRawMapResponse delivers the raw MapResponse mr to nodeKeyDst. It's meant -// for testing incremental map updates. -// -// Once AddRawMapResponse has been sent to a node, all future automatic -// MapResponses to that node will be suppressed and only explicit MapResponses -// injected via AddRawMapResponse will be sent. -// -// It reports whether the message was enqueued. That is, it reports whether -// nodeKeyDst was connected. -func (s *Server) AddRawMapResponse(nodeKeyDst key.NodePublic, mr *tailcfg.MapResponse) bool { - return s.addDebugMessage(nodeKeyDst, mr) -} - -func (s *Server) addDebugMessage(nodeKeyDst key.NodePublic, msg any) bool { - s.mu.Lock() - defer s.mu.Unlock() - if s.msgToSend == nil { - s.msgToSend = map[key.NodePublic]any{} - } - // Now send the update to the channel - node := s.nodeLocked(nodeKeyDst) - if node == nil { - return false - } - - if _, ok := msg.(*tailcfg.MapResponse); ok { - if s.suppressAutoMapResponses == nil { - s.suppressAutoMapResponses = set.Set[key.NodePublic]{} - } - s.suppressAutoMapResponses.Add(nodeKeyDst) - } - - s.msgToSend[nodeKeyDst] = msg - nodeID := node.ID - oldUpdatesCh := s.updates[nodeID] - return sendUpdate(oldUpdatesCh, updateDebugInjection) -} - -// Mark the Node key of every node as expired -func (s *Server) SetExpireAllNodes(expired bool) { - s.mu.Lock() - defer s.mu.Unlock() - - s.allExpired = expired - - for _, node := range s.nodes { - sendUpdate(s.updates[node.ID], updateSelfChanged) - } -} - -type AuthPath struct { - nodeKey key.NodePublic - - closeOnce sync.Once - ch chan struct{} - success bool -} - -func (ap *AuthPath) completeSuccessfully() { - ap.success = true - close(ap.ch) -} - -// CompleteSuccessfully completes the login path successfully, as if -// the user did the whole auth dance. -func (ap *AuthPath) CompleteSuccessfully() { - ap.closeOnce.Do(ap.completeSuccessfully) -} - -func (s *Server) logf(format string, a ...any) { - if s.Logf != nil { - s.Logf(format, a...) - } else { - log.Printf(format, a...) - } -} - -func (s *Server) initMux() { - s.mux = http.NewServeMux() - s.mux.HandleFunc("/", s.serveUnhandled) - s.mux.HandleFunc("/generate_204", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - }) - s.mux.HandleFunc("/key", s.serveKey) - s.mux.HandleFunc("/machine/", s.serveMachine) - s.mux.HandleFunc("/ts2021", s.serveNoiseUpgrade) - if s.HandleC2N != nil { - s.mux.Handle("/some-c2n-path/", s.HandleC2N) - } -} - -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.initMuxOnce.Do(s.initMux) - s.mux.ServeHTTP(w, r) -} - -func (s *Server) serveUnhandled(w http.ResponseWriter, r *http.Request) { - var got bytes.Buffer - r.Write(&got) - go panic(fmt.Sprintf("testcontrol.Server received unhandled request: %s", got.Bytes())) -} - -type peerMachinePublicContextKey struct{} - -func (s *Server) serveNoiseUpgrade(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if r.Method != "POST" { - http.Error(w, "POST required", 400) - return - } - - s.mu.Lock() - noisePrivate := s.noisePrivKey - s.mu.Unlock() - cc, err := controlhttpserver.AcceptHTTP(ctx, w, r, noisePrivate, nil) - if err != nil { - log.Printf("AcceptHTTP: %v", err) - return - } - defer cc.Close() - - var h2srv http2.Server - peerPub := cc.Peer() - - h2srv.ServeConn(cc, &http2.ServeConnOpts{ - Context: context.WithValue(ctx, peerMachinePublicContextKey{}, peerPub), - BaseConfig: &http.Server{ - Handler: s.mux, - }, - }) -} - -func (s *Server) publicKeys() (noiseKey, pubKey key.MachinePublic) { - s.mu.Lock() - defer s.mu.Unlock() - s.ensureKeyPairLocked() - return s.noisePubKey, s.pubKey -} - -func (s *Server) ensureKeyPairLocked() { - if !s.pubKey.IsZero() { - return - } - s.noisePrivKey = key.NewMachine() - s.noisePubKey = s.noisePrivKey.Public() - s.privKey = key.NewControl() - s.pubKey = s.privKey.Public() -} - -func (s *Server) serveKey(w http.ResponseWriter, r *http.Request) { - noiseKey, legacyKey := s.publicKeys() - if r.FormValue("v") == "" { - w.Header().Set("Content-Type", "text/plain") - io.WriteString(w, legacyKey.UntypedHexString()) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&tailcfg.OverTLSPublicKeyResponse{ - LegacyPublicKey: legacyKey, - PublicKey: noiseKey, - }) -} - -func (s *Server) serveMachine(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "POST required", 400) - return - } - ctx := r.Context() - - mkey, ok := ctx.Value(peerMachinePublicContextKey{}).(key.MachinePublic) - if !ok { - panic("no peer machine public key in context") - } - - switch r.URL.Path { - case "/machine/map": - s.serveMap(w, r, mkey) - case "/machine/register": - s.serveRegister(w, r, mkey) - case "/machine/update-health": - io.Copy(io.Discard, r.Body) - w.WriteHeader(http.StatusNoContent) - default: - s.serveUnhandled(w, r) - } -} - -// SetSubnetRoutes sets the list of subnet routes which a node is routing. -func (s *Server) SetSubnetRoutes(nodeKey key.NodePublic, routes []netip.Prefix) { - s.mu.Lock() - defer s.mu.Unlock() - s.logf("Setting subnet routes for %s: %v", nodeKey.ShortString(), routes) - mak.Set(&s.nodeSubnetRoutes, nodeKey, routes) -} - -// MasqueradePair is a pair of nodes and the IP address that the -// Node masquerades as for the Peer. -// -// Setting this will have future MapResponses for Node to have -// Peer.SelfNodeV{4,6}MasqAddrForThisPeer set to NodeMasqueradesAs. -// MapResponses for the Peer will now see Node.Addresses as -// NodeMasqueradesAs. -type MasqueradePair struct { - Node key.NodePublic - Peer key.NodePublic - NodeMasqueradesAs netip.Addr -} - -// SetJailed sets b to be jailed when it is a peer of a. -func (s *Server) SetJailed(a, b key.NodePublic, jailed bool) { - s.mu.Lock() - defer s.mu.Unlock() - if s.peerIsJailed == nil { - s.peerIsJailed = map[key.NodePublic]map[key.NodePublic]bool{} - } - if s.peerIsJailed[a] == nil { - s.peerIsJailed[a] = map[key.NodePublic]bool{} - } - s.peerIsJailed[a][b] = jailed - s.updateLocked("SetJailed", s.nodeIDsLocked(0)) -} - -// SetMasqueradeAddresses sets the masquerade addresses for the server. -// See MasqueradePair for more details. -func (s *Server) SetMasqueradeAddresses(pairs []MasqueradePair) { - m := make(map[key.NodePublic]map[key.NodePublic]netip.Addr) - for _, p := range pairs { - if m[p.Node] == nil { - m[p.Node] = make(map[key.NodePublic]netip.Addr) - } - m[p.Node][p.Peer] = p.NodeMasqueradesAs - } - s.mu.Lock() - defer s.mu.Unlock() - s.masquerades = m - s.updateLocked("SetMasqueradeAddresses", s.nodeIDsLocked(0)) -} - -// SetNodeCapMap overrides the capability map the specified client receives. -func (s *Server) SetNodeCapMap(nodeKey key.NodePublic, capMap tailcfg.NodeCapMap) { - s.mu.Lock() - defer s.mu.Unlock() - mak.Set(&s.nodeCapMaps, nodeKey, capMap) - s.updateLocked("SetNodeCapMap", s.nodeIDsLocked(0)) -} - -// nodeIDsLocked returns the node IDs of all nodes in the server, except -// for the node with the given ID. -func (s *Server) nodeIDsLocked(except tailcfg.NodeID) []tailcfg.NodeID { - var ids []tailcfg.NodeID - for _, n := range s.nodes { - if n.ID == except { - continue - } - ids = append(ids, n.ID) - } - return ids -} - -// Node returns the node for nodeKey. It's always nil or cloned memory. -func (s *Server) Node(nodeKey key.NodePublic) *tailcfg.Node { - s.mu.Lock() - defer s.mu.Unlock() - return s.nodeLocked(nodeKey) -} - -// nodeLocked returns the node for nodeKey. It's always nil or cloned memory. -// -// s.mu must be held. -func (s *Server) nodeLocked(nodeKey key.NodePublic) *tailcfg.Node { - return s.nodes[nodeKey].Clone() -} - -// AddFakeNode injects a fake node into the server. -func (s *Server) AddFakeNode() { - s.mu.Lock() - defer s.mu.Unlock() - if s.nodes == nil { - s.nodes = make(map[key.NodePublic]*tailcfg.Node) - } - nk := key.NewNode().Public() - mk := key.NewMachine().Public() - dk := key.NewDisco().Public() - r := nk.Raw32() - id := int64(binary.LittleEndian.Uint64(r[:])) - ip := netaddr.IPv4(r[0], r[1], r[2], r[3]) - addr := netip.PrefixFrom(ip, 32) - s.nodes[nk] = &tailcfg.Node{ - ID: tailcfg.NodeID(id), - StableID: tailcfg.StableNodeID(fmt.Sprintf("TESTCTRL%08x", id)), - User: tailcfg.UserID(id), - Machine: mk, - Key: nk, - MachineAuthorized: true, - DiscoKey: dk, - Addresses: []netip.Prefix{addr}, - AllowedIPs: []netip.Prefix{addr}, - } - // TODO: send updates to other (non-fake?) nodes -} - -func (s *Server) AllUsers() (users []*tailcfg.User) { - s.mu.Lock() - defer s.mu.Unlock() - for _, u := range s.users { - users = append(users, u.Clone()) - } - return users -} - -func (s *Server) AllNodes() (nodes []*tailcfg.Node) { - s.mu.Lock() - defer s.mu.Unlock() - for _, n := range s.nodes { - nodes = append(nodes, n.Clone()) - } - sort.Slice(nodes, func(i, j int) bool { - return nodes[i].StableID < nodes[j].StableID - }) - return nodes -} - -const domain = "fake-control.example.net" - -func (s *Server) getUser(nodeKey key.NodePublic) (*tailcfg.User, *tailcfg.Login) { - s.mu.Lock() - defer s.mu.Unlock() - if s.users == nil { - s.users = map[key.NodePublic]*tailcfg.User{} - } - if s.logins == nil { - s.logins = map[key.NodePublic]*tailcfg.Login{} - } - if u, ok := s.users[nodeKey]; ok { - return u, s.logins[nodeKey] - } - id := tailcfg.UserID(len(s.users) + 1) - loginName := fmt.Sprintf("user-%d@%s", id, domain) - displayName := fmt.Sprintf("User %d", id) - login := &tailcfg.Login{ - ID: tailcfg.LoginID(id), - Provider: "testcontrol", - LoginName: loginName, - DisplayName: displayName, - ProfilePicURL: "https://tailscale.com/static/images/marketing/team-carney.jpg", - } - user := &tailcfg.User{ - ID: id, - LoginName: loginName, - DisplayName: displayName, - Logins: []tailcfg.LoginID{login.ID}, - } - s.users[nodeKey] = user - s.logins[nodeKey] = login - return user, login -} - -// authPathDone returns a close-only struct that's closed when the -// authPath ("/auth/XXXXXX") has authenticated. -func (s *Server) authPathDone(authPath string) <-chan struct{} { - s.mu.Lock() - defer s.mu.Unlock() - if a, ok := s.authPath[authPath]; ok { - return a.ch - } - return nil -} - -func (s *Server) addAuthPath(authPath string, nodeKey key.NodePublic) { - s.mu.Lock() - defer s.mu.Unlock() - if s.authPath == nil { - s.authPath = map[string]*AuthPath{} - } - s.authPath[authPath] = &AuthPath{ - ch: make(chan struct{}), - nodeKey: nodeKey, - } -} - -// CompleteAuth marks the provided path or URL (containing -// "/auth/...") as successfully authenticated, unblocking any -// requests blocked on that in serveRegister. -func (s *Server) CompleteAuth(authPathOrURL string) bool { - i := strings.Index(authPathOrURL, "/auth/") - if i == -1 { - return false - } - authPath := authPathOrURL[i:] - - s.mu.Lock() - defer s.mu.Unlock() - ap, ok := s.authPath[authPath] - if !ok { - return false - } - if ap.nodeKey.IsZero() { - panic("zero AuthPath.NodeKey") - } - if s.nodeKeyAuthed == nil { - s.nodeKeyAuthed = map[key.NodePublic]bool{} - } - s.nodeKeyAuthed[ap.nodeKey] = true - ap.CompleteSuccessfully() - return true -} - -func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey key.MachinePublic) { - msg, err := io.ReadAll(io.LimitReader(r.Body, msgLimit)) - r.Body.Close() - if err != nil { - http.Error(w, fmt.Sprintf("bad map request read: %v", err), 400) - return - } - - var req tailcfg.RegisterRequest - if err := s.decode(msg, &req); err != nil { - go panic(fmt.Sprintf("serveRegister: decode: %v", err)) - } - if req.Version == 0 { - panic("serveRegister: zero Version") - } - if req.NodeKey.IsZero() { - go panic("serveRegister: request has zero node key") - } - if s.Verbose { - j, _ := json.MarshalIndent(req, "", "\t") - log.Printf("Got %T: %s", req, j) - } - if s.RequireAuthKey != "" && (req.Auth == nil || req.Auth.AuthKey != s.RequireAuthKey) { - res := must.Get(s.encode(false, tailcfg.RegisterResponse{ - Error: "invalid authkey", - })) - w.WriteHeader(200) - w.Write(res) - return - } - - // If this is a followup request, wait until interactive followup URL visit complete. - if req.Followup != "" { - followupURL, err := url.Parse(req.Followup) - if err != nil { - panic(err) - } - doneCh := s.authPathDone(followupURL.Path) - select { - case <-r.Context().Done(): - return - case <-doneCh: - } - // TODO(bradfitz): support a side test API to mark an - // auth as failed so we can send an error response in - // some follow-ups? For now all are successes. - } - - nk := req.NodeKey - - user, login := s.getUser(nk) - s.mu.Lock() - if s.nodes == nil { - s.nodes = map[key.NodePublic]*tailcfg.Node{} - } - - machineAuthorized := true // TODO: add Server.RequireMachineAuth - - v4Prefix := netip.PrefixFrom(netaddr.IPv4(100, 64, uint8(tailcfg.NodeID(user.ID)>>8), uint8(tailcfg.NodeID(user.ID))), 32) - v6Prefix := netip.PrefixFrom(tsaddr.Tailscale4To6(v4Prefix.Addr()), 128) - - allowedIPs := []netip.Prefix{ - v4Prefix, - v6Prefix, - } - - s.nodes[nk] = &tailcfg.Node{ - ID: tailcfg.NodeID(user.ID), - StableID: tailcfg.StableNodeID(fmt.Sprintf("TESTCTRL%08x", int(user.ID))), - User: user.ID, - Machine: mkey, - Key: req.NodeKey, - MachineAuthorized: machineAuthorized, - Addresses: allowedIPs, - AllowedIPs: allowedIPs, - Hostinfo: req.Hostinfo.View(), - Name: req.Hostinfo.Hostname, - Capabilities: []tailcfg.NodeCapability{ - tailcfg.CapabilityHTTPS, - tailcfg.NodeAttrFunnel, - tailcfg.CapabilityFunnelPorts + "?ports=8080,443", - }, - } - requireAuth := s.RequireAuth - if requireAuth && s.nodeKeyAuthed[nk] { - requireAuth = false - } - allExpired := s.allExpired - s.mu.Unlock() - - authURL := "" - if requireAuth { - authPath := fmt.Sprintf("/auth/%s", rands.HexString(20)) - s.addAuthPath(authPath, nk) - authURL = s.BaseURL() + authPath - } - - res, err := s.encode(false, tailcfg.RegisterResponse{ - User: *user, - Login: *login, - NodeKeyExpired: allExpired, - MachineAuthorized: machineAuthorized, - AuthURL: authURL, - }) - if err != nil { - go panic(fmt.Sprintf("serveRegister: encode: %v", err)) - } - w.WriteHeader(200) - w.Write(res) -} - -// updateType indicates why a long-polling map request is being woken -// up for an update. -type updateType int - -const ( - // updatePeerChanged is an update that a peer has changed. - updatePeerChanged updateType = iota + 1 - - // updateSelfChanged is an update that the node changed itself - // via a lite endpoint update. These ones are never dup-suppressed, - // as the client is expecting an answer regardless. - updateSelfChanged - - // updateDebugInjection is an update used for PingRequests - // or a raw MapResponse. - updateDebugInjection -) - -func (s *Server) updateLocked(source string, peers []tailcfg.NodeID) { - for _, peer := range peers { - sendUpdate(s.updates[peer], updatePeerChanged) - } -} - -// sendUpdate sends updateType to dst if dst is non-nil and -// has capacity. It reports whether a value was sent. -func sendUpdate(dst chan<- updateType, updateType updateType) bool { - if dst == nil { - return false - } - // The dst channel has a buffer size of 1. - // If we fail to insert an update into the buffer that - // means there is already an update pending. - select { - case dst <- updateType: - return true - default: - return false - } -} - -func (s *Server) UpdateNode(n *tailcfg.Node) (peersToUpdate []tailcfg.NodeID) { - s.mu.Lock() - defer s.mu.Unlock() - if n.Key.IsZero() { - panic("zero nodekey") - } - s.nodes[n.Key] = n.Clone() - return s.nodeIDsLocked(n.ID) -} - -func (s *Server) incrInServeMap(delta int) { - s.mu.Lock() - defer s.mu.Unlock() - s.inServeMap += delta -} - -// InServeMap returns the number of clients currently in a MapRequest HTTP handler. -func (s *Server) InServeMap() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.inServeMap -} - -func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.MachinePublic) { - s.incrInServeMap(1) - defer s.incrInServeMap(-1) - ctx := r.Context() - - msg, err := io.ReadAll(io.LimitReader(r.Body, msgLimit)) - if err != nil { - r.Body.Close() - http.Error(w, fmt.Sprintf("bad map request read: %v", err), 400) - return - } - r.Body.Close() - - req := new(tailcfg.MapRequest) - if err := s.decode(msg, req); err != nil { - go panic(fmt.Sprintf("bad map request: %v", err)) - } - - jitter := rand.N(8 * time.Second) - keepAlive := 50*time.Second + jitter - - node := s.Node(req.NodeKey) - if node == nil { - http.Error(w, "node not found", 400) - return - } - if node.Machine != mkey { - http.Error(w, "node doesn't match machine key", 400) - return - } - - var peersToUpdate []tailcfg.NodeID - if !req.ReadOnly { - endpoints := filterInvalidIPv6Endpoints(req.Endpoints) - node.Endpoints = endpoints - node.DiscoKey = req.DiscoKey - if req.Hostinfo != nil { - node.Hostinfo = req.Hostinfo.View() - if ni := node.Hostinfo.NetInfo(); ni.Valid() { - if ni.PreferredDERP() != 0 { - node.DERP = fmt.Sprintf("127.3.3.40:%d", ni.PreferredDERP()) - } - } - } - peersToUpdate = s.UpdateNode(node) - } - - nodeID := node.ID - - s.mu.Lock() - updatesCh := make(chan updateType, 1) - oldUpdatesCh := s.updates[nodeID] - if breakSameNodeMapResponseStreams(req) { - if oldUpdatesCh != nil { - close(oldUpdatesCh) - } - if s.updates == nil { - s.updates = map[tailcfg.NodeID]chan updateType{} - } - s.updates[nodeID] = updatesCh - } else { - sendUpdate(oldUpdatesCh, updateSelfChanged) - } - s.updateLocked("serveMap", peersToUpdate) - s.condLocked().Broadcast() - s.mu.Unlock() - - // ReadOnly implies no streaming, as it doesn't - // register an updatesCh to get updates. - streaming := req.Stream && !req.ReadOnly - compress := req.Compress != "" - - w.WriteHeader(200) - for { - if resBytes, ok := s.takeRawMapMessage(req.NodeKey); ok { - if err := s.sendMapMsg(w, compress, resBytes); err != nil { - s.logf("sendMapMsg of raw message: %v", err) - return - } - if streaming { - continue - } - return - } - - if s.canGenerateAutomaticMapResponseFor(req.NodeKey) { - res, err := s.MapResponse(req) - if err != nil { - // TODO: log - return - } - if res == nil { - return // done - } - - s.mu.Lock() - allExpired := s.allExpired - s.mu.Unlock() - if allExpired { - res.Node.KeyExpiry = time.Now().Add(-1 * time.Minute) - } - // TODO: add minner if/when needed - resBytes, err := json.Marshal(res) - if err != nil { - s.logf("json.Marshal: %v", err) - return - } - if err := s.sendMapMsg(w, compress, resBytes); err != nil { - return - } - } - if !streaming { - return - } - if s.hasPendingRawMapMessage(req.NodeKey) { - continue - } - keepAliveLoop: - for { - var keepAliveTimer *time.Timer - var keepAliveTimerCh <-chan time.Time - if keepAlive > 0 { - keepAliveTimer = time.NewTimer(keepAlive) - keepAliveTimerCh = keepAliveTimer.C - } - select { - case <-ctx.Done(): - if keepAliveTimer != nil { - keepAliveTimer.Stop() - } - return - case _, ok := <-updatesCh: - if !ok { - // replaced by new poll request - return - } - break keepAliveLoop - case <-keepAliveTimerCh: - if err := s.sendMapMsg(w, compress, keepAliveMsg); err != nil { - return - } - } - } - } -} - -var keepAliveMsg = &struct { - KeepAlive bool -}{ - KeepAlive: true, -} - -func packetFilterWithIngressCaps() []tailcfg.FilterRule { - out := slices.Clone(tailcfg.FilterAllowAll) - out = append(out, tailcfg.FilterRule{ - SrcIPs: []string{"*"}, - CapGrant: []tailcfg.CapGrant{ - { - Dsts: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}, - Caps: []tailcfg.PeerCapability{tailcfg.PeerCapabilityIngress}, - }, - }, - }) - return out -} - -// MapResponse generates a MapResponse for a MapRequest. -// -// No updates to s are done here. -func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse, err error) { - nk := req.NodeKey - node := s.Node(nk) - if node == nil { - // node key rotated away (once test server supports that) - return nil, nil - } - - s.mu.Lock() - nodeCapMap := maps.Clone(s.nodeCapMaps[nk]) - s.mu.Unlock() - - node.CapMap = nodeCapMap - node.Capabilities = append(node.Capabilities, tailcfg.NodeAttrDisableUPnP) - - user, _ := s.getUser(nk) - t := time.Date(2020, 8, 3, 0, 0, 0, 1, time.UTC) - dns := s.DNSConfig - if dns != nil && s.MagicDNSDomain != "" { - dns = dns.Clone() - dns.CertDomains = []string{ - fmt.Sprintf(node.Hostinfo.Hostname() + "." + s.MagicDNSDomain), - } - } - - res = &tailcfg.MapResponse{ - Node: node, - DERPMap: s.DERPMap, - Domain: domain, - CollectServices: "true", - PacketFilter: packetFilterWithIngressCaps(), - DNSConfig: dns, - ControlTime: &t, - } - - s.mu.Lock() - nodeMasqs := s.masquerades[node.Key] - jailed := maps.Clone(s.peerIsJailed[node.Key]) - s.mu.Unlock() - for _, p := range s.AllNodes() { - if p.StableID == node.StableID { - continue - } - if masqIP := nodeMasqs[p.Key]; masqIP.IsValid() { - if masqIP.Is6() { - p.SelfNodeV6MasqAddrForThisPeer = ptr.To(masqIP) - } else { - p.SelfNodeV4MasqAddrForThisPeer = ptr.To(masqIP) - } - } - p.IsJailed = jailed[p.Key] - - s.mu.Lock() - peerAddress := s.masquerades[p.Key][node.Key] - routes := s.nodeSubnetRoutes[p.Key] - s.mu.Unlock() - if peerAddress.IsValid() { - if peerAddress.Is6() { - p.Addresses[1] = netip.PrefixFrom(peerAddress, peerAddress.BitLen()) - p.AllowedIPs[1] = netip.PrefixFrom(peerAddress, peerAddress.BitLen()) - } else { - p.Addresses[0] = netip.PrefixFrom(peerAddress, peerAddress.BitLen()) - p.AllowedIPs[0] = netip.PrefixFrom(peerAddress, peerAddress.BitLen()) - } - } - if len(routes) > 0 { - p.PrimaryRoutes = routes - p.AllowedIPs = append(p.AllowedIPs, routes...) - } - res.Peers = append(res.Peers, p) - } - - sort.Slice(res.Peers, func(i, j int) bool { - return res.Peers[i].ID < res.Peers[j].ID - }) - for _, u := range s.AllUsers() { - res.UserProfiles = append(res.UserProfiles, tailcfg.UserProfile{ - ID: u.ID, - LoginName: u.LoginName, - DisplayName: u.DisplayName, - }) - } - - v4Prefix := netip.PrefixFrom(netaddr.IPv4(100, 64, uint8(tailcfg.NodeID(user.ID)>>8), uint8(tailcfg.NodeID(user.ID))), 32) - v6Prefix := netip.PrefixFrom(tsaddr.Tailscale4To6(v4Prefix.Addr()), 128) - - res.Node.Addresses = []netip.Prefix{ - v4Prefix, - v6Prefix, - } - - s.mu.Lock() - defer s.mu.Unlock() - res.Node.PrimaryRoutes = s.nodeSubnetRoutes[nk] - res.Node.AllowedIPs = append(res.Node.Addresses, s.nodeSubnetRoutes[nk]...) - - // Consume a PingRequest while protected by mutex if it exists - switch m := s.msgToSend[nk].(type) { - case *tailcfg.PingRequest: - res.PingRequest = m - delete(s.msgToSend, nk) - } - return res, nil -} - -func (s *Server) canGenerateAutomaticMapResponseFor(nk key.NodePublic) bool { - s.mu.Lock() - defer s.mu.Unlock() - return !s.suppressAutoMapResponses.Contains(nk) -} - -func (s *Server) hasPendingRawMapMessage(nk key.NodePublic) bool { - s.mu.Lock() - defer s.mu.Unlock() - _, ok := s.msgToSend[nk].(*tailcfg.MapResponse) - return ok -} - -func (s *Server) takeRawMapMessage(nk key.NodePublic) (mapResJSON []byte, ok bool) { - s.mu.Lock() - defer s.mu.Unlock() - mr, ok := s.msgToSend[nk].(*tailcfg.MapResponse) - if !ok { - return nil, false - } - delete(s.msgToSend, nk) - var err error - mapResJSON, err = json.Marshal(mr) - if err != nil { - panic(err) - } - return mapResJSON, true -} - -func (s *Server) sendMapMsg(w http.ResponseWriter, compress bool, msg any) error { - resBytes, err := s.encode(compress, msg) - if err != nil { - return err - } - if len(resBytes) > 16<<20 { - return fmt.Errorf("map message too big: %d", len(resBytes)) - } - var siz [4]byte - binary.LittleEndian.PutUint32(siz[:], uint32(len(resBytes))) - if _, err := w.Write(siz[:]); err != nil { - return err - } - if _, err := w.Write(resBytes); err != nil { - return err - } - if f, ok := w.(http.Flusher); ok { - f.Flush() - } else { - s.logf("[unexpected] ResponseWriter %T is not a Flusher", w) - } - return nil -} - -func (s *Server) decode(msg []byte, v any) error { - if len(msg) == msgLimit { - return errors.New("encrypted message too long") - } - return json.Unmarshal(msg, v) -} - -func (s *Server) encode(compress bool, v any) (b []byte, err error) { - var isBytes bool - if b, isBytes = v.([]byte); !isBytes { - b, err = json.Marshal(v) - if err != nil { - return nil, err - } - } - if compress { - b = zstdframe.AppendEncode(nil, b, zstdframe.FastestCompression) - } - return b, nil -} - -// filterInvalidIPv6Endpoints removes invalid IPv6 endpoints from eps, -// modify the slice in place, returning the potentially smaller subset (aliasing -// the original memory). -// -// Two types of IPv6 endpoints are considered invalid: link-local -// addresses, and anything with a zone. -func filterInvalidIPv6Endpoints(eps []netip.AddrPort) []netip.AddrPort { - clean := eps[:0] - for _, ep := range eps { - if keepClientEndpoint(ep) { - clean = append(clean, ep) - } - } - return clean -} - -func keepClientEndpoint(ipp netip.AddrPort) bool { - ip := ipp.Addr() - if ip.Zone() != "" { - return false - } - if ip.Is6() && ip.IsLinkLocalUnicast() { - // We let clients send these for now, but - // tailscaled doesn't know how to use them yet - // so we filter them out for now. A future - // MapRequest.Version might signal that - // clients know how to use them (e.g. try all - // local scopes). - return false - } - return true -} - -// breakSameNodeMapResponseStreams reports whether req should break a -// prior long-polling MapResponse stream (if active) from the same -// node ID. -func breakSameNodeMapResponseStreams(req *tailcfg.MapRequest) bool { - if req.ReadOnly { - // Don't register our updatesCh for closability - // nor close another peer's if we're a read-only request. - return false - } - if !req.Stream && req.OmitPeers { - // Likewise, if we're not streaming and not asking for peers, - // (but still mutable, without Readonly set), consider this an endpoint - // update request only, and don't close any existing map response - // for this nodeID. It's likely the same client with a built-up - // compression context. We want to let them update their - // new endpoints with us without breaking that other long-running - // map response. - return false - } - return true -} diff --git a/tstest/integration/vms/README.md b/tstest/integration/vms/README.md deleted file mode 100644 index 519c3d000fb63..0000000000000 --- a/tstest/integration/vms/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# End-to-End VM-based Integration Testing - -This test spins up a bunch of common linux distributions and then tries to get -them to connect to a -[`testcontrol`](https://pkg.go.dev/tailscale.com/tstest/integration/testcontrol) -server. - -## Running - -This test currently only runs on Linux. - -This test depends on the following command line tools: - -- [qemu](https://www.qemu.org/) -- [cdrkit](https://en.wikipedia.org/wiki/Cdrkit) -- [openssh](https://www.openssh.com/) - -This test also requires the following: - -- about 10 GB of temporary storage -- about 10 GB of cached VM images -- at least 4 GB of ram for virtual machines -- hardware virtualization support - ([KVM](https://www.linux-kvm.org/page/Main_Page)) enabled in the BIOS -- the `kvm` module to be loaded (`modprobe kvm`) -- the user running these tests must have access to `/dev/kvm` (being in the - `kvm` group should suffice) - -The `--no-s3` flag is needed to disable downloads from S3, which require -credentials. However keep in mind that some distributions do not use stable URLs -for each individual image artifact, so there may be spurious test failures as a -result. - -If you are using [Nix](https://nixos.org), you can run all of the tests with the -correct command line tools using this command: - -```console -$ nix-shell -p nixos-generators -p openssh -p go -p qemu -p cdrkit --run "go test . --run-vm-tests --v --timeout 30m --no-s3" -``` - -Keep the timeout high for the first run, especially if you are not downloading -VM images from S3. The mirrors we pull images from have download rate limits and -will take a while to download. - -Because of the hardware requirements of this test, this test will not run -without the `--run-vm-tests` flag set. - -## Other Fun Flags - -This test's behavior is customized with command line flags. - -### Don't Download Images From S3 - -If you pass the `-no-s3` flag to `go test`, the S3 step will be skipped in favor -of downloading the images directly from upstream sources, which may cause the -test to fail in odd places. - -### Distribution Picking - -This test runs on a large number of distributions. By default it tries to run -everything, which may or may not be ideal for you. If you only want to test a -subset of distributions, you can use the `--distro-regex` flag to match a subset -of distributions using a [regular expression](https://golang.org/pkg/regexp/) -such as like this: - -```console -$ go test -run-vm-tests -distro-regex centos -``` - -This would run all tests on all versions of CentOS. - -```console -$ go test -run-vm-tests -distro-regex '(debian|ubuntu)' -``` - -This would run all tests on all versions of Debian and Ubuntu. - -### Ram Limiting - -This test uses a lot of memory. In order to avoid making machines run out of -memory running this test, a semaphore is used to limit how many megabytes of ram -are being used at once. By default this semaphore is set to 4096 MB of ram -(about 4 gigabytes). You can customize this with the `--ram-limit` flag: - -```console -$ go test --run-vm-tests --ram-limit 2048 -$ go test --run-vm-tests --ram-limit 65536 -``` - -The first example will set the limit to 2048 MB of ram (about 2 gigabytes). The -second example will set the limit to 65536 MB of ram (about 65 gigabytes). -Please be careful with this flag, improper usage of it is known to cause the -Linux out-of-memory killer to engage. Try to keep it within 50-75% of your -machine's available ram (there is some overhead involved with the -virtualization) to be on the safe side. diff --git a/tstest/integration/vms/derive_bindhost_test.go b/tstest/integration/vms/derive_bindhost_test.go deleted file mode 100644 index 728f60c01e465..0000000000000 --- a/tstest/integration/vms/derive_bindhost_test.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vms - -import ( - "net/netip" - "runtime" - "testing" - - "tailscale.com/net/netmon" -) - -func deriveBindhost(t *testing.T) string { - t.Helper() - - ifName, err := netmon.DefaultRouteInterface() - if err != nil { - t.Fatal(err) - } - - var ret string - err = netmon.ForeachInterfaceAddress(func(i netmon.Interface, prefix netip.Prefix) { - if ret != "" || i.Name != ifName { - return - } - ret = prefix.Addr().String() - }) - if ret != "" { - return ret - } - if err != nil { - t.Fatal(err) - } - t.Fatal("can't find a bindhost") - return "unreachable" -} - -func TestDeriveBindhost(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("requires GOOS=linux") - } - t.Log(deriveBindhost(t)) -} diff --git a/tstest/integration/vms/distros.go b/tstest/integration/vms/distros.go deleted file mode 100644 index ca2bf53ba66a7..0000000000000 --- a/tstest/integration/vms/distros.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vms - -import ( - _ "embed" - "encoding/json" - "log" - - "github.com/tailscale/hujson" -) - -type Distro struct { - Name string // amazon-linux - URL string // URL to a qcow2 image - SHA256Sum string // hex-encoded sha256 sum of contents of URL - MemoryMegs int // VM memory in megabytes - PackageManager string // yum/apt/dnf/zypper - InitSystem string // systemd/openrc - HostGenerated bool // generated image rather than downloaded -} - -func (d *Distro) InstallPre() string { - switch d.PackageManager { - case "yum": - return ` - [ yum, update, gnupg2 ] - - [ yum, "-y", install, iptables ] - - [ sh, "-c", "printf '\n\nUseDNS no\n\n' | tee -a /etc/ssh/sshd_config" ] - - [ systemctl, restart, "sshd.service" ]` - case "zypper": - return ` - [ zypper, in, "-y", iptables ]` - - case "dnf": - return ` - [ dnf, install, "-y", iptables ]` - - case "apt": - return ` - [ apt-get, update ] - - [ apt-get, "-y", install, curl, "apt-transport-https", gnupg2 ]` - - case "apk": - return ` - [ apk, "-U", add, curl, "ca-certificates", iptables, ip6tables ] - - [ modprobe, tun ]` - } - - return "" -} - -//go:embed distros.hujson -var distroData string - -var Distros []Distro = func() []Distro { - var result []Distro - b, err := hujson.Standardize([]byte(distroData)) - if err != nil { - log.Fatalf("error decoding distros: %v", err) - } - if err := json.Unmarshal(b, &result); err != nil { - log.Fatalf("error decoding distros: %v", err) - } - return result -}() diff --git a/tstest/integration/vms/distros.hujson b/tstest/integration/vms/distros.hujson deleted file mode 100644 index 049091ed50e6e..0000000000000 --- a/tstest/integration/vms/distros.hujson +++ /dev/null @@ -1,39 +0,0 @@ -// NOTE(Xe): If you run into issues getting the autoconfig to work, run -// this test with the flag `--distro-regex=alpine-edge`. Connect with a VNC -// client with a command like this: -// -// $ vncviewer :0 -// -// On NixOS you can get away with something like this: -// -// $ env NIXPKGS_ALLOW_UNFREE=1 nix-shell -p tigervnc --run 'vncviewer :0' -// -// Login as root with the password root. Then look in -// /var/log/cloud-init-output.log for what you messed up. -[ - { - "Name": "ubuntu-18-04", - "URL": "https://cloud-images.ubuntu.com/releases/bionic/release-20210817/ubuntu-18.04-server-cloudimg-amd64.img", - "SHA256Sum": "1ee1039f0b91c8367351413b5b5f56026aaf302fd5f66f17f8215132d6e946d2", - "MemoryMegs": 512, - "PackageManager": "apt", - "InitSystem": "systemd" - }, - { - "Name": "ubuntu-20-04", - "URL": "https://cloud-images.ubuntu.com/releases/focal/release-20210819/ubuntu-20.04-server-cloudimg-amd64.img", - "SHA256Sum": "99e25e6e344e3a50a081235e825937238a3d51b099969e107ef66f0d3a1f955e", - "MemoryMegs": 512, - "PackageManager": "apt", - "InitSystem": "systemd" - }, - { - "Name": "nixos-21-11", - "URL": "channel:nixos-21.11", - "SHA256Sum": "lolfakesha", - "MemoryMegs": 512, - "PackageManager": "nix", - "InitSystem": "systemd", - "HostGenerated": true - }, -] diff --git a/tstest/integration/vms/distros_test.go b/tstest/integration/vms/distros_test.go deleted file mode 100644 index 462aa2a6bc825..0000000000000 --- a/tstest/integration/vms/distros_test.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vms - -import ( - "testing" -) - -func TestDistrosGotLoaded(t *testing.T) { - if len(Distros) == 0 { - t.Fatal("no distros were loaded") - } -} diff --git a/tstest/integration/vms/dns_tester.go b/tstest/integration/vms/dns_tester.go deleted file mode 100644 index 50b39bb5f1fa1..0000000000000 --- a/tstest/integration/vms/dns_tester.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ignore - -// Command dns_tester exists in order to perform tests of our DNS -// configuration stack. This was written because the state of DNS -// in our target environments is so diverse that we need a little tool -// to do this test for us. -package main - -import ( - "context" - "encoding/json" - "flag" - "net" - "os" - "time" -) - -func main() { - flag.Parse() - target := flag.Arg(0) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - errCount := 0 - wait := 25 * time.Millisecond - for range make([]struct{}, 5) { - err := lookup(ctx, target) - if err != nil { - errCount++ - time.Sleep(wait) - wait = wait * 2 - continue - } - - break - } -} - -func lookup(ctx context.Context, target string) error { - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - - hosts, err := net.LookupHost(target) - if err != nil { - return err - } - - json.NewEncoder(os.Stdout).Encode(hosts) - return nil -} diff --git a/tstest/integration/vms/doc.go b/tstest/integration/vms/doc.go deleted file mode 100644 index 6093b53ac8ed5..0000000000000 --- a/tstest/integration/vms/doc.go +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package vms does VM-based integration/functional tests by using -// qemu and a bank of pre-made VM images. -package vms diff --git a/tstest/integration/vms/harness_test.go b/tstest/integration/vms/harness_test.go deleted file mode 100644 index 1e080414d72e7..0000000000000 --- a/tstest/integration/vms/harness_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && !plan9 - -package vms - -import ( - "bytes" - "context" - "fmt" - "log" - "net" - "net/http" - "net/netip" - "os" - "os/exec" - "path" - "path/filepath" - "strconv" - "sync" - "testing" - "time" - - "golang.org/x/crypto/ssh" - "golang.org/x/net/proxy" - "tailscale.com/tailcfg" - "tailscale.com/tstest/integration" - "tailscale.com/tstest/integration/testcontrol" - "tailscale.com/types/dnstype" -) - -type Harness struct { - testerDialer proxy.Dialer - testerDir string - binaryDir string - cli string - daemon string - pubKey string - signer ssh.Signer - cs *testcontrol.Server - loginServerURL string - testerV4 netip.Addr - ipMu *sync.Mutex - ipMap map[string]ipMapping -} - -func newHarness(t *testing.T) *Harness { - dir := t.TempDir() - bindHost := deriveBindhost(t) - ln, err := net.Listen("tcp", net.JoinHostPort(bindHost, "0")) - if err != nil { - t.Fatalf("can't make TCP listener: %v", err) - } - t.Cleanup(func() { - ln.Close() - }) - t.Logf("host:port: %s", ln.Addr()) - - cs := &testcontrol.Server{ - DNSConfig: &tailcfg.DNSConfig{ - // TODO: this is wrong. - // It is also only one of many configurations. - // Figure out how to scale it up. - Resolvers: []*dnstype.Resolver{{Addr: "100.100.100.100"}, {Addr: "8.8.8.8"}}, - Domains: []string{"record"}, - Proxied: true, - ExtraRecords: []tailcfg.DNSRecord{{Name: "extratest.record", Type: "A", Value: "1.2.3.4"}}, - }, - } - - derpMap := integration.RunDERPAndSTUN(t, t.Logf, bindHost) - cs.DERPMap = derpMap - - var ( - ipMu sync.Mutex - ipMap = map[string]ipMapping{} - ) - - mux := http.NewServeMux() - mux.Handle("/", cs) - - lc := &integration.LogCatcher{} - if *verboseLogcatcher { - lc.UseLogf(t.Logf) - t.Cleanup(func() { - lc.UseLogf(nil) // do not log after test is complete - }) - } - mux.Handle("/c/", lc) - - // This handler will let the virtual machines tell the host information about that VM. - // This is used to maintain a list of port->IP address mappings that are known to be - // working. This allows later steps to connect over SSH. This returns no response to - // clients because no response is needed. - mux.HandleFunc("/myip/", func(w http.ResponseWriter, r *http.Request) { - ipMu.Lock() - defer ipMu.Unlock() - - name := path.Base(r.URL.Path) - host, _, _ := net.SplitHostPort(r.RemoteAddr) - port, err := strconv.Atoi(name) - if err != nil { - log.Panicf("bad port: %v", port) - } - distro := r.UserAgent() - ipMap[distro] = ipMapping{distro, port, host} - t.Logf("%s: %v", name, host) - }) - - hs := &http.Server{Handler: mux} - go hs.Serve(ln) - - cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", "machinekey", "-N", "") - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("ssh-keygen: %v, %s", err, out) - } - pubkey, err := os.ReadFile(filepath.Join(dir, "machinekey.pub")) - if err != nil { - t.Fatalf("can't read ssh key: %v", err) - } - - privateKey, err := os.ReadFile(filepath.Join(dir, "machinekey")) - if err != nil { - t.Fatalf("can't read ssh private key: %v", err) - } - - signer, err := ssh.ParsePrivateKey(privateKey) - if err != nil { - t.Fatalf("can't parse private key: %v", err) - } - - loginServer := fmt.Sprintf("http://%s", ln.Addr()) - t.Logf("loginServer: %s", loginServer) - - h := &Harness{ - pubKey: string(pubkey), - binaryDir: integration.BinaryDir(t), - cli: integration.TailscaleBinary(t), - daemon: integration.TailscaledBinary(t), - signer: signer, - loginServerURL: loginServer, - cs: cs, - ipMu: &ipMu, - ipMap: ipMap, - } - - h.makeTestNode(t, loginServer) - - return h -} - -func (h *Harness) Tailscale(t *testing.T, args ...string) []byte { - t.Helper() - - args = append([]string{"--socket=" + filepath.Join(h.testerDir, "sock")}, args...) - - cmd := exec.Command(h.cli, args...) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatal(err) - } - - return out -} - -// makeTestNode creates a userspace tailscaled running in netstack mode that -// enables us to make connections to and from the tailscale network being -// tested. This mutates the Harness to allow tests to dial into the tailscale -// network as well as control the tester's tailscaled. -func (h *Harness) makeTestNode(t *testing.T, controlURL string) { - dir := t.TempDir() - h.testerDir = dir - - port, err := getProbablyFreePortNumber() - if err != nil { - t.Fatalf("can't get free port: %v", err) - } - - cmd := exec.Command( - h.daemon, - "--tun=userspace-networking", - "--state="+filepath.Join(dir, "state.json"), - "--socket="+filepath.Join(dir, "sock"), - fmt.Sprintf("--socks5-server=localhost:%d", port), - ) - - cmd.Env = append( - os.Environ(), - "NOTIFY_SOCKET="+filepath.Join(dir, "notify_socket"), - "TS_LOG_TARGET="+h.loginServerURL, - ) - - err = cmd.Start() - if err != nil { - t.Fatalf("can't start tailscaled: %v", err) - } - - t.Cleanup(func() { - cmd.Process.Kill() - }) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - ticker := time.NewTicker(100 * time.Millisecond) - -outer: - for { - select { - case <-ctx.Done(): - t.Fatal("timed out waiting for tailscaled to come up") - return - case <-ticker.C: - conn, err := net.Dial("unix", filepath.Join(dir, "sock")) - if err != nil { - continue - } - - conn.Close() - break outer - } - } - - run(t, dir, h.cli, - "--socket="+filepath.Join(dir, "sock"), - "up", - "--login-server="+controlURL, - "--hostname=tester", - ) - - dialer, err := proxy.SOCKS5("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprint(port)), nil, &net.Dialer{}) - if err != nil { - t.Fatalf("can't make netstack proxy dialer: %v", err) - } - h.testerDialer = dialer - h.testerV4 = bytes2Netaddr(h.Tailscale(t, "ip", "-4")) -} - -func bytes2Netaddr(inp []byte) netip.Addr { - return netip.MustParseAddr(string(bytes.TrimSpace(inp))) -} diff --git a/tstest/integration/vms/nixos_test.go b/tstest/integration/vms/nixos_test.go deleted file mode 100644 index c2998ff3c087c..0000000000000 --- a/tstest/integration/vms/nixos_test.go +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && !plan9 - -package vms - -import ( - "flag" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "text/template" - - "tailscale.com/types/logger" -) - -var ( - verboseNixOutput = flag.Bool("verbose-nix-output", false, "if set, use verbose nix output (lots of noise)") -) - -/* - NOTE(Xe): Okay, so, at a high level testing NixOS is a lot different than - other distros due to NixOS' determinism. Normally NixOS wants packages to - be defined in either an overlay, a custom packageOverrides or even - yolo-inline as a part of the system configuration. This is going to have - us take a different approach compared to other distributions. The overall - plan here is as following: - - 1. make the binaries as normal - 2. template in their paths as raw strings to the nixos system module - 3. run `nixos-generators -f qcow -o $CACHE_DIR/tailscale/nixos/version -c generated-config.nix` - 4. pass that to the steps that make the virtual machine - - It doesn't really make sense for us to use a premade virtual machine image - for this as that will make it harder to deterministically create the image. -*/ - -const nixosConfigTemplate = ` -# NOTE(Xe): This template is going to be heavily commented. - -# All NixOS modules are functions. Here is the function prelude for this NixOS -# module that defines the system. It is a function that takes in an attribute -# set (effectively a map[string]nix.Value) and destructures it to some variables: -{ - # other NixOS settings as defined in other modules - config, - - # nixpkgs, which is basically the standard library of NixOS - pkgs, - - # the path to some system-scoped NixOS modules that aren't imported by default - modulesPath, - - # the rest of the arguments don't matter - ... -}: - -# Nix's syntax was inspired by Haskell and other functional languages, so the -# let .. in pattern is used to create scoped variables: -let - # Define the package (derivation) for Tailscale based on the binaries we - # just built for this test: - testTailscale = pkgs.stdenv.mkDerivation { - # The name of the package. This usually includes a version however it - # doesn't matter here. - name = "tailscale-test"; - - # The path on disk to the "source code" of the package, in this case it is - # the path to the binaries that are built. This needs to be the raw - # unquoted slash-separated path, not a string containing the path because Nix - # has a special path type. - src = {{.BinPath}}; - - # We only need to worry about the install phase because we've already - # built the binaries. - phases = "installPhase"; - - # We need to wrap tailscaled such that it has iptables in its $PATH. - nativeBuildInputs = [ pkgs.makeWrapper ]; - - # The install instructions for this package ('' ''defines a multi-line string). - # The with statement lets us bring in values into scope as if they were - # defined in the current scope. - installPhase = with pkgs; '' - # This is bash. - - # Make the output folders for the package (systemd unit and binary folders). - mkdir -p $out/bin - - # Install tailscale{,d} - cp $src/tailscale $out/bin/tailscale - cp $src/tailscaled $out/bin/tailscaled - - # Wrap tailscaled with the ip and iptables commands. - wrapProgram $out/bin/tailscaled --prefix PATH : ${ - lib.makeBinPath [ iproute iptables ] - } - - # Install systemd unit. - cp $src/systemd/tailscaled.service . - sed -i -e "s#/usr/sbin#$out/bin#" -e "/^EnvironmentFile/d" ./tailscaled.service - install -D -m0444 -t $out/lib/systemd/system ./tailscaled.service - ''; - }; -in { - # This is a QEMU VM. This module has a lot of common qemu VM settings so you - # don't have to set them manually. - imports = [ (modulesPath + "/profiles/qemu-guest.nix") ]; - - # We need virtio support to boot. - boot.initrd.availableKernelModules = - [ "ata_piix" "uhci_hcd" "virtio_pci" "sr_mod" "virtio_blk" ]; - boot.initrd.kernelModules = [ ]; - boot.kernelModules = [ ]; - boot.extraModulePackages = [ ]; - - # Curl is needed for one of the steps in cloud-final - systemd.services.cloud-final.path = with pkgs; [ curl ]; - - # Curl is needed for one of the integration tests - environment.systemPackages = with pkgs; [ curl nix bash squid openssl daemonize ]; - - # yolo, this vm can sudo freely. - security.sudo.wheelNeedsPassword = false; - - # Enable cloud-init so we can set VM hostnames and the like the same as other - # distros. This will also take care of SSH keys. It's pretty handy. - services.cloud-init = { - enable = true; - ext4.enable = true; - }; - - # We want sshd running. - services.openssh.enable = true; - - # Tailscale settings: - services.tailscale = { - # We want Tailscale to start at boot. - enable = true; - - # Use the Tailscale package we just assembled. - package = testTailscale; - }; - - # Override TS_LOG_TARGET to our private logcatcher. - systemd.services.tailscaled.environment."TS_LOG_TARGET" = "{{.LogTarget}}"; -}` - -func (h *Harness) copyUnit(t *testing.T) { - t.Helper() - - data, err := os.ReadFile("../../../cmd/tailscaled/tailscaled.service") - if err != nil { - t.Fatal(err) - } - os.MkdirAll(filepath.Join(h.binaryDir, "systemd"), 0755) - err = os.WriteFile(filepath.Join(h.binaryDir, "systemd", "tailscaled.service"), data, 0666) - if err != nil { - t.Fatal(err) - } -} - -func (h *Harness) makeNixOSImage(t *testing.T, d Distro, cdir string) string { - if d.Name == "nixos-unstable" { - t.Skip("https://github.com/NixOS/nixpkgs/issues/131098") - } - - h.copyUnit(t) - dir := t.TempDir() - fname := filepath.Join(dir, d.Name+".nix") - fout, err := os.Create(fname) - if err != nil { - t.Fatal(err) - } - - tmpl := template.Must(template.New("base.nix").Parse(nixosConfigTemplate)) - err = tmpl.Execute(fout, struct { - BinPath string - LogTarget string - }{ - BinPath: h.binaryDir, - LogTarget: h.loginServerURL, - }) - if err != nil { - t.Fatal(err) - } - - err = fout.Close() - if err != nil { - t.Fatal(err) - } - - outpath := filepath.Join(cdir, "nixos") - os.MkdirAll(outpath, 0755) - - t.Cleanup(func() { - os.RemoveAll(filepath.Join(outpath, d.Name)) // makes the disk image a candidate for GC - }) - - cmd := exec.Command("nixos-generate", "-f", "qcow", "-o", filepath.Join(outpath, d.Name), "-c", fname) - if *verboseNixOutput { - cmd.Stdout = logger.FuncWriter(t.Logf) - cmd.Stderr = logger.FuncWriter(t.Logf) - } else { - fname := fmt.Sprintf("nix-build-%s-%s", os.Getenv("GITHUB_RUN_NUMBER"), strings.Replace(t.Name(), "/", "-", -1)) - t.Logf("writing nix logs to %s", fname) - fout, err := os.Create(fname) - if err != nil { - t.Fatalf("can't make log file for nix build: %v", err) - } - cmd.Stdout = fout - cmd.Stderr = fout - defer fout.Close() - } - cmd.Env = append(os.Environ(), "NIX_PATH=nixpkgs="+d.URL) - cmd.Dir = outpath - t.Logf("running %s %#v", "nixos-generate", cmd.Args) - if err := cmd.Run(); err != nil { - t.Fatalf("error while making NixOS image for %s: %v", d.Name, err) - } - - if !*verboseNixOutput { - t.Log("done") - } - - return filepath.Join(outpath, d.Name, "nixos.qcow2") -} diff --git a/tstest/integration/vms/opensuse_leap_15_1_test.go b/tstest/integration/vms/opensuse_leap_15_1_test.go deleted file mode 100644 index 7d3ac579ec6d1..0000000000000 --- a/tstest/integration/vms/opensuse_leap_15_1_test.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && !plan9 - -package vms - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/google/uuid" -) - -/* - The images that we use for OpenSUSE Leap 15.1 have an issue that makes the - nocloud backend[1] for cloud-init just not work. As a distro-specific - workaround, we're gonna pretend to be OpenStack. - - TODO(Xe): delete once we no longer need to support OpenSUSE Leap 15.1. - - [1]: https://cloudinit.readthedocs.io/en/latest/topics/datasources/nocloud.html -*/ - -type openSUSELeap151MetaData struct { - Zone string `json:"availability_zone"` // nova - Hostname string `json:"hostname"` // opensuse-leap-15-1 - LaunchIndex string `json:"launch_index"` // 0 - Meta openSUSELeap151MetaDataMeta `json:"meta"` // some openstack metadata we don't need to care about - Name string `json:"name"` // opensuse-leap-15-1 - UUID string `json:"uuid"` // e9c664cd-b116-433b-aa61-7ff420163dcd -} - -type openSUSELeap151MetaDataMeta struct { - Role string `json:"role"` // server - DSMode string `json:"dsmode"` // local - Essential string `json:"essential"` // essential -} - -func hackOpenSUSE151UserData(t *testing.T, d Distro, dir string) bool { - if d.Name != "opensuse-leap-15-1" { - return false - } - - t.Log("doing OpenSUSE Leap 15.1 hack") - osDir := filepath.Join(dir, "openstack", "latest") - err := os.MkdirAll(osDir, 0755) - if err != nil { - t.Fatalf("can't make metadata home: %v", err) - } - - metadata, err := json.Marshal(openSUSELeap151MetaData{ - Zone: "nova", - Hostname: d.Name, - LaunchIndex: "0", - Meta: openSUSELeap151MetaDataMeta{ - Role: "server", - DSMode: "local", - Essential: "false", - }, - Name: d.Name, - UUID: uuid.New().String(), - }) - if err != nil { - t.Fatalf("can't encode metadata: %v", err) - } - err = os.WriteFile(filepath.Join(osDir, "meta_data.json"), metadata, 0666) - if err != nil { - t.Fatalf("can't write to meta_data.json: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, "user-data")) - if err != nil { - t.Fatalf("can't read user_data: %v", err) - } - - err = os.WriteFile(filepath.Join(osDir, "user_data"), data, 0666) - if err != nil { - t.Fatalf("can't create output user_data: %v", err) - } - - return true -} diff --git a/tstest/integration/vms/regex_flag.go b/tstest/integration/vms/regex_flag.go deleted file mode 100644 index 02e399ecdfaad..0000000000000 --- a/tstest/integration/vms/regex_flag.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vms - -import "regexp" - -type regexValue struct { - r *regexp.Regexp -} - -func (r *regexValue) String() string { - if r.r == nil { - return "" - } - - return r.r.String() -} - -func (r *regexValue) Set(val string) error { - if rex, err := regexp.Compile(val); err != nil { - return err - } else { - r.r = rex - return nil - } -} - -func (r regexValue) Unwrap() *regexp.Regexp { return r.r } diff --git a/tstest/integration/vms/regex_flag_test.go b/tstest/integration/vms/regex_flag_test.go deleted file mode 100644 index 0f4e5f8f7bdec..0000000000000 --- a/tstest/integration/vms/regex_flag_test.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vms - -import ( - "flag" - "testing" -) - -func TestRegexFlag(t *testing.T) { - var v regexValue - fs := flag.NewFlagSet(t.Name(), flag.PanicOnError) - fs.Var(&v, "regex", "regex to parse") - - const want = `.*` - fs.Parse([]string{"-regex", want}) - if v.Unwrap().String() != want { - t.Fatalf("got wrong regex: %q, wanted: %q", v.Unwrap().String(), want) - } -} diff --git a/tstest/integration/vms/runner.nix b/tstest/integration/vms/runner.nix deleted file mode 100644 index ac569cf658cb1..0000000000000 --- a/tstest/integration/vms/runner.nix +++ /dev/null @@ -1,89 +0,0 @@ -# This is a NixOS module to allow a machine to act as an integration test -# runner. This is used for the end-to-end VM test suite. - -{ lib, config, pkgs, ... }: - -{ - # The GitHub Actions self-hosted runner service. - services.github-runner = { - enable = true; - url = "https://github.com/tailscale/tailscale"; - replace = true; - extraLabels = [ "vm_integration_test" ]; - - # Justifications for the packages: - extraPackages = with pkgs; [ - # The test suite is written in Go. - go - - # This contains genisoimage, which is needed to create cloud-init - # seeds. - cdrkit - - # This package is the virtual machine hypervisor we use in tests. - qemu - - # This package contains tools like `ssh-keygen`. - openssh - - # The C compiler so cgo builds work. - gcc - - # The package manager Nix, just in case. - nix - - # Used to generate a NixOS image for testing. - nixos-generators - - # Used to extract things. - gnutar - - # Used to decompress things. - lzma - ]; - - # Customize this to include your GitHub username so we can track - # who is running which node. - name = "YOUR-GITHUB-USERNAME-tstest-integration-vms"; - - # Replace this with the path to the GitHub Actions runner token on - # your disk. - tokenFile = "/run/decrypted/ts-oss-ghaction-token"; - }; - - # A user account so there is a home directory and so they have kvm - # access. Please don't change this account name. - users.users.ghrunner = { - createHome = true; - isSystemUser = true; - extraGroups = [ "kvm" ]; - }; - - # The default github-runner service sets a lot of isolation features - # that attempt to limit the damage that malicious code can use. - # Unfortunately we rely on some "dangerous" features to do these tests, - # so this shim will peel some of them away. - systemd.services.github-runner = { - serviceConfig = { - # We need access to /dev to poke /dev/kvm. - PrivateDevices = lib.mkForce false; - - # /dev/kvm is how qemu creates a virtual machine with KVM. - DeviceAllow = lib.mkForce [ "/dev/kvm" ]; - - # Ensure the service has KVM permissions with the `kvm` group. - ExtraGroups = [ "kvm" ]; - - # The service runs as a dynamic user by default. This makes it hard - # to persistently store things in /var/lib/ghrunner. This line - # disables the dynamic user feature. - DynamicUser = lib.mkForce false; - - # Run this service as our ghrunner user. - User = "ghrunner"; - - # We need access to /var/lib/ghrunner to store VM images. - ProtectSystem = lib.mkForce null; - }; - }; -} diff --git a/tstest/integration/vms/squid.conf b/tstest/integration/vms/squid.conf deleted file mode 100644 index 29d32bd6d8606..0000000000000 --- a/tstest/integration/vms/squid.conf +++ /dev/null @@ -1,39 +0,0 @@ -pid_filename /run/squid.pid -cache_dir ufs /tmp/squid/cache 500 16 256 -maximum_object_size 4096 KB -coredump_dir /tmp/squid/core -visible_hostname localhost -cache_access_log /tmp/squid/access.log -cache_log /tmp/squid/cache.log - -# Access Control lists -acl localhost src 127.0.0.1 ::1 -acl manager proto cache_object -acl SSL_ports port 443 -acl Safe_ports port 80 # http -acl Safe_ports port 21 # ftp -acl Safe_ports port 443 # https -acl Safe_ports port 70 # gopher -acl Safe_ports port 210 # wais -acl Safe_ports port 1025-65535 # unregistered ports -acl Safe_ports port 280 # http-mgmt -acl Safe_ports port 488 # gss-http -acl Safe_ports port 591 # filemaker -acl Safe_ports port 777 # multiling http -acl CONNECT method CONNECT - -http_access allow localhost -http_access deny all -forwarded_for on - -# sslcrtd_program /nix/store/nqlqk1f6qlxdirlrl1aijgb6vbzxs0gs-squid-4.17/libexec/security_file_certgen -s /tmp/squid/ssl_db -M 4MB -sslcrtd_children 5 - -http_port 127.0.0.1:3128 \ - ssl-bump \ - generate-host-certificates=on \ - dynamic_cert_mem_cache_size=4MB \ - cert=/tmp/squid/myca-mitm.pem - -ssl_bump stare all # mimic the Client Hello, drop unsupported extensions -ssl_bump bump all # terminate and establish new TLS connection \ No newline at end of file diff --git a/tstest/integration/vms/top_level_test.go b/tstest/integration/vms/top_level_test.go deleted file mode 100644 index c107fd89cc886..0000000000000 --- a/tstest/integration/vms/top_level_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && !plan9 - -package vms - -import ( - "context" - "testing" - "time" - - "github.com/pkg/sftp" - expect "github.com/tailscale/goexpect" -) - -func TestRunUbuntu1804(t *testing.T) { - testOneDistribution(t, 0, Distros[0]) -} - -func TestRunUbuntu2004(t *testing.T) { - testOneDistribution(t, 1, Distros[1]) -} - -func TestRunNixos2111(t *testing.T) { - t.Parallel() - testOneDistribution(t, 2, Distros[2]) -} - -// TestMITMProxy is a smoke test for derphttp through a MITM proxy. -// Encountering such proxies is unfortunately commonplace in more -// traditional enterprise networks. -// -// We invoke tailscale netcheck because the networking check is done -// by tailscale rather than tailscaled, making it easier to configure -// the proxy. -// -// To provide the actual MITM server, we use squid. -func TestMITMProxy(t *testing.T) { - t.Parallel() - setupTests(t) - distro := Distros[2] // nixos-21.11 - - if distroRex.Unwrap().MatchString(distro.Name) { - t.Logf("%s matches %s", distro.Name, distroRex.Unwrap()) - } else { - t.Skip("regex not matched") - } - - ctx, done := context.WithCancel(context.Background()) - t.Cleanup(done) - - h := newHarness(t) - - err := ramsem.sem.Acquire(ctx, int64(distro.MemoryMegs)) - if err != nil { - t.Fatalf("can't acquire ram semaphore: %v", err) - } - t.Cleanup(func() { ramsem.sem.Release(int64(distro.MemoryMegs)) }) - - vm := h.mkVM(t, 2, distro, h.pubKey, h.loginServerURL, t.TempDir()) - vm.waitStartup(t) - - ipm := h.waitForIPMap(t, vm, distro) - _, cli := h.setupSSHShell(t, distro, ipm) - - sftpCli, err := sftp.NewClient(cli) - if err != nil { - t.Fatalf("can't connect over sftp to copy binaries: %v", err) - } - defer sftpCli.Close() - - // Initialize a squid installation. - // - // A few things of note here: - // - The first thing we do is append the nsslcrtd_program stanza to the config. - // This must be an absolute path and is based on the nix path of the squid derivation, - // so we compute and write it out here. - // - Squid expects a pre-initialized directory layout, so we create that in /tmp/squid then - // invoke squid with -z to have it fill in the rest. - // - Doing a meddler-in-the-middle attack requires using some fake keys, so we create - // them using openssl and then use the security_file_certgen tool to setup squids' ssl_db. - // - There were some perms issues, so i yeeted 0777. Its only a test anyway - copyFile(t, sftpCli, "squid.conf", "/tmp/squid.conf") - runTestCommands(t, 30*time.Second, cli, []expect.Batcher{ - &expect.BSnd{S: "echo -e \"\\nsslcrtd_program $(nix eval --raw nixpkgs.squid)/libexec/security_file_certgen -s /tmp/squid/ssl_db -M 4MB\\n\" >> /tmp/squid.conf\n"}, - &expect.BSnd{S: "mkdir -p /tmp/squid/{cache,core}\n"}, - &expect.BSnd{S: "openssl req -batch -new -newkey rsa:4096 -sha256 -days 3650 -nodes -x509 -keyout /tmp/squid/myca-mitm.pem -out /tmp/squid/myca-mitm.pem\n"}, - &expect.BExp{R: `writing new private key to '/tmp/squid/myca-mitm.pem'`}, - &expect.BSnd{S: "$(nix eval --raw nixpkgs.squid)/libexec/security_file_certgen -c -s /tmp/squid/ssl_db -M 4MB\n"}, - &expect.BExp{R: `Done`}, - &expect.BSnd{S: "sudo chmod -R 0777 /tmp/squid\n"}, - &expect.BSnd{S: "squid --foreground -YCs -z -f /tmp/squid.conf\n"}, - &expect.BSnd{S: "echo Success.\n"}, - &expect.BExp{R: `Success.`}, - }) - - // Start the squid server. - runTestCommands(t, 10*time.Second, cli, []expect.Batcher{ - &expect.BSnd{S: "daemonize -v -c /tmp/squid $(nix eval --raw nixpkgs.squid)/bin/squid --foreground -YCs -f /tmp/squid.conf\n"}, // start daemon - // NOTE(tom): Writing to /dev/tcp/* is bash magic, not a file. This - // eldritchian incantation lets us wait till squid is up. - &expect.BSnd{S: "while ! timeout 5 bash -c 'echo > /dev/tcp/localhost/3128'; do sleep 1; done\n"}, - &expect.BSnd{S: "echo Success.\n"}, - &expect.BExp{R: `Success.`}, - }) - - // Uncomment to help debugging this test if it fails. - // - // runTestCommands(t, 30 * time.Second, cli, []expect.Batcher{ - // &expect.BSnd{S: "sudo ifconfig\n"}, - // &expect.BSnd{S: "sudo ip link\n"}, - // &expect.BSnd{S: "sudo ip route\n"}, - // &expect.BSnd{S: "ps -aux\n"}, - // &expect.BSnd{S: "netstat -a\n"}, - // &expect.BSnd{S: "cat /tmp/squid/access.log && cat /tmp/squid/cache.log && cat /tmp/squid.conf && echo Success.\n"}, - // &expect.BExp{R: `Success.`}, - // }) - - runTestCommands(t, 30*time.Second, cli, []expect.Batcher{ - &expect.BSnd{S: "SSL_CERT_FILE=/tmp/squid/myca-mitm.pem HTTPS_PROXY=http://127.0.0.1:3128 tailscale netcheck\n"}, - &expect.BExp{R: `IPv4: yes`}, - }) -} diff --git a/tstest/integration/vms/udp_tester.go b/tstest/integration/vms/udp_tester.go deleted file mode 100644 index be44aa9636103..0000000000000 --- a/tstest/integration/vms/udp_tester.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ignore - -// Command udp_tester exists because all of these distros being tested don't -// have a consistent tool for doing UDP traffic. This is a very hacked up tool -// that does that UDP traffic so these tests can be done. -package main - -import ( - "flag" - "io" - "log" - "net" - "os" -) - -var ( - client = flag.String("client", "", "host:port to connect to for sending UDP") - server = flag.String("server", "", "host:port to bind to for receiving UDP") -) - -func main() { - flag.Parse() - - if *client == "" && *server == "" { - log.Fatal("specify -client or -server") - } - - if *client != "" { - conn, err := net.Dial("udp", *client) - if err != nil { - log.Fatalf("can't dial %s: %v", *client, err) - } - log.Printf("dialed to %s", conn.RemoteAddr()) - defer conn.Close() - - buf := make([]byte, 2048) - n, err := os.Stdin.Read(buf) - if err != nil && err != io.EOF { - log.Fatalf("can't read from stdin: %v", err) - } - - nn, err := conn.Write(buf[:n]) - if err != nil { - log.Fatalf("can't write to %s: %v", conn.RemoteAddr(), err) - } - - if n == nn { - return - } - - log.Fatalf("wanted to write %d bytes, wrote %d bytes", n, nn) - } - - if *server != "" { - addr, err := net.ResolveUDPAddr("udp", *server) - if err != nil { - log.Fatalf("can't resolve %s: %v", *server, err) - } - ln, err := net.ListenUDP("udp", addr) - if err != nil { - log.Fatalf("can't listen %s: %v", *server, err) - } - defer ln.Close() - - buf := make([]byte, 2048) - - n, _, err := ln.ReadFromUDP(buf) - if err != nil { - log.Fatal(err) - } - - os.Stdout.Write(buf[:n]) - } -} diff --git a/tstest/integration/vms/vm_setup_test.go b/tstest/integration/vms/vm_setup_test.go deleted file mode 100644 index 0c6901014bb74..0000000000000 --- a/tstest/integration/vms/vm_setup_test.go +++ /dev/null @@ -1,443 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && !plan9 - -package vms - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "testing" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/feature/s3/manager" - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/pkg/sftp" - "golang.org/x/crypto/ssh" - "tailscale.com/types/logger" -) - -type vmInstance struct { - d Distro - cmd *exec.Cmd - done chan struct{} - doneErr error // not written until done is closed -} - -func (vm *vmInstance) running() bool { - select { - case <-vm.done: - return false - default: - return true - } -} - -func (vm *vmInstance) waitStartup(t *testing.T) { - t.Helper() - for range 100 { - if vm.running() { - break - } - time.Sleep(100 * time.Millisecond) - } - if !vm.running() { - t.Fatal("vm not running") - } -} - -func (h *Harness) makeImage(t *testing.T, d Distro, cdir string) string { - if !strings.HasPrefix(d.Name, "nixos") { - t.Fatal("image generation for non-nixos is not implemented") - } - return h.makeNixOSImage(t, d, cdir) -} - -// mkVM makes a KVM-accelerated virtual machine and prepares it for introduction -// to the testcontrol server. The function it returns is for killing the virtual -// machine when it is time for it to die. -func (h *Harness) mkVM(t *testing.T, n int, d Distro, sshKey, hostURL, tdir string) *vmInstance { - t.Helper() - - cdir, err := os.UserCacheDir() - if err != nil { - t.Fatalf("can't find cache dir: %v", err) - } - cdir = filepath.Join(cdir, "tailscale", "vm-test") - os.MkdirAll(filepath.Join(cdir, "qcow2"), 0755) - - port, err := getProbablyFreePortNumber() - if err != nil { - t.Fatal(err) - } - - var qcowPath string - if d.HostGenerated { - qcowPath = h.makeImage(t, d, cdir) - } else { - qcowPath = fetchDistro(t, d) - } - - mkLayeredQcow(t, tdir, d, qcowPath) - mkSeed(t, d, sshKey, hostURL, tdir, port) - - driveArg := fmt.Sprintf("file=%s,if=virtio", filepath.Join(tdir, d.Name+".qcow2")) - - args := []string{ - "-machine", "q35,accel=kvm,usb=off,vmport=off,dump-guest-core=off", - "-netdev", fmt.Sprintf("user,hostfwd=::%d-:22,id=net0", port), - "-device", "virtio-net-pci,netdev=net0,id=net0,mac=8a:28:5c:30:1f:25", - "-m", fmt.Sprint(d.MemoryMegs), - "-cpu", "host", - "-smp", "4", - "-boot", "c", - "-drive", driveArg, - "-cdrom", filepath.Join(tdir, d.Name, "seed", "seed.iso"), - "-smbios", "type=1,serial=ds=nocloud;h=" + d.Name, - "-nographic", - } - - if *useVNC { - // test listening on VNC port - ln, err := net.Listen("tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(5900+n))) - if err != nil { - t.Fatalf("would not be able to listen on the VNC port for the VM: %v", err) - } - ln.Close() - args = append(args, "-vnc", fmt.Sprintf(":%d", n)) - } else { - args = append(args, "-display", "none") - } - - t.Logf("running: qemu-system-x86_64 %s", strings.Join(args, " ")) - - cmd := exec.Command("qemu-system-x86_64", args...) - cmd.Stdout = &qemuLog{f: t.Logf} - cmd.Stderr = &qemuLog{f: t.Logf} - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - - vm := &vmInstance{ - cmd: cmd, - d: d, - done: make(chan struct{}), - } - - go func() { - vm.doneErr = cmd.Wait() - close(vm.done) - }() - t.Cleanup(func() { - err := vm.cmd.Process.Kill() - if err != nil { - t.Logf("can't kill %s (%d): %v", d.Name, cmd.Process.Pid, err) - } - <-vm.done - }) - - return vm -} - -type qemuLog struct { - buf []byte - f logger.Logf -} - -func (w *qemuLog) Write(p []byte) (int, error) { - if !*verboseQemu { - return len(p), nil - } - w.buf = append(w.buf, p...) - if i := bytes.LastIndexByte(w.buf, '\n'); i > 0 { - j := i - if w.buf[j-1] == '\r' { - j-- - } - buf := ansiEscCodeRE.ReplaceAll(w.buf[:j], nil) - w.buf = w.buf[i+1:] - - w.f("qemu console: %q", buf) - } - return len(p), nil -} - -var ansiEscCodeRE = regexp.MustCompile("\x1b" + `\[[0-?]*[ -/]*[@-~]`) - -// fetchFromS3 fetches a distribution image from Amazon S3 or reports whether -// it is unable to. It can fail to fetch from S3 if there is either no AWS -// configuration (in ~/.aws/credentials) or if the `-no-s3` flag is passed. In -// that case the test will fall back to downloading distribution images from the -// public internet. -// -// Like fetching from HTTP, the test will fail if an error is encountered during -// the downloading process. -// -// This function writes the distribution image to fout. It is always closed. Do -// not expect fout to remain writable. -func fetchFromS3(t *testing.T, fout *os.File, d Distro) bool { - t.Helper() - - if *noS3 { - t.Log("you asked to not use S3, not using S3") - return false - } - - cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion("us-east-1")) - if err != nil { - t.Logf("can't load AWS credentials: %v", err) - return false - } - - dler := manager.NewDownloader(s3.NewFromConfig(cfg), func(d *manager.Downloader) { - d.PartSize = 64 * 1024 * 1024 // 64MB per part - }) - - t.Logf("fetching s3://%s/%s", bucketName, d.SHA256Sum) - - _, err = dler.Download(context.TODO(), fout, &s3.GetObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(d.SHA256Sum), - }) - if err != nil { - fout.Close() - t.Fatalf("can't get s3://%s/%s: %v", bucketName, d.SHA256Sum, err) - } - - err = fout.Close() - if err != nil { - t.Fatalf("can't close fout: %v", err) - } - - return true -} - -// fetchDistro fetches a distribution from the internet if it doesn't already exist locally. It -// also validates the sha256 sum from a known good hash. -func fetchDistro(t *testing.T, resultDistro Distro) string { - t.Helper() - - cdir, err := os.UserCacheDir() - if err != nil { - t.Fatalf("can't find cache dir: %v", err) - } - cdir = filepath.Join(cdir, "tailscale", "vm-test") - - qcowPath := filepath.Join(cdir, "qcow2", resultDistro.SHA256Sum) - - if _, err = os.Stat(qcowPath); err == nil { - hash := checkCachedImageHash(t, resultDistro, cdir) - if hash == resultDistro.SHA256Sum { - return qcowPath - } - t.Logf("hash for %s (%s) doesn't match expected %s, re-downloading", resultDistro.Name, qcowPath, resultDistro.SHA256Sum) - if err := os.Remove(qcowPath); err != nil { - t.Fatalf("can't delete wrong cached image: %v", err) - } - } - - t.Logf("downloading distro image %s to %s", resultDistro.URL, qcowPath) - if err := os.MkdirAll(filepath.Dir(qcowPath), 0777); err != nil { - t.Fatal(err) - } - fout, err := os.Create(qcowPath) - if err != nil { - t.Fatal(err) - } - - if !fetchFromS3(t, fout, resultDistro) { - resp, err := http.Get(resultDistro.URL) - if err != nil { - t.Fatalf("can't fetch qcow2 for %s (%s): %v", resultDistro.Name, resultDistro.URL, err) - } - - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - t.Fatalf("%s replied %s", resultDistro.URL, resp.Status) - } - - if n, err := io.Copy(fout, resp.Body); err != nil { - t.Fatalf("download of %s failed: %v", resultDistro.URL, err) - } else if n == 0 { - t.Fatalf("download of %s got zero-length file", resultDistro.URL) - } - - resp.Body.Close() - if err = fout.Close(); err != nil { - t.Fatalf("can't close fout: %v", err) - } - - hash := checkCachedImageHash(t, resultDistro, cdir) - - if hash != resultDistro.SHA256Sum { - t.Fatalf("hash mismatch for %s, want: %s, got: %s", resultDistro.URL, resultDistro.SHA256Sum, hash) - } - } - - return qcowPath -} - -func checkCachedImageHash(t *testing.T, d Distro, cacheDir string) string { - t.Helper() - - qcowPath := filepath.Join(cacheDir, "qcow2", d.SHA256Sum) - - fin, err := os.Open(qcowPath) - if err != nil { - t.Fatal(err) - } - defer fin.Close() - - hasher := sha256.New() - if _, err := io.Copy(hasher, fin); err != nil { - t.Fatal(err) - } - hash := hex.EncodeToString(hasher.Sum(nil)) - - if hash != d.SHA256Sum { - t.Fatalf("hash mismatch, got: %q, want: %q", hash, d.SHA256Sum) - } - return hash -} - -func (h *Harness) copyBinaries(t *testing.T, d Distro, conn *ssh.Client) { - if strings.HasPrefix(d.Name, "nixos") { - return - } - - cli, err := sftp.NewClient(conn) - if err != nil { - t.Fatalf("can't connect over sftp to copy binaries: %v", err) - } - - mkdir(t, cli, "/usr/bin") - mkdir(t, cli, "/usr/sbin") - mkdir(t, cli, "/etc/default") - mkdir(t, cli, "/var/lib/tailscale") - - copyFile(t, cli, h.daemon, "/usr/sbin/tailscaled") - copyFile(t, cli, h.cli, "/usr/bin/tailscale") - - // TODO(Xe): revisit this assumption before it breaks the test. - copyFile(t, cli, "../../../cmd/tailscaled/tailscaled.defaults", "/etc/default/tailscaled") - - switch d.InitSystem { - case "openrc": - mkdir(t, cli, "/etc/init.d") - copyFile(t, cli, "../../../cmd/tailscaled/tailscaled.openrc", "/etc/init.d/tailscaled") - case "systemd": - mkdir(t, cli, "/etc/systemd/system") - copyFile(t, cli, "../../../cmd/tailscaled/tailscaled.service", "/etc/systemd/system/tailscaled.service") - } - - fout, err := cli.OpenFile("/etc/default/tailscaled", os.O_WRONLY|os.O_APPEND) - if err != nil { - t.Fatalf("can't append to defaults for tailscaled: %v", err) - } - fmt.Fprintf(fout, "\n\nTS_LOG_TARGET=%s\n", h.loginServerURL) - fout.Close() - - t.Log("tailscale installed!") -} - -func mkdir(t *testing.T, cli *sftp.Client, name string) { - t.Helper() - - err := cli.MkdirAll(name) - if err != nil { - t.Fatalf("can't make %s: %v", name, err) - } -} - -func copyFile(t *testing.T, cli *sftp.Client, localSrc, remoteDest string) { - t.Helper() - - fin, err := os.Open(localSrc) - if err != nil { - t.Fatalf("can't open: %v", err) - } - defer fin.Close() - - fi, err := fin.Stat() - if err != nil { - t.Fatalf("can't stat: %v", err) - } - - fout, err := cli.Create(remoteDest) - if err != nil { - t.Fatalf("can't create output file: %v", err) - } - - err = fout.Chmod(fi.Mode()) - if err != nil { - fout.Close() - t.Fatalf("can't chmod fout: %v", err) - } - - n, err := io.Copy(fout, fin) - if err != nil { - fout.Close() - t.Fatalf("copy failed: %v", err) - } - - if fi.Size() != n { - t.Fatalf("incorrect number of bytes copied: wanted: %d, got: %d", fi.Size(), n) - } - - err = fout.Close() - if err != nil { - t.Fatalf("can't close fout on remote host: %v", err) - } -} - -const metaDataTemplate = `instance-id: {{.ID}} -local-hostname: {{.Hostname}}` - -const userDataTemplate = `#cloud-config -#vim:syntax=yaml - -cloud_config_modules: - - runcmd - -cloud_final_modules: - - [users-groups, always] - - [scripts-user, once-per-instance] - -users: - - name: root - ssh-authorized-keys: - - {{.SSHKey}} - - name: ts - plain_text_passwd: {{.Password}} - groups: [ wheel ] - sudo: [ "ALL=(ALL) NOPASSWD:ALL" ] - shell: /bin/sh - ssh-authorized-keys: - - {{.SSHKey}} - -write_files: - - path: /etc/cloud/cloud.cfg.d/80_disable_network_after_firstboot.cfg - content: | - # Disable network configuration after first boot - network: - config: disabled - -runcmd: -{{.InstallPre}} - - [ curl, "{{.HostURL}}/myip/{{.Port}}", "-H", "User-Agent: {{.Hostname}}" ] -` diff --git a/tstest/integration/vms/vms_steps_test.go b/tstest/integration/vms/vms_steps_test.go deleted file mode 100644 index 94e4114f01e78..0000000000000 --- a/tstest/integration/vms/vms_steps_test.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && !plan9 - -package vms - -import ( - "bytes" - "context" - "fmt" - "net" - "net/http" - "net/netip" - "strings" - "testing" - "time" - - "golang.org/x/crypto/ssh" -) - -func retry(t *testing.T, fn func() error) { - t.Helper() - const tries = 3 - var err error - for i := range tries { - err = fn() - if err != nil { - t.Logf("%dth invocation failed, trying again: %v", i, err) - time.Sleep(50 * time.Millisecond) - } - if err == nil { - return - } - } - t.Fatalf("tried %d times, got: %v", tries, err) -} - -func (h *Harness) testPing(t *testing.T, ipAddr netip.Addr, cli *ssh.Client) { - retry(t, func() error { - sess := getSession(t, cli) - cmd := fmt.Sprintf("tailscale ping --verbose %s", ipAddr) - outp, err := sess.CombinedOutput(cmd) - if err == nil && !bytes.Contains(outp, []byte("pong")) { - err = fmt.Errorf("%s: no pong", cmd) - } - if err != nil { - return fmt.Errorf("%s : %v, output: %s", cmd, err, outp) - } - t.Logf("%s", outp) - return nil - }) - - retry(t, func() error { - sess := getSession(t, cli) - - // NOTE(Xe): the ping command is inconsistent across distros. Joy. - cmd := fmt.Sprintf("sh -c 'ping -c 1 %[1]s || ping -6 -c 1 %[1]s || ping6 -c 1 %[1]s\n'", ipAddr) - t.Logf("running %q", cmd) - outp, err := sess.CombinedOutput(cmd) - if err == nil && !bytes.Contains(outp, []byte("bytes")) { - err = fmt.Errorf("%s: wanted output to contain %q, it did not", cmd, "bytes") - } - if err != nil { - err = fmt.Errorf("%s: %v, output: %s", cmd, err, outp) - } - return err - }) -} - -func getSession(t *testing.T, cli *ssh.Client) *ssh.Session { - sess, err := cli.NewSession() - if err != nil { - t.Fatal(err) - } - - t.Cleanup(func() { - sess.Close() - }) - - return sess -} - -func (h *Harness) testOutgoingTCP(t *testing.T, ipAddr netip.Addr, cli *ssh.Client) { - const sendmsg = "this is a message that curl won't print" - ctx, cancel := context.WithCancel(context.Background()) - s := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Logf("http connection from %s", r.RemoteAddr) - cancel() - fmt.Fprintln(w, sendmsg) - }), - } - ln, err := net.Listen("tcp", net.JoinHostPort("::", "0")) - if err != nil { - t.Fatalf("can't make HTTP server: %v", err) - } - _, port, _ := net.SplitHostPort(ln.Addr().String()) - go s.Serve(ln) - - // sess := getSession(t, cli) - // sess.Stderr = logger.FuncWriter(t.Logf) - // sess.Stdout = logger.FuncWriter(t.Logf) - // sess.Run("ip route show table all") - - // sess = getSession(t, cli) - // sess.Stderr = logger.FuncWriter(t.Logf) - // sess.Stdout = logger.FuncWriter(t.Logf) - // sess.Run("sysctl -a") - - retry(t, func() error { - var err error - sess := getSession(t, cli) - v6Arg := "" - if ipAddr.Is6() { - v6Arg = "-6 -g" - } - cmd := fmt.Sprintf("curl -v %s -s -f http://%s\n", v6Arg, net.JoinHostPort(ipAddr.String(), port)) - t.Logf("running: %s", cmd) - outp, err := sess.CombinedOutput(cmd) - if msg := string(bytes.TrimSpace(outp)); err == nil && !strings.Contains(msg, sendmsg) { - err = fmt.Errorf("wanted %q, got: %q", sendmsg, msg) - } - if err != nil { - err = fmt.Errorf("%v, output: %s", err, outp) - } - return err - }) - - <-ctx.Done() -} diff --git a/tstest/integration/vms/vms_test.go b/tstest/integration/vms/vms_test.go deleted file mode 100644 index 6d73a3f78d27e..0000000000000 --- a/tstest/integration/vms/vms_test.go +++ /dev/null @@ -1,709 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows && !plan9 - -package vms - -import ( - "bytes" - "context" - "flag" - "fmt" - "net" - "net/netip" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "testing" - "text/template" - "time" - - "github.com/pkg/sftp" - expect "github.com/tailscale/goexpect" - "golang.org/x/crypto/ssh" - "golang.org/x/sync/semaphore" - "tailscale.com/tstest" - "tailscale.com/tstest/integration" - "tailscale.com/types/logger" -) - -const ( - securePassword = "hunter2" - bucketName = "tailscale-integration-vm-images" -) - -var ( - runVMTests = flag.Bool("run-vm-tests", false, "if set, run expensive VM based integration tests") - noS3 = flag.Bool("no-s3", false, "if set, always download images from the public internet (risks breaking)") - vmRamLimit = flag.Int("ram-limit", 4096, "the maximum number of megabytes of ram that can be used for VMs, must be greater than or equal to 1024") - useVNC = flag.Bool("use-vnc", false, "if set, display guest vms over VNC") - verboseLogcatcher = flag.Bool("verbose-logcatcher", true, "if set, print logcatcher to t.Logf") - verboseQemu = flag.Bool("verbose-qemu", true, "if set, print qemu console to t.Logf") - distroRex = func() *regexValue { - result := ®exValue{r: regexp.MustCompile(`.*`)} - flag.Var(result, "distro-regex", "The regex that matches what distros should be run") - return result - }() -) - -func TestMain(m *testing.M) { - flag.Parse() - v := m.Run() - integration.CleanupBinaries() - os.Exit(v) -} - -func TestDownloadImages(t *testing.T) { - if !*runVMTests { - t.Skip("not running integration tests (need --run-vm-tests)") - } - - for _, d := range Distros { - distro := d - t.Run(distro.Name, func(t *testing.T) { - t.Parallel() - if !distroRex.Unwrap().MatchString(distro.Name) { - t.Skipf("distro name %q doesn't match regex: %s", distro.Name, distroRex) - } - if strings.HasPrefix(distro.Name, "nixos") { - t.Skip("NixOS is built on the fly, no need to download it") - } - - fetchDistro(t, distro) - }) - } -} - -// run runs a command or fails the test. -func run(t *testing.T, dir, prog string, args ...string) { - t.Helper() - t.Logf("running: %s %s", prog, strings.Join(args, " ")) - tstest.FixLogs(t) - - cmd := exec.Command(prog, args...) - cmd.Stdout = logger.FuncWriter(t.Logf) - cmd.Stderr = logger.FuncWriter(t.Logf) - cmd.Dir = dir - if err := cmd.Run(); err != nil { - t.Fatal(err) - } -} - -// mkLayeredQcow makes a layered qcow image that allows us to keep the upstream -// VM images pristine and only do our changes on an overlay. -func mkLayeredQcow(t *testing.T, tdir string, d Distro, qcowBase string) { - t.Helper() - - run(t, tdir, "qemu-img", "create", - "-f", "qcow2", - "-b", qcowBase, - "-F", "qcow2", - filepath.Join(tdir, d.Name+".qcow2"), - ) -} - -var ( - metaDataTempl = template.Must(template.New("meta-data.yaml").Parse(metaDataTemplate)) - userDataTempl = template.Must(template.New("user-data.yaml").Parse(userDataTemplate)) -) - -// mkSeed makes the cloud-init seed ISO that is used to configure a VM with -// tailscale. -func mkSeed(t *testing.T, d Distro, sshKey, hostURL, tdir string, port int) { - t.Helper() - - dir := filepath.Join(tdir, d.Name, "seed") - os.MkdirAll(dir, 0700) - - // make meta-data - { - fout, err := os.Create(filepath.Join(dir, "meta-data")) - if err != nil { - t.Fatal(err) - } - - err = metaDataTempl.Execute(fout, struct { - ID string - Hostname string - }{ - ID: "31337", - Hostname: d.Name, - }) - if err != nil { - t.Fatal(err) - } - - err = fout.Close() - if err != nil { - t.Fatal(err) - } - } - - // make user-data - { - fout, err := os.Create(filepath.Join(dir, "user-data")) - if err != nil { - t.Fatal(err) - } - - err = userDataTempl.Execute(fout, struct { - SSHKey string - HostURL string - Hostname string - Port int - InstallPre string - Password string - }{ - SSHKey: strings.TrimSpace(sshKey), - HostURL: hostURL, - Hostname: d.Name, - Port: port, - InstallPre: d.InstallPre(), - Password: securePassword, - }) - if err != nil { - t.Fatal(err) - } - - err = fout.Close() - if err != nil { - t.Fatal(err) - } - } - - args := []string{ - "-output", filepath.Join(dir, "seed.iso"), - "-volid", "cidata", "-joliet", "-rock", - filepath.Join(dir, "meta-data"), - filepath.Join(dir, "user-data"), - } - - if hackOpenSUSE151UserData(t, d, dir) { - args = append(args, filepath.Join(dir, "openstack")) - } - - run(t, tdir, "genisoimage", args...) -} - -// ipMapping maps a hostname, SSH port and SSH IP together -type ipMapping struct { - name string - port int - ip string -} - -// getProbablyFreePortNumber does what it says on the tin, but as a side effect -// it is a kind of racy function. Do not use this carelessly. -// -// This is racy because it does not "lock" the port number with the OS. The -// "random" port number that is returned here is most likely free to use, however -// it is difficult to be 100% sure. This function should be used with care. It -// will probably do what you want, but it is very easy to hold this wrong. -func getProbablyFreePortNumber() (int, error) { - l, err := net.Listen("tcp", ":0") - if err != nil { - return 0, err - } - - defer l.Close() - - _, port, err := net.SplitHostPort(l.Addr().String()) - if err != nil { - return 0, err - } - - portNum, err := strconv.Atoi(port) - if err != nil { - return 0, err - } - - return portNum, nil -} - -func setupTests(t *testing.T) { - ramsem.once.Do(func() { - ramsem.sem = semaphore.NewWeighted(int64(*vmRamLimit)) - }) - - if !*runVMTests { - t.Skip("not running integration tests (need --run-vm-tests)") - } - - os.Setenv("CGO_ENABLED", "0") - - if _, err := exec.LookPath("qemu-system-x86_64"); err != nil { - t.Logf("hint: nix-shell -p go -p qemu -p cdrkit --run 'go test --v --timeout=60m --run-vm-tests'") - t.Fatalf("missing dependency: %v", err) - } - - if _, err := exec.LookPath("genisoimage"); err != nil { - t.Logf("hint: nix-shell -p go -p qemu -p cdrkit --run 'go test --v --timeout=60m --run-vm-tests'") - t.Fatalf("missing dependency: %v", err) - } -} - -var ramsem struct { - once sync.Once - sem *semaphore.Weighted -} - -func testOneDistribution(t *testing.T, n int, distro Distro) { - setupTests(t) - - if distroRex.Unwrap().MatchString(distro.Name) { - t.Logf("%s matches %s", distro.Name, distroRex.Unwrap()) - } else { - t.Skip("regex not matched") - } - - ctx, done := context.WithCancel(context.Background()) - t.Cleanup(done) - - h := newHarness(t) - dir := t.TempDir() - - err := ramsem.sem.Acquire(ctx, int64(distro.MemoryMegs)) - if err != nil { - t.Fatalf("can't acquire ram semaphore: %v", err) - } - t.Cleanup(func() { ramsem.sem.Release(int64(distro.MemoryMegs)) }) - - vm := h.mkVM(t, n, distro, h.pubKey, h.loginServerURL, dir) - vm.waitStartup(t) - - h.testDistro(t, distro, h.waitForIPMap(t, vm, distro)) -} - -func (h *Harness) waitForIPMap(t *testing.T, vm *vmInstance, distro Distro) ipMapping { - t.Helper() - var ipm ipMapping - - waiter := time.NewTicker(time.Second) - defer waiter.Stop() - for { - var ok bool - - h.ipMu.Lock() - ipm, ok = h.ipMap[distro.Name] - h.ipMu.Unlock() - - if ok { - break - } - if !vm.running() { - t.Fatal("vm not running") - } - <-waiter.C - } - return ipm -} - -func (h *Harness) setupSSHShell(t *testing.T, d Distro, ipm ipMapping) (*ssh.ClientConfig, *ssh.Client) { - signer := h.signer - - t.Helper() - port := ipm.port - hostport := fmt.Sprintf("127.0.0.1:%d", port) - ccfg := &ssh.ClientConfig{ - User: "root", - Auth: []ssh.AuthMethod{ssh.PublicKeys(signer), ssh.Password(securePassword)}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - } - - // NOTE(Xe): This deadline loop helps to make things a bit faster, centos - // sometimes is slow at starting its sshd and will sometimes randomly kill - // SSH sessions on transition to multi-user.target. I don't know why they - // don't use socket activation. - const maxRetries = 5 - var working bool - for range maxRetries { - cli, err := ssh.Dial("tcp", hostport, ccfg) - if err == nil { - working = true - cli.Close() - break - } - - time.Sleep(10 * time.Second) - } - - if !working { - t.Fatalf("can't connect to %s, tried %d times", hostport, maxRetries) - } - - t.Logf("about to ssh into 127.0.0.1:%d", port) - cli, err := ssh.Dial("tcp", hostport, ccfg) - if err != nil { - t.Fatal(err) - } - h.copyBinaries(t, d, cli) - - return ccfg, cli -} - -func (h *Harness) testDistro(t *testing.T, d Distro, ipm ipMapping) { - loginServer := h.loginServerURL - ccfg, cli := h.setupSSHShell(t, d, ipm) - - timeout := 30 * time.Second - - t.Run("start-tailscale", func(t *testing.T) { - var batch = []expect.Batcher{ - &expect.BExp{R: `(\#)`}, - } - - switch d.InitSystem { - case "openrc": - // NOTE(Xe): this is a sin, however openrc doesn't really have the concept - // of service readiness. If this sleep is removed then tailscale will not be - // ready once the `tailscale up` command is sent. This is not ideal, but I - // am not really sure there is a good way around this without a delay of - // some kind. - batch = append(batch, &expect.BSnd{S: "rc-service tailscaled start && sleep 2\n"}) - case "systemd": - batch = append(batch, &expect.BSnd{S: "systemctl start tailscaled.service\n"}) - } - - batch = append(batch, &expect.BExp{R: `(\#)`}) - - runTestCommands(t, timeout, cli, batch) - }) - - t.Run("login", func(t *testing.T) { - runTestCommands(t, timeout, cli, []expect.Batcher{ - &expect.BSnd{S: fmt.Sprintf("tailscale up --login-server=%s\n", loginServer)}, - &expect.BSnd{S: "echo Success.\n"}, - &expect.BExp{R: `Success.`}, - }) - }) - - t.Run("tailscale status", func(t *testing.T) { - dur := 100 * time.Millisecond - var outp []byte - var err error - - // NOTE(Xe): retry `tailscale status` a few times until it works. When tailscaled - // starts with testcontrol sometimes there can be up to a few seconds where - // tailscaled is in an unknown state on these virtual machines. This exponential - // delay loop should delay long enough for tailscaled to be ready. - for count := 0; count < 10; count++ { - sess := getSession(t, cli) - - outp, err = sess.CombinedOutput("tailscale status") - if err == nil { - t.Logf("tailscale status: %s", outp) - if !strings.Contains(string(outp), "100.64.0.1") { - t.Fatal("can't find tester IP") - } - return - } - time.Sleep(dur) - dur = dur * 2 - } - - t.Log(string(outp)) - t.Fatalf("error: %v", err) - }) - - t.Run("dump routes", func(t *testing.T) { - sess, err := cli.NewSession() - if err != nil { - t.Fatal(err) - } - defer sess.Close() - sess.Stdout = logger.FuncWriter(t.Logf) - sess.Stderr = logger.FuncWriter(t.Logf) - err = sess.Run("ip route show table 52") - if err != nil { - t.Fatal(err) - } - - sess, err = cli.NewSession() - if err != nil { - t.Fatal(err) - } - defer sess.Close() - sess.Stdout = logger.FuncWriter(t.Logf) - sess.Stderr = logger.FuncWriter(t.Logf) - err = sess.Run("ip -6 route show table 52") - if err != nil { - t.Fatal(err) - } - }) - - for _, tt := range []struct { - ipProto string - addr netip.Addr - }{ - {"ipv4", h.testerV4}, - } { - t.Run(tt.ipProto+"-address", func(t *testing.T) { - sess := getSession(t, cli) - - ipBytes, err := sess.Output("tailscale ip -" + string(tt.ipProto[len(tt.ipProto)-1])) - if err != nil { - t.Fatalf("can't get IP: %v", err) - } - - netip.MustParseAddr(string(bytes.TrimSpace(ipBytes))) - }) - - t.Run("ping-"+tt.ipProto, func(t *testing.T) { - h.testPing(t, tt.addr, cli) - }) - - t.Run("outgoing-tcp-"+tt.ipProto, func(t *testing.T) { - h.testOutgoingTCP(t, tt.addr, cli) - }) - } - - t.Run("incoming-ssh-ipv4", func(t *testing.T) { - sess, err := cli.NewSession() - if err != nil { - t.Fatalf("can't make incoming session: %v", err) - } - defer sess.Close() - ipBytes, err := sess.Output("tailscale ip -4") - if err != nil { - t.Fatalf("can't run `tailscale ip -4`: %v", err) - } - ip := string(bytes.TrimSpace(ipBytes)) - - conn, err := h.testerDialer.Dial("tcp", net.JoinHostPort(ip, "22")) - if err != nil { - t.Fatalf("can't dial connection to vm: %v", err) - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(30 * time.Second)) - - sshConn, chanchan, reqchan, err := ssh.NewClientConn(conn, net.JoinHostPort(ip, "22"), ccfg) - if err != nil { - t.Fatalf("can't negotiate connection over tailscale: %v", err) - } - defer sshConn.Close() - - cli := ssh.NewClient(sshConn, chanchan, reqchan) - defer cli.Close() - - sess, err = cli.NewSession() - if err != nil { - t.Fatalf("can't make SSH session with VM: %v", err) - } - defer sess.Close() - - testIPBytes, err := sess.Output("tailscale ip -4") - if err != nil { - t.Fatalf("can't run command on remote VM: %v", err) - } - - if !bytes.Equal(testIPBytes, ipBytes) { - t.Fatalf("wanted reported ip to be %q, got: %q", string(ipBytes), string(testIPBytes)) - } - }) - - t.Run("outgoing-udp-ipv4", func(t *testing.T) { - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("can't get working directory: %v", err) - } - dir := t.TempDir() - run(t, cwd, "go", "build", "-o", filepath.Join(dir, "udp_tester"), "./udp_tester.go") - - sftpCli, err := sftp.NewClient(cli) - if err != nil { - t.Fatalf("can't connect over sftp to copy binaries: %v", err) - } - defer sftpCli.Close() - - copyFile(t, sftpCli, filepath.Join(dir, "udp_tester"), "/udp_tester") - - uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort("::", "0")) - if err != nil { - t.Fatalf("can't resolve udp listener addr: %v", err) - } - - buf := make([]byte, 2048) - - ln, err := net.ListenUDP("udp", uaddr) - if err != nil { - t.Fatalf("can't listen for UDP traffic: %v", err) - } - defer ln.Close() - - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - go func() { - for { - select { - case <-ctx.Done(): - return - default: - } - - sess, err := cli.NewSession() - if err != nil { - t.Errorf("can't open session: %v", err) - return - } - defer sess.Close() - - sess.Stdin = strings.NewReader("hi") - sess.Stdout = logger.FuncWriter(t.Logf) - sess.Stderr = logger.FuncWriter(t.Logf) - - _, port, _ := net.SplitHostPort(ln.LocalAddr().String()) - - cmd := fmt.Sprintf("/udp_tester -client %s\n", net.JoinHostPort("100.64.0.1", port)) - t.Logf("sending packet: %s", cmd) - err = sess.Run(cmd) - if err != nil { - t.Logf("can't send UDP packet: %v", err) - } - - time.Sleep(10 * time.Millisecond) - } - }() - - t.Log("listening for packet") - n, _, err := ln.ReadFromUDP(buf) - if err != nil { - t.Fatal(err) - } - - if n == 0 { - t.Fatal("got nothing") - } - - if !bytes.Contains(buf, []byte("hi")) { - t.Fatal("did not get UDP message") - } - }) - - t.Run("incoming-udp-ipv4", func(t *testing.T) { - // vms_test.go:947: can't dial: socks connect udp 127.0.0.1:36497->100.64.0.2:33409: network not implemented - t.Skip("can't make outgoing sockets over UDP with our socks server") - - sess, err := cli.NewSession() - if err != nil { - t.Fatalf("can't open session: %v", err) - } - defer sess.Close() - - ip, err := sess.Output("tailscale ip -4") - if err != nil { - t.Fatalf("can't nab ipv4 address: %v", err) - } - - port, err := getProbablyFreePortNumber() - if err != nil { - t.Fatalf("unable to fetch port number: %v", err) - } - - go func() { - time.Sleep(10 * time.Millisecond) - - conn, err := h.testerDialer.Dial("udp", net.JoinHostPort(string(bytes.TrimSpace(ip)), strconv.Itoa(port))) - if err != nil { - t.Errorf("can't dial: %v", err) - } - - fmt.Fprint(conn, securePassword) - }() - - sess, err = cli.NewSession() - if err != nil { - t.Fatalf("can't open session: %v", err) - } - defer sess.Close() - sess.Stderr = logger.FuncWriter(t.Logf) - - msg, err := sess.Output( - fmt.Sprintf( - "/udp_tester -server %s", - net.JoinHostPort(string(bytes.TrimSpace(ip)), strconv.Itoa(port)), - ), - ) - - if msg := string(bytes.TrimSpace(msg)); msg != securePassword { - t.Fatalf("wanted %q from vm, got: %q", securePassword, msg) - } - }) - - t.Run("dns-test", func(t *testing.T) { - t.Run("etc-resolv-conf", func(t *testing.T) { - sess := getSession(t, cli) - sess.Stdout = logger.FuncWriter(t.Logf) - sess.Stderr = logger.FuncWriter(t.Errorf) - if err := sess.Run("cat /etc/resolv.conf"); err != nil { - t.Fatal(err) - } - }) - - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("can't get working directory: %v", err) - } - dir := t.TempDir() - run(t, cwd, "go", "build", "-o", filepath.Join(dir, "dns_tester"), "./dns_tester.go") - - sftpCli, err := sftp.NewClient(cli) - if err != nil { - t.Fatalf("can't connect over sftp to copy binaries: %v", err) - } - defer sftpCli.Close() - - copyFile(t, sftpCli, filepath.Join(dir, "dns_tester"), "/dns_tester") - - for _, record := range []string{"extratest.record", "extratest"} { - t.Run(record, func(t *testing.T) { - sess := getSession(t, cli) - sess.Stderr = logger.FuncWriter(t.Errorf) - msg, err := sess.Output("/dns_tester " + record) - if err != nil { - t.Fatal(err) - } - - msg = bytes.TrimSpace(msg) - if want := []byte("1.2.3.4"); !bytes.Contains(msg, want) { - t.Fatalf("got: %q, want: %q", msg, want) - } - }) - } - }) -} - -func runTestCommands(t *testing.T, timeout time.Duration, cli *ssh.Client, batch []expect.Batcher) { - e, _, err := expect.SpawnSSH(cli, timeout, - expect.Verbose(true), - expect.VerboseWriter(logger.FuncWriter(t.Logf)), - - // // NOTE(Xe): if you get a timeout, uncomment this region to have the raw - // // output be sent to the test log quicker. - // expect.Tee(nopWriteCloser{logger.FuncWriter(t.Logf)}), - ) - if err != nil { - t.Fatalf("%s: can't register a shell session: %v", cli.RemoteAddr(), err) - } - defer e.Close() - - _, err = e.ExpectBatch(batch, timeout) - if err != nil { - sess, terr := cli.NewSession() - if terr != nil { - t.Fatalf("can't dump tailscaled logs on failed test: %v", terr) - } - sess.Stdout = logger.FuncWriter(t.Logf) - sess.Stderr = logger.FuncWriter(t.Logf) - terr = sess.Run("journalctl -u tailscaled") - if terr != nil { - t.Fatalf("can't dump tailscaled logs on failed test: %v", terr) - } - t.Fatalf("not successful: %v", err) - } -} diff --git a/tstest/iosdeps/iosdeps.go b/tstest/iosdeps/iosdeps.go deleted file mode 100644 index f414f53dfd0b6..0000000000000 --- a/tstest/iosdeps/iosdeps.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package iosdeps is a just a list of the packages we import on iOS, to let us -// test that our transitive closure of dependencies on iOS doesn't accidentally -// grow too large, as we've historically been memory constrained there. -package iosdeps - -import ( - _ "bufio" - _ "bytes" - _ "context" - _ "crypto/rand" - _ "crypto/sha256" - _ "encoding/json" - _ "errors" - _ "fmt" - _ "io" - _ "io/fs" - _ "log" - _ "math" - _ "net" - _ "net/http" - _ "os" - _ "os/signal" - _ "path/filepath" - _ "runtime" - _ "runtime/debug" - _ "strings" - _ "sync" - _ "sync/atomic" - _ "syscall" - _ "time" - _ "unsafe" - - _ "github.com/tailscale/wireguard-go/device" - _ "github.com/tailscale/wireguard-go/tun" - _ "go4.org/mem" - _ "golang.org/x/sys/unix" - _ "tailscale.com/hostinfo" - _ "tailscale.com/ipn" - _ "tailscale.com/ipn/ipnlocal" - _ "tailscale.com/ipn/localapi" - _ "tailscale.com/logtail" - _ "tailscale.com/logtail/filch" - _ "tailscale.com/net/dns" - _ "tailscale.com/net/netaddr" - _ "tailscale.com/net/tsdial" - _ "tailscale.com/net/tstun" - _ "tailscale.com/paths" - _ "tailscale.com/types/empty" - _ "tailscale.com/types/logger" - _ "tailscale.com/util/clientmetric" - _ "tailscale.com/util/dnsname" - _ "tailscale.com/version" - _ "tailscale.com/wgengine" - _ "tailscale.com/wgengine/router" -) diff --git a/tstest/iosdeps/iosdeps_test.go b/tstest/iosdeps/iosdeps_test.go deleted file mode 100644 index ab69f1c2b0649..0000000000000 --- a/tstest/iosdeps/iosdeps_test.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package iosdeps - -import ( - "testing" - - "tailscale.com/tstest/deptest" -) - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - GOOS: "ios", - GOARCH: "arm64", - BadDeps: map[string]string{ - "testing": "do not use testing package in production code", - "text/template": "linker bloat (MethodByName)", - "html/template": "linker bloat (MethodByName)", - "tailscale.com/net/wsconn": "https://github.com/tailscale/tailscale/issues/13762", - "github.com/coder/websocket": "https://github.com/tailscale/tailscale/issues/13762", - "github.com/mitchellh/go-ps": "https://github.com/tailscale/tailscale/pull/13759", - "database/sql/driver": "iOS doesn't use an SQL database", - "github.com/google/uuid": "see tailscale/tailscale#13760", - "tailscale.com/clientupdate/distsign": "downloads via AppStore, not distsign", - "github.com/tailscale/hujson": "no config file support on iOS", - }, - }.Check(t) -} diff --git a/tstest/jsdeps/jsdeps.go b/tstest/jsdeps/jsdeps.go deleted file mode 100644 index 1d188152f73b1..0000000000000 --- a/tstest/jsdeps/jsdeps.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package jsdeps is a just a list of the packages we import in the -// JavaScript/WASM build, to let us test that our transitive closure of -// dependencies doesn't accidentally grow too large, since binary size -// is more of a concern. -package jsdeps - -import ( - _ "bytes" - _ "context" - _ "encoding/hex" - _ "encoding/json" - _ "fmt" - _ "log" - _ "math/rand/v2" - _ "net" - _ "strings" - _ "time" - - _ "golang.org/x/crypto/ssh" - _ "tailscale.com/control/controlclient" - _ "tailscale.com/ipn" - _ "tailscale.com/ipn/ipnserver" - _ "tailscale.com/net/netaddr" - _ "tailscale.com/net/netns" - _ "tailscale.com/net/tsdial" - _ "tailscale.com/safesocket" - _ "tailscale.com/tailcfg" - _ "tailscale.com/types/logger" - _ "tailscale.com/wgengine" - _ "tailscale.com/wgengine/netstack" - _ "tailscale.com/words" -) diff --git a/tstest/jsdeps/jsdeps_test.go b/tstest/jsdeps/jsdeps_test.go deleted file mode 100644 index eb44df62eda8f..0000000000000 --- a/tstest/jsdeps/jsdeps_test.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package jsdeps - -import ( - "testing" - - "tailscale.com/tstest/deptest" -) - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - GOOS: "js", - GOARCH: "wasm", - BadDeps: map[string]string{ - "testing": "do not use testing package in production code", - "runtime/pprof": "bloat", - "golang.org/x/net/http2/h2c": "bloat", - "net/http/pprof": "bloat", - "golang.org/x/net/proxy": "bloat", - "github.com/tailscale/goupnp": "bloat, which can't work anyway in wasm", - }, - }.Check(t) -} diff --git a/tstest/log.go b/tstest/log.go deleted file mode 100644 index cb67c609adcdf..0000000000000 --- a/tstest/log.go +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import ( - "bytes" - "fmt" - "log" - "os" - "sync" - "testing" - - "go4.org/mem" - "tailscale.com/types/logger" -) - -type testLogWriter struct { - t *testing.T -} - -func (w *testLogWriter) Write(b []byte) (int, error) { - w.t.Helper() - w.t.Logf("%s", b) - return len(b), nil -} - -func FixLogs(t *testing.T) { - log.SetFlags(log.Ltime | log.Lshortfile) - log.SetOutput(&testLogWriter{t}) -} - -func UnfixLogs(t *testing.T) { - defer log.SetOutput(os.Stderr) -} - -type panicLogWriter struct{} - -func (panicLogWriter) Write(b []byte) (int, error) { - // Allow certain phrases for now, in the interest of getting - // CI working on Windows and not having to refactor all the - // interfaces.GetState & tshttpproxy code to allow pushing - // down a Logger yet. TODO(bradfitz): do that refactoring once - // 1.2.0 is out. - if bytes.Contains(b, []byte("tshttpproxy: ")) || - bytes.Contains(b, []byte("runtime/panic.go:")) || - bytes.Contains(b, []byte("XXX")) { - os.Stderr.Write(b) - return len(b), nil - } - panic(fmt.Sprintf("please use tailscale.com/logger.Logf instead of the log package (tried to log: %q)", b)) -} - -// PanicOnLog modifies the standard library log package's default output to -// an io.Writer that panics, to root out code that's not plumbing their logging -// through explicit tailscale.com/logger.Logf paths. -func PanicOnLog() { - log.SetOutput(panicLogWriter{}) -} - -// NewLogLineTracker produces a LogLineTracker wrapping a given logf that tracks whether expectedFormatStrings were seen. -func NewLogLineTracker(logf logger.Logf, expectedFormatStrings []string) *LogLineTracker { - ret := &LogLineTracker{ - logf: logf, - listenFor: expectedFormatStrings, - seen: make(map[string]bool), - } - for _, line := range expectedFormatStrings { - ret.seen[line] = false - } - return ret -} - -// LogLineTracker is a logger that tracks which log format patterns it's -// seen and can report which expected ones were not seen later. -type LogLineTracker struct { - logf logger.Logf - listenFor []string - - mu sync.Mutex - closed bool - seen map[string]bool // format string => false (if not yet seen but wanted) or true (once seen) -} - -// Logf logs to its underlying logger and also tracks that the given format pattern has been seen. -func (lt *LogLineTracker) Logf(format string, args ...any) { - lt.mu.Lock() - if lt.closed { - lt.mu.Unlock() - return - } - if v, ok := lt.seen[format]; ok && !v { - lt.seen[format] = true - } - lt.mu.Unlock() - lt.logf(format, args...) -} - -// Check returns which format strings haven't been logged yet. -func (lt *LogLineTracker) Check() []string { - lt.mu.Lock() - defer lt.mu.Unlock() - var notSeen []string - for _, format := range lt.listenFor { - if !lt.seen[format] { - notSeen = append(notSeen, format) - } - } - return notSeen -} - -// Reset forgets everything that it's seen. -func (lt *LogLineTracker) Reset() { - lt.mu.Lock() - defer lt.mu.Unlock() - for _, line := range lt.listenFor { - lt.seen[line] = false - } -} - -// Close closes lt. After calling Close, calls to Logf become no-ops. -func (lt *LogLineTracker) Close() { - lt.mu.Lock() - defer lt.mu.Unlock() - lt.closed = true -} - -// MemLogger is a bytes.Buffer with a Logf method for tests that want -// to log to a buffer. -type MemLogger struct { - sync.Mutex - bytes.Buffer -} - -func (ml *MemLogger) Logf(format string, args ...any) { - ml.Lock() - defer ml.Unlock() - fmt.Fprintf(&ml.Buffer, format, args...) - if !mem.HasSuffix(mem.B(ml.Buffer.Bytes()), mem.S("\n")) { - ml.Buffer.WriteByte('\n') - } -} - -func (ml *MemLogger) String() string { - ml.Lock() - defer ml.Unlock() - return ml.Buffer.String() -} - -// WhileTestRunningLogger returns a logger.Logf that logs to t.Logf until the -// test finishes, at which point it no longer logs anything. -func WhileTestRunningLogger(t testing.TB) logger.Logf { - var ( - mu sync.RWMutex - done bool - ) - tlogf := logger.TestLogger(t) - logger := func(format string, args ...any) { - t.Helper() - - mu.RLock() - defer mu.RUnlock() - - if done { - return - } - tlogf(format, args...) - } - - // t.Cleanup is run before the test is marked as done, so by acquiring - // the mutex and then disabling logs, we know that all existing log - // functions have completed, and that no future calls to the logger - // will log something. - // - // We can't do this with an atomic bool, since it's possible to - // observe the following race: - // - // test goroutine goroutine 1 - // -------------- ----------- - // check atomic, testFinished = no - // test finishes - // run t.Cleanups - // set testFinished = true - // call t.Logf - // panic - // - // Using a mutex ensures that all actions in goroutine 1 in the - // sequence above occur atomically, and thus should not panic. - t.Cleanup(func() { - mu.Lock() - defer mu.Unlock() - done = true - }) - return logger -} diff --git a/tstest/log_test.go b/tstest/log_test.go deleted file mode 100644 index 51a5743c2c7f2..0000000000000 --- a/tstest/log_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import ( - "reflect" - "testing" -) - -func TestLogLineTracker(t *testing.T) { - const ( - l1 = "line 1: %s" - l2 = "line 2: %s" - l3 = "line 3: %s" - ) - - lt := NewLogLineTracker(t.Logf, []string{l1, l2}) - - if got, want := lt.Check(), []string{l1, l2}; !reflect.DeepEqual(got, want) { - t.Errorf("Check = %q; want %q", got, want) - } - - lt.Logf(l3, "hi") - - if got, want := lt.Check(), []string{l1, l2}; !reflect.DeepEqual(got, want) { - t.Errorf("Check = %q; want %q", got, want) - } - - lt.Logf(l1, "hi") - - if got, want := lt.Check(), []string{l2}; !reflect.DeepEqual(got, want) { - t.Errorf("Check = %q; want %q", got, want) - } - - lt.Logf(l1, "bye") - - if got, want := lt.Check(), []string{l2}; !reflect.DeepEqual(got, want) { - t.Errorf("Check = %q; want %q", got, want) - } - - lt.Logf(l2, "hi") - - if got, want := lt.Check(), []string(nil); !reflect.DeepEqual(got, want) { - t.Errorf("Check = %q; want %q", got, want) - } -} diff --git a/tstest/natlab/firewall.go b/tstest/natlab/firewall.go deleted file mode 100644 index c427d6692a29c..0000000000000 --- a/tstest/natlab/firewall.go +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package natlab - -import ( - "fmt" - "net/netip" - "sync" - "time" - - "tailscale.com/util/mak" -) - -// FirewallType is the type of filtering a stateful firewall -// does. Values express different modes defined by RFC 4787. -type FirewallType int - -const ( - // AddressAndPortDependentFirewall specifies a destination - // address-and-port dependent firewall. Outbound traffic to an - // ip:port authorizes traffic from that ip:port exactly, and - // nothing else. - AddressAndPortDependentFirewall FirewallType = iota - // AddressDependentFirewall specifies a destination address - // dependent firewall. Once outbound traffic has been seen to an - // IP address, that IP address can talk back from any port. - AddressDependentFirewall - // EndpointIndependentFirewall specifies a destination endpoint - // independent firewall. Once outbound traffic has been seen from - // a source, anyone can talk back to that source. - EndpointIndependentFirewall -) - -// fwKey is the lookup key for a firewall session. While it contains a -// 4-tuple ({src,dst} {ip,port}), some FirewallTypes will zero out -// some fields, so in practice the key is either a 2-tuple (src only), -// 3-tuple (src ip+port and dst ip) or 4-tuple (src+dst ip+port). -type fwKey struct { - src netip.AddrPort - dst netip.AddrPort -} - -// key returns an fwKey for the given src and dst, trimmed according -// to the FirewallType. fwKeys are always constructed from the -// "outbound" point of view (i.e. src is the "trusted" side of the -// world), it's the caller's responsibility to swap src and dst in the -// call to key when processing packets inbound from the "untrusted" -// world. -func (s FirewallType) key(src, dst netip.AddrPort) fwKey { - k := fwKey{src: src} - switch s { - case EndpointIndependentFirewall: - case AddressDependentFirewall: - k.dst = netip.AddrPortFrom(dst.Addr(), k.dst.Port()) - case AddressAndPortDependentFirewall: - k.dst = dst - default: - panic(fmt.Sprintf("unknown firewall selectivity %v", s)) - } - return k -} - -// DefaultSessionTimeout is the default timeout for a firewall -// session. -const DefaultSessionTimeout = 30 * time.Second - -// Firewall is a simple stateful firewall that allows all outbound -// traffic and filters inbound traffic based on recently seen outbound -// traffic. Its HandlePacket method should be attached to a Machine to -// give it a stateful firewall. -type Firewall struct { - // SessionTimeout is the lifetime of idle sessions in the firewall - // state. Packets transiting from the TrustedInterface reset the - // session lifetime to SessionTimeout. If zero, - // DefaultSessionTimeout is used. - SessionTimeout time.Duration - // Type specifies how precisely return traffic must match - // previously seen outbound traffic to be allowed. Defaults to - // AddressAndPortDependentFirewall. - Type FirewallType - // TrustedInterface is an optional interface that is considered - // trusted in addition to PacketConns local to the Machine. All - // other interfaces can only respond to traffic from - // TrustedInterface or the local host. - TrustedInterface *Interface - // TimeNow is a function returning the current time. If nil, - // time.Now is used. - TimeNow func() time.Time - - // TODO: refresh directionality: outbound-only, both - - mu sync.Mutex - seen map[fwKey]time.Time // session -> deadline -} - -func (f *Firewall) timeNow() time.Time { - if f.TimeNow != nil { - return f.TimeNow() - } - return time.Now() -} - -// Reset drops all firewall state, forgetting all flows. -func (f *Firewall) Reset() { - f.mu.Lock() - defer f.mu.Unlock() - f.seen = nil -} - -func (f *Firewall) HandleOut(p *Packet, oif *Interface) *Packet { - f.mu.Lock() - defer f.mu.Unlock() - - k := f.Type.key(p.Src, p.Dst) - mak.Set(&f.seen, k, f.timeNow().Add(f.sessionTimeoutLocked())) - p.Trace("firewall out ok") - return p -} - -func (f *Firewall) HandleIn(p *Packet, iif *Interface) *Packet { - f.mu.Lock() - defer f.mu.Unlock() - - // reverse src and dst because the session table is from the POV - // of outbound packets. - k := f.Type.key(p.Dst, p.Src) - now := f.timeNow() - if now.After(f.seen[k]) { - p.Trace("firewall drop") - return nil - } - p.Trace("firewall in ok") - return p -} - -func (f *Firewall) HandleForward(p *Packet, iif *Interface, oif *Interface) *Packet { - if iif == f.TrustedInterface { - // Treat just like a locally originated packet - return f.HandleOut(p, oif) - } - if oif != f.TrustedInterface { - // Not a possible return packet from our trusted interface, drop. - p.Trace("firewall drop, unexpected oif") - return nil - } - // Otherwise, a session must exist, same as HandleIn. - return f.HandleIn(p, iif) -} - -func (f *Firewall) sessionTimeoutLocked() time.Duration { - if f.SessionTimeout == 0 { - return DefaultSessionTimeout - } - return f.SessionTimeout -} diff --git a/tstest/natlab/nat.go b/tstest/natlab/nat.go deleted file mode 100644 index d756c5bf11833..0000000000000 --- a/tstest/natlab/nat.go +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package natlab - -import ( - "context" - "fmt" - "net" - "net/netip" - "sync" - "time" -) - -// mapping is the state of an allocated NAT session. -type mapping struct { - lanSrc netip.AddrPort - lanDst netip.AddrPort - wanSrc netip.AddrPort - deadline time.Time - - // pc is a PacketConn that reserves an outbound port on the NAT's - // WAN interface. We do this because ListenPacket already has - // random port selection logic built in. Additionally this means - // that concurrent use of ListenPacket for connections originating - // from the NAT box won't conflict with NAT mappings, since both - // use PacketConn to reserve ports on the machine. - pc net.PacketConn -} - -// NATType is the mapping behavior of a NAT device. Values express -// different modes defined by RFC 4787. -type NATType int - -const ( - // EndpointIndependentNAT specifies a destination endpoint - // independent NAT. All traffic from a source ip:port gets mapped - // to a single WAN ip:port. - EndpointIndependentNAT NATType = iota - // AddressDependentNAT specifies a destination address dependent - // NAT. Every distinct destination IP gets its own WAN ip:port - // allocation. - AddressDependentNAT - // AddressAndPortDependentNAT specifies a destination - // address-and-port dependent NAT. Every distinct destination - // ip:port gets its own WAN ip:port allocation. - AddressAndPortDependentNAT -) - -// natKey is the lookup key for a NAT session. While it contains a -// 4-tuple ({src,dst} {ip,port}), some NATTypes will zero out some -// fields, so in practice the key is either a 2-tuple (src only), -// 3-tuple (src ip+port and dst ip) or 4-tuple (src+dst ip+port). -type natKey struct { - src, dst netip.AddrPort -} - -func (t NATType) key(src, dst netip.AddrPort) natKey { - k := natKey{src: src} - switch t { - case EndpointIndependentNAT: - case AddressDependentNAT: - k.dst = netip.AddrPortFrom(dst.Addr(), k.dst.Port()) - case AddressAndPortDependentNAT: - k.dst = dst - default: - panic(fmt.Sprintf("unknown NAT type %v", t)) - } - return k -} - -// DefaultMappingTimeout is the default timeout for a NAT mapping. -const DefaultMappingTimeout = 30 * time.Second - -// SNAT44 implements an IPv4-to-IPv4 source NAT (SNAT) translator, with -// optional builtin firewall. -type SNAT44 struct { - // Machine is the machine to which this NAT is attached. Altered - // packets are injected back into this Machine for processing. - Machine *Machine - // ExternalInterface is the "WAN" interface of Machine. Packets - // from other sources get NATed onto this interface. - ExternalInterface *Interface - // Type specifies the mapping allocation behavior for this NAT. - Type NATType - // MappingTimeout is the lifetime of individual NAT sessions. Once - // a session expires, the mapped port effectively "closes" to new - // traffic. If MappingTimeout is 0, DefaultMappingTimeout is used. - MappingTimeout time.Duration - // Firewall is an optional packet handler that will be invoked as - // a firewall during NAT translation. The firewall always sees - // packets in their "LAN form", i.e. before translation in the - // outbound direction and after translation in the inbound - // direction. - Firewall PacketHandler - // TimeNow is a function that returns the current time. If - // nil, time.Now is used. - TimeNow func() time.Time - - mu sync.Mutex - byLAN map[natKey]*mapping // lookup by outbound packet tuple - byWAN map[netip.AddrPort]*mapping // lookup by wan ip:port only -} - -func (n *SNAT44) timeNow() time.Time { - if n.TimeNow != nil { - return n.TimeNow() - } - return time.Now() -} - -func (n *SNAT44) mappingTimeout() time.Duration { - if n.MappingTimeout == 0 { - return DefaultMappingTimeout - } - return n.MappingTimeout -} - -func (n *SNAT44) initLocked() { - if n.byLAN == nil { - n.byLAN = map[natKey]*mapping{} - n.byWAN = map[netip.AddrPort]*mapping{} - } - if n.ExternalInterface.Machine() != n.Machine { - panic(fmt.Sprintf("NAT given interface %s that is not part of given machine %s", n.ExternalInterface, n.Machine.Name)) - } -} - -func (n *SNAT44) HandleOut(p *Packet, oif *Interface) *Packet { - // NATs don't affect locally originated packets. - if n.Firewall != nil { - return n.Firewall.HandleOut(p, oif) - } - return p -} - -func (n *SNAT44) HandleIn(p *Packet, iif *Interface) *Packet { - if iif != n.ExternalInterface { - // NAT can't apply, defer to firewall. - if n.Firewall != nil { - return n.Firewall.HandleIn(p, iif) - } - return p - } - - n.mu.Lock() - defer n.mu.Unlock() - n.initLocked() - - now := n.timeNow() - mapping := n.byWAN[p.Dst] - if mapping == nil || now.After(mapping.deadline) { - // NAT didn't hit, defer to firewall or allow in for local - // socket handling. - if n.Firewall != nil { - return n.Firewall.HandleIn(p, iif) - } - return p - } - - p.Dst = mapping.lanSrc - p.Trace("dnat to %v", p.Dst) - // Don't process firewall here. We mutated the packet such that - // it's no longer destined locally, so we'll get reinvoked as - // HandleForward and need to process the altered packet there. - return p -} - -func (n *SNAT44) HandleForward(p *Packet, iif, oif *Interface) *Packet { - switch { - case oif == n.ExternalInterface: - if p.Src.Addr() == oif.V4() { - // Packet already NATed and is just retraversing Forward, - // don't touch it again. - return p - } - - if n.Firewall != nil { - p2 := n.Firewall.HandleForward(p, iif, oif) - if p2 == nil { - // firewall dropped, done - return nil - } - if !p.Equivalent(p2) { - // firewall mutated packet? Weird, but okay. - return p2 - } - } - - n.mu.Lock() - defer n.mu.Unlock() - n.initLocked() - - k := n.Type.key(p.Src, p.Dst) - now := n.timeNow() - m := n.byLAN[k] - if m == nil || now.After(m.deadline) { - pc, wanAddr := n.allocateMappedPort() - m = &mapping{ - lanSrc: p.Src, - lanDst: p.Dst, - wanSrc: wanAddr, - pc: pc, - } - n.byLAN[k] = m - n.byWAN[wanAddr] = m - } - m.deadline = now.Add(n.mappingTimeout()) - p.Src = m.wanSrc - p.Trace("snat from %v", p.Src) - return p - case iif == n.ExternalInterface: - // Packet was already un-NAT-ed, we just need to either - // firewall it or let it through. - if n.Firewall != nil { - return n.Firewall.HandleForward(p, iif, oif) - } - return p - default: - // No NAT applies, invoke firewall or drop. - if n.Firewall != nil { - return n.Firewall.HandleForward(p, iif, oif) - } - return nil - } -} - -func (n *SNAT44) allocateMappedPort() (net.PacketConn, netip.AddrPort) { - // Clean up old entries before trying to allocate, to free up any - // expired ports. - n.gc() - - ip := n.ExternalInterface.V4() - pc, err := n.Machine.ListenPacket(context.Background(), "udp", net.JoinHostPort(ip.String(), "0")) - if err != nil { - panic(fmt.Sprintf("ran out of NAT ports: %v", err)) - } - addr := netip.AddrPortFrom(ip, uint16(pc.LocalAddr().(*net.UDPAddr).Port)) - return pc, addr -} - -func (n *SNAT44) gc() { - now := n.timeNow() - for _, m := range n.byLAN { - if !now.After(m.deadline) { - continue - } - m.pc.Close() - delete(n.byLAN, n.Type.key(m.lanSrc, m.lanDst)) - delete(n.byWAN, m.wanSrc) - } -} diff --git a/tstest/natlab/natlab.go b/tstest/natlab/natlab.go deleted file mode 100644 index 92a4ccb68e25a..0000000000000 --- a/tstest/natlab/natlab.go +++ /dev/null @@ -1,874 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package natlab lets us simulate different types of networks all -// in-memory without running VMs or requiring root, etc. Despite the -// name, it does more than just NATs. But NATs are the most -// interesting. -package natlab - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "math/rand/v2" - "net" - "net/netip" - "os" - "sort" - "strconv" - "sync" - "time" - - "tailscale.com/net/netaddr" -) - -var traceOn, _ = strconv.ParseBool(os.Getenv("NATLAB_TRACE")) - -// Packet represents a UDP packet flowing through the virtual network. -type Packet struct { - Src, Dst netip.AddrPort - Payload []byte - - // Prefix set by various internal methods of natlab, to locate - // where in the network a trace occurred. - locator string -} - -// Equivalent returns true if Src, Dst and Payload are the same in p -// and p2. -func (p *Packet) Equivalent(p2 *Packet) bool { - return p.Src == p2.Src && p.Dst == p2.Dst && bytes.Equal(p.Payload, p2.Payload) -} - -// Clone returns a copy of p that shares nothing with p. -func (p *Packet) Clone() *Packet { - return &Packet{ - Src: p.Src, - Dst: p.Dst, - Payload: bytes.Clone(p.Payload), - locator: p.locator, - } -} - -// short returns a short identifier for a packet payload, -// suitable for printing trace information. -func (p *Packet) short() string { - s := sha256.Sum256(p.Payload) - payload := base64.RawStdEncoding.EncodeToString(s[:])[:2] - - s = sha256.Sum256([]byte(p.Src.String() + "_" + p.Dst.String())) - tuple := base64.RawStdEncoding.EncodeToString(s[:])[:2] - - return fmt.Sprintf("%s/%s", payload, tuple) -} - -func (p *Packet) Trace(msg string, args ...any) { - if !traceOn { - return - } - allArgs := []any{p.short(), p.locator, p.Src, p.Dst} - allArgs = append(allArgs, args...) - fmt.Fprintf(os.Stderr, "[%s]%s src=%s dst=%s "+msg+"\n", allArgs...) -} - -func (p *Packet) setLocator(msg string, args ...any) { - p.locator = fmt.Sprintf(" "+msg, args...) -} - -func mustPrefix(s string) netip.Prefix { - ipp, err := netip.ParsePrefix(s) - if err != nil { - panic(err) - } - return ipp -} - -// NewInternet returns a network that simulates the internet. -func NewInternet() *Network { - return &Network{ - Name: "internet", - // easily recognizable internetty addresses - Prefix4: mustPrefix("1.0.0.0/24"), - Prefix6: mustPrefix("1111::/64"), - } -} - -type Network struct { - Name string - Prefix4 netip.Prefix - Prefix6 netip.Prefix - - mu sync.Mutex - machine map[netip.Addr]*Interface - defaultGW *Interface // optional - lastV4 netip.Addr - lastV6 netip.Addr -} - -func (n *Network) SetDefaultGateway(gwIf *Interface) { - n.mu.Lock() - defer n.mu.Unlock() - if gwIf.net != n { - panic(fmt.Sprintf("can't set if=%s as net=%s's default gw, if not connected to net", gwIf.name, gwIf.net.Name)) - } - n.defaultGW = gwIf -} - -func (n *Network) addMachineLocked(ip netip.Addr, iface *Interface) { - if iface == nil { - return // for tests - } - if n.machine == nil { - n.machine = map[netip.Addr]*Interface{} - } - n.machine[ip] = iface -} - -func (n *Network) allocIPv4(iface *Interface) netip.Addr { - n.mu.Lock() - defer n.mu.Unlock() - if !n.Prefix4.IsValid() { - return netip.Addr{} - } - if !n.lastV4.IsValid() { - n.lastV4 = n.Prefix4.Addr() - } - a := n.lastV4.As16() - addOne(&a, 15) - n.lastV4 = netip.AddrFrom16(a).Unmap() - if !n.Prefix4.Contains(n.lastV4) { - panic("pool exhausted") - } - n.addMachineLocked(n.lastV4, iface) - return n.lastV4 -} - -func (n *Network) allocIPv6(iface *Interface) netip.Addr { - n.mu.Lock() - defer n.mu.Unlock() - if !n.Prefix6.IsValid() { - return netip.Addr{} - } - if !n.lastV6.IsValid() { - n.lastV6 = n.Prefix6.Addr() - } - a := n.lastV6.As16() - addOne(&a, 15) - n.lastV6 = netip.AddrFrom16(a).Unmap() - if !n.Prefix6.Contains(n.lastV6) { - panic("pool exhausted") - } - n.addMachineLocked(n.lastV6, iface) - return n.lastV6 -} - -func addOne(a *[16]byte, index int) { - if v := a[index]; v < 255 { - a[index]++ - } else { - a[index] = 0 - addOne(a, index-1) - } -} - -func (n *Network) write(p *Packet) (num int, err error) { - p.setLocator("net=%s", n.Name) - - n.mu.Lock() - defer n.mu.Unlock() - iface, ok := n.machine[p.Dst.Addr()] - if !ok { - // If the destination is within the network's authoritative - // range, no route to host. - if p.Dst.Addr().Is4() && n.Prefix4.Contains(p.Dst.Addr()) { - p.Trace("no route to %v", p.Dst.Addr()) - return len(p.Payload), nil - } - if p.Dst.Addr().Is6() && n.Prefix6.Contains(p.Dst.Addr()) { - p.Trace("no route to %v", p.Dst.Addr()) - return len(p.Payload), nil - } - - if n.defaultGW == nil { - p.Trace("no route to %v", p.Dst.Addr()) - return len(p.Payload), nil - } - iface = n.defaultGW - } - - // Pretend it went across the network. Make a copy so nobody - // can later mess with caller's memory. - p.Trace("-> mach=%s if=%s", iface.machine.Name, iface.name) - go iface.machine.deliverIncomingPacket(p, iface) - return len(p.Payload), nil -} - -type Interface struct { - machine *Machine - net *Network - name string // optional - ips []netip.Addr // static; not mutated once created -} - -func (f *Interface) Machine() *Machine { - return f.machine -} - -func (f *Interface) Network() *Network { - return f.net -} - -// V4 returns the machine's first IPv4 address, or the zero value if none. -func (f *Interface) V4() netip.Addr { return f.pickIP(netip.Addr.Is4) } - -// V6 returns the machine's first IPv6 address, or the zero value if none. -func (f *Interface) V6() netip.Addr { return f.pickIP(netip.Addr.Is6) } - -func (f *Interface) pickIP(pred func(netip.Addr) bool) netip.Addr { - for _, ip := range f.ips { - if pred(ip) { - return ip - } - } - return netip.Addr{} -} - -func (f *Interface) String() string { - // TODO: make this all better - if f.name != "" { - return f.name - } - return fmt.Sprintf("unnamed-interface-on-network-%p", f.net) -} - -// Contains reports whether f contains ip as an IP. -func (f *Interface) Contains(ip netip.Addr) bool { - for _, v := range f.ips { - if ip == v { - return true - } - } - return false -} - -type routeEntry struct { - prefix netip.Prefix - iface *Interface -} - -// A PacketVerdict is a decision of what to do with a packet. -type PacketVerdict int - -const ( - // Continue means the packet should be processed by the "local - // sockets" logic of the Machine. - Continue PacketVerdict = iota - // Drop means the packet should not be handled further. - Drop -) - -func (v PacketVerdict) String() string { - switch v { - case Continue: - return "Continue" - case Drop: - return "Drop" - default: - return fmt.Sprintf("", v) - } -} - -// A PacketHandler can look at packets arriving at, departing, and -// transiting a Machine, and filter or mutate them. -// -// Each method is invoked with a Packet that natlab would like to keep -// processing. Handlers can return that same Packet to allow -// processing to continue; nil to drop the Packet; or a different -// Packet that should be processed instead of the original. -// -// Packets passed to handlers share no state with anything else, and -// are therefore safe to mutate. It's safe to return the original -// packet mutated in-place, or a brand new packet initialized from -// scratch. -// -// Packets mutated by a PacketHandler are processed anew by the -// associated Machine, as if the packet had always been the mutated -// one. For example, if HandleForward is invoked with a Packet, and -// the handler changes the destination IP address to one of the -// Machine's own IPs, the Machine restarts delivery, but this time -// going to a local PacketConn (which in turn will invoke HandleIn, -// since the packet is now destined for local delivery). -type PacketHandler interface { - // HandleIn processes a packet arriving on iif, whose destination - // is an IP address owned by the attached Machine. If p is - // returned unmodified, the Machine will go on to deliver the - // Packet to the appropriate listening PacketConn, if one exists. - HandleIn(p *Packet, iif *Interface) *Packet - // HandleOut processes a packet about to depart on oif from a - // local PacketConn. If p is returned unmodified, the Machine will - // transmit the Packet on oif. - HandleOut(p *Packet, oif *Interface) *Packet - // HandleForward is called when the Machine wants to forward a - // packet from iif to oif. If p is returned unmodified, the - // Machine will transmit the packet on oif. - HandleForward(p *Packet, iif, oif *Interface) *Packet -} - -// A Machine is a representation of an operating system's network -// stack. It has a network routing table and can have multiple -// attached networks. The zero value is valid, but lacks any -// networking capability until Attach is called. -type Machine struct { - // Name is a pretty name for debugging and packet tracing. It need - // not be globally unique. - Name string - - // PacketHandler, if not nil, is a PacketHandler implementation - // that inspects all packets arriving, departing, or transiting - // the Machine. See the definition of the PacketHandler interface - // for semantics. - // - // If PacketHandler is nil, the machine allows all inbound - // traffic, all outbound traffic, and drops forwarded packets. - PacketHandler PacketHandler - - mu sync.Mutex - interfaces []*Interface - routes []routeEntry // sorted by longest prefix to shortest - - conns4 map[netip.AddrPort]*conn // conns that want IPv4 packets - conns6 map[netip.AddrPort]*conn // conns that want IPv6 packets -} - -func (m *Machine) isLocalIP(ip netip.Addr) bool { - m.mu.Lock() - defer m.mu.Unlock() - for _, intf := range m.interfaces { - for _, iip := range intf.ips { - if ip == iip { - return true - } - } - } - return false -} - -func (m *Machine) deliverIncomingPacket(p *Packet, iface *Interface) { - p.setLocator("mach=%s if=%s", m.Name, iface.name) - - if m.isLocalIP(p.Dst.Addr()) { - m.deliverLocalPacket(p, iface) - } else { - m.forwardPacket(p, iface) - } -} - -func (m *Machine) deliverLocalPacket(p *Packet, iface *Interface) { - // TODO: can't hold lock while handling packet. This is safe as - // long as you set HandlePacket before traffic starts flowing. - if m.PacketHandler != nil { - p2 := m.PacketHandler.HandleIn(p.Clone(), iface) - if p2 == nil { - // Packet dropped, nothing left to do. - return - } - if !p.Equivalent(p2) { - // Restart delivery, this packet might be a forward packet - // now. - m.deliverIncomingPacket(p2, iface) - return - } - } - - m.mu.Lock() - defer m.mu.Unlock() - - conns := m.conns4 - if p.Dst.Addr().Is6() { - conns = m.conns6 - } - possibleDsts := []netip.AddrPort{ - p.Dst, - netip.AddrPortFrom(v6unspec, p.Dst.Port()), - netip.AddrPortFrom(v4unspec, p.Dst.Port()), - } - for _, dest := range possibleDsts { - c, ok := conns[dest] - if !ok { - continue - } - select { - case c.in <- p: - p.Trace("queued to conn") - default: - p.Trace("dropped, queue overflow") - // Queue overflow. Just drop it. - } - return - } - p.Trace("dropped, no listening conn") -} - -func (m *Machine) forwardPacket(p *Packet, iif *Interface) { - oif, err := m.interfaceForIP(p.Dst.Addr()) - if err != nil { - p.Trace("%v", err) - return - } - - if m.PacketHandler == nil { - // Forwarding not allowed by default - p.Trace("drop, forwarding not allowed") - return - } - p2 := m.PacketHandler.HandleForward(p.Clone(), iif, oif) - if p2 == nil { - p.Trace("drop") - // Packet dropped, done. - return - } - if !p.Equivalent(p2) { - // Packet changed, restart delivery. - p2.Trace("PacketHandler mutated packet") - m.deliverIncomingPacket(p2, iif) - return - } - - p.Trace("-> net=%s oif=%s", oif.net.Name, oif) - oif.net.write(p) -} - -// Attach adds an interface to a machine. -// -// The first interface added to a Machine becomes that machine's -// default route. -func (m *Machine) Attach(interfaceName string, n *Network) *Interface { - f := &Interface{ - machine: m, - net: n, - name: interfaceName, - } - if ip := n.allocIPv4(f); ip.IsValid() { - f.ips = append(f.ips, ip) - } - if ip := n.allocIPv6(f); ip.IsValid() { - f.ips = append(f.ips, ip) - } - - m.mu.Lock() - defer m.mu.Unlock() - - m.interfaces = append(m.interfaces, f) - if len(m.interfaces) == 1 { - m.routes = append(m.routes, - routeEntry{ - prefix: mustPrefix("0.0.0.0/0"), - iface: f, - }, - routeEntry{ - prefix: mustPrefix("::/0"), - iface: f, - }) - } else { - if n.Prefix4.IsValid() { - m.routes = append(m.routes, routeEntry{ - prefix: n.Prefix4, - iface: f, - }) - } - if n.Prefix6.IsValid() { - m.routes = append(m.routes, routeEntry{ - prefix: n.Prefix6, - iface: f, - }) - } - } - sort.Slice(m.routes, func(i, j int) bool { - return m.routes[i].prefix.Bits() > m.routes[j].prefix.Bits() - }) - - return f -} - -var ( - v4unspec = netaddr.IPv4(0, 0, 0, 0) - v6unspec = netip.IPv6Unspecified() -) - -func (m *Machine) writePacket(p *Packet) (n int, err error) { - p.setLocator("mach=%s", m.Name) - - iface, err := m.interfaceForIP(p.Dst.Addr()) - if err != nil { - p.Trace("%v", err) - return 0, err - } - origSrcIP := p.Src.Addr() - switch { - case p.Src.Addr() == v4unspec: - p.Trace("assigning srcIP=%s", iface.V4()) - p.Src = netip.AddrPortFrom(iface.V4(), p.Src.Port()) - case p.Src.Addr() == v6unspec: - // v6unspec in Go means "any src, but match address families" - if p.Dst.Addr().Is6() { - p.Trace("assigning srcIP=%s", iface.V6()) - p.Src = netip.AddrPortFrom(iface.V6(), p.Src.Port()) - } else if p.Dst.Addr().Is4() { - p.Trace("assigning srcIP=%s", iface.V4()) - p.Src = netip.AddrPortFrom(iface.V4(), p.Src.Port()) - } - default: - if !iface.Contains(p.Src.Addr()) { - err := fmt.Errorf("can't send to %v with src %v on interface %v", p.Dst.Addr(), p.Src.Addr(), iface) - p.Trace("%v", err) - return 0, err - } - } - if !p.Src.Addr().IsValid() { - err := fmt.Errorf("no matching address for address family for %v", origSrcIP) - p.Trace("%v", err) - return 0, err - } - - if m.PacketHandler != nil { - p2 := m.PacketHandler.HandleOut(p.Clone(), iface) - if p2 == nil { - // Packet dropped, done. - return len(p.Payload), nil - } - if !p.Equivalent(p2) { - // Restart transmission, src may have changed weirdly - m.writePacket(p2) - return - } - } - - p.Trace("-> net=%s if=%s", iface.net.Name, iface) - return iface.net.write(p) -} - -func (m *Machine) interfaceForIP(ip netip.Addr) (*Interface, error) { - m.mu.Lock() - defer m.mu.Unlock() - for _, re := range m.routes { - if re.prefix.Contains(ip) { - return re.iface, nil - } - } - return nil, fmt.Errorf("no route found to %v", ip) -} - -func (m *Machine) pickEphemPort() (port uint16, err error) { - m.mu.Lock() - defer m.mu.Unlock() - for tries := 0; tries < 500; tries++ { - port := uint16(rand.IntN(32<<10) + 32<<10) - if !m.portInUseLocked(port) { - return port, nil - } - } - return 0, errors.New("failed to find an ephemeral port") -} - -func (m *Machine) portInUseLocked(port uint16) bool { - for ipp := range m.conns4 { - if ipp.Port() == port { - return true - } - } - for ipp := range m.conns6 { - if ipp.Port() == port { - return true - } - } - return false -} - -func (m *Machine) registerConn4(c *conn) error { - m.mu.Lock() - defer m.mu.Unlock() - if c.ipp.Addr().Is6() && c.ipp.Addr() != v6unspec { - return fmt.Errorf("registerConn4 got IPv6 %s", c.ipp) - } - return registerConn(&m.conns4, c) -} - -func (m *Machine) unregisterConn4(c *conn) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.conns4, c.ipp) -} - -func (m *Machine) registerConn6(c *conn) error { - m.mu.Lock() - defer m.mu.Unlock() - if c.ipp.Addr().Is4() { - return fmt.Errorf("registerConn6 got IPv4 %s", c.ipp) - } - return registerConn(&m.conns6, c) -} - -func (m *Machine) unregisterConn6(c *conn) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.conns6, c.ipp) -} - -func registerConn(conns *map[netip.AddrPort]*conn, c *conn) error { - if _, ok := (*conns)[c.ipp]; ok { - return fmt.Errorf("duplicate conn listening on %v", c.ipp) - } - if *conns == nil { - *conns = map[netip.AddrPort]*conn{} - } - (*conns)[c.ipp] = c - return nil -} - -func (m *Machine) AddNetwork(n *Network) {} - -func (m *Machine) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) { - // if udp4, udp6, etc... look at address IP vs unspec - var ( - fam uint8 - ip netip.Addr - ) - switch network { - default: - return nil, fmt.Errorf("unsupported network type %q", network) - case "udp": - fam = 0 - ip = v6unspec - case "udp4": - fam = 4 - ip = v4unspec - case "udp6": - fam = 6 - ip = v6unspec - } - - host, portStr, err := net.SplitHostPort(address) - if err != nil { - return nil, err - } - if host != "" { - ip, err = netip.ParseAddr(host) - if err != nil { - return nil, err - } - if fam == 0 && (ip != v4unspec && ip != v6unspec) { - // We got an explicit IP address, need to switch the - // family to the right one. - if ip.Is4() { - fam = 4 - } else { - fam = 6 - } - } - } - porti, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - return nil, err - } - port := uint16(porti) - if port == 0 { - port, err = m.pickEphemPort() - if err != nil { - return nil, nil - } - } - ipp := netip.AddrPortFrom(ip, port) - - c := &conn{ - m: m, - fam: fam, - ipp: ipp, - in: make(chan *Packet, 100), // arbitrary - } - switch c.fam { - case 0: - if err := m.registerConn4(c); err != nil { - return nil, err - } - if err := m.registerConn6(c); err != nil { - m.unregisterConn4(c) - return nil, err - } - case 4: - if err := m.registerConn4(c); err != nil { - return nil, err - } - case 6: - if err := m.registerConn6(c); err != nil { - return nil, err - } - } - return c, nil -} - -// conn is our net.PacketConn implementation -type conn struct { - m *Machine - fam uint8 // 0, 4, or 6 - ipp netip.AddrPort - - mu sync.Mutex - closed bool - readDeadline time.Time - activeReads map[*activeRead]bool - in chan *Packet -} - -type activeRead struct { - cancel context.CancelFunc -} - -// canRead reports whether we can do a read. -func (c *conn) canRead() error { - c.mu.Lock() - defer c.mu.Unlock() - if c.closed { - return net.ErrClosed - } - if !c.readDeadline.IsZero() && c.readDeadline.Before(time.Now()) { - return errors.New("read deadline exceeded") - } - return nil -} - -func (c *conn) registerActiveRead(ar *activeRead, active bool) { - c.mu.Lock() - defer c.mu.Unlock() - if c.activeReads == nil { - c.activeReads = make(map[*activeRead]bool) - } - if active { - c.activeReads[ar] = true - } else { - delete(c.activeReads, ar) - } -} - -func (c *conn) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - if c.closed { - return nil - } - c.closed = true - switch c.fam { - case 0: - c.m.unregisterConn4(c) - c.m.unregisterConn6(c) - case 4: - c.m.unregisterConn4(c) - case 6: - c.m.unregisterConn6(c) - } - c.breakActiveReadsLocked() - return nil -} - -func (c *conn) breakActiveReadsLocked() { - for ar := range c.activeReads { - ar.cancel() - } - c.activeReads = nil -} - -func (c *conn) LocalAddr() net.Addr { - return &net.UDPAddr{ - IP: c.ipp.Addr().AsSlice(), - Port: int(c.ipp.Port()), - Zone: c.ipp.Addr().Zone(), - } -} - -func (c *conn) Read(buf []byte) (int, error) { - panic("unimplemented stub") -} - -func (c *conn) RemoteAddr() net.Addr { - panic("unimplemented stub") -} - -func (c *conn) Write(buf []byte) (int, error) { - panic("unimplemented stub") -} - -func (c *conn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { - n, ap, err := c.ReadFromUDPAddrPort(p) - if err != nil { - return 0, nil, err - } - return n, net.UDPAddrFromAddrPort(ap), nil -} - -func (c *conn) ReadFromUDPAddrPort(p []byte) (n int, addr netip.AddrPort, err error) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ar := &activeRead{cancel: cancel} - - if err := c.canRead(); err != nil { - return 0, netip.AddrPort{}, err - } - - c.registerActiveRead(ar, true) - defer c.registerActiveRead(ar, false) - - select { - case pkt := <-c.in: - n = copy(p, pkt.Payload) - pkt.Trace("PacketConn.ReadFrom") - return n, pkt.Src, nil - case <-ctx.Done(): - return 0, netip.AddrPort{}, context.DeadlineExceeded - } -} - -func (c *conn) WriteTo(p []byte, addr net.Addr) (n int, err error) { - ipp, err := netip.ParseAddrPort(addr.String()) - if err != nil { - return 0, fmt.Errorf("bogus addr %T %q", addr, addr.String()) - } - return c.WriteToUDPAddrPort(p, ipp) -} - -func (c *conn) WriteToUDPAddrPort(p []byte, ipp netip.AddrPort) (n int, err error) { - pkt := &Packet{ - Src: c.ipp, - Dst: ipp, - Payload: bytes.Clone(p), - } - pkt.setLocator("mach=%s", c.m.Name) - pkt.Trace("PacketConn.WriteTo") - return c.m.writePacket(pkt) -} - -func (c *conn) SetDeadline(t time.Time) error { - panic("SetWriteDeadline unsupported; TODO when needed") -} -func (c *conn) SetWriteDeadline(t time.Time) error { - panic("SetWriteDeadline unsupported; TODO when needed") -} -func (c *conn) SetReadDeadline(t time.Time) error { - c.mu.Lock() - defer c.mu.Unlock() - - now := time.Now() - if t.After(now) { - panic("SetReadDeadline in the future not yet supported; TODO?") - } - - if !t.IsZero() && t.Before(now) { - c.breakActiveReadsLocked() - } - c.readDeadline = t - - return nil -} diff --git a/tstest/natlab/natlab_test.go b/tstest/natlab/natlab_test.go deleted file mode 100644 index 84388373236be..0000000000000 --- a/tstest/natlab/natlab_test.go +++ /dev/null @@ -1,509 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package natlab - -import ( - "context" - "fmt" - "net" - "net/netip" - "testing" - "time" - - "tailscale.com/tstest" -) - -func TestAllocIPs(t *testing.T) { - n := NewInternet() - saw := map[netip.Addr]bool{} - for range 255 { - for _, f := range []func(*Interface) netip.Addr{n.allocIPv4, n.allocIPv6} { - ip := f(nil) - if saw[ip] { - t.Fatalf("got duplicate %v", ip) - } - saw[ip] = true - } - } - - // This should work: - n.allocIPv6(nil) - - // But allocating another IPv4 should panic, exhausting the - // limited /24 range: - defer func() { - if e := recover(); fmt.Sprint(e) != "pool exhausted" { - t.Errorf("unexpected panic: %v", e) - } - }() - n.allocIPv4(nil) - t.Fatalf("expected panic from IPv4") -} - -func TestSendPacket(t *testing.T) { - internet := NewInternet() - - foo := &Machine{Name: "foo"} - bar := &Machine{Name: "bar"} - ifFoo := foo.Attach("eth0", internet) - ifBar := bar.Attach("enp0s1", internet) - - fooAddr := netip.AddrPortFrom(ifFoo.V4(), 123) - barAddr := netip.AddrPortFrom(ifBar.V4(), 456) - - ctx := context.Background() - fooPC, err := foo.ListenPacket(ctx, "udp4", fooAddr.String()) - if err != nil { - t.Fatal(err) - } - barPC, err := bar.ListenPacket(ctx, "udp4", barAddr.String()) - if err != nil { - t.Fatal(err) - } - - const msg = "some message" - if _, err := fooPC.WriteTo([]byte(msg), net.UDPAddrFromAddrPort(barAddr)); err != nil { - t.Fatal(err) - } - - buf := make([]byte, 1500) // TODO: care about MTUs in the natlab package somewhere - n, addr, err := barPC.ReadFrom(buf) - if err != nil { - t.Fatal(err) - } - buf = buf[:n] - if string(buf) != msg { - t.Errorf("read %q; want %q", buf, msg) - } - if addr.String() != fooAddr.String() { - t.Errorf("addr = %q; want %q", addr, fooAddr) - } -} - -func TestMultiNetwork(t *testing.T) { - lan := &Network{ - Name: "lan", - Prefix4: mustPrefix("192.168.0.0/24"), - } - internet := NewInternet() - - client := &Machine{Name: "client"} - nat := &Machine{Name: "nat"} - server := &Machine{Name: "server"} - - ifClient := client.Attach("eth0", lan) - ifNATWAN := nat.Attach("ethwan", internet) - ifNATLAN := nat.Attach("ethlan", lan) - ifServer := server.Attach("eth0", internet) - - ctx := context.Background() - clientPC, err := client.ListenPacket(ctx, "udp", ":123") - if err != nil { - t.Fatal(err) - } - natPC, err := nat.ListenPacket(ctx, "udp", ":456") - if err != nil { - t.Fatal(err) - } - serverPC, err := server.ListenPacket(ctx, "udp", ":789") - if err != nil { - t.Fatal(err) - } - - clientAddr := netip.AddrPortFrom(ifClient.V4(), 123) - natLANAddr := netip.AddrPortFrom(ifNATLAN.V4(), 456) - natWANAddr := netip.AddrPortFrom(ifNATWAN.V4(), 456) - serverAddr := netip.AddrPortFrom(ifServer.V4(), 789) - - const msg1, msg2 = "hello", "world" - if _, err := natPC.WriteTo([]byte(msg1), net.UDPAddrFromAddrPort(clientAddr)); err != nil { - t.Fatal(err) - } - if _, err := natPC.WriteTo([]byte(msg2), net.UDPAddrFromAddrPort(serverAddr)); err != nil { - t.Fatal(err) - } - - buf := make([]byte, 1500) - n, addr, err := clientPC.ReadFrom(buf) - if err != nil { - t.Fatal(err) - } - if string(buf[:n]) != msg1 { - t.Errorf("read %q; want %q", buf[:n], msg1) - } - if addr.String() != natLANAddr.String() { - t.Errorf("addr = %q; want %q", addr, natLANAddr) - } - - n, addr, err = serverPC.ReadFrom(buf) - if err != nil { - t.Fatal(err) - } - if string(buf[:n]) != msg2 { - t.Errorf("read %q; want %q", buf[:n], msg2) - } - if addr.String() != natWANAddr.String() { - t.Errorf("addr = %q; want %q", addr, natLANAddr) - } -} - -type trivialNAT struct { - clientIP netip.Addr - lanIf, wanIf *Interface -} - -func (n *trivialNAT) HandleIn(p *Packet, iface *Interface) *Packet { - if iface == n.wanIf && p.Dst.Addr() == n.wanIf.V4() { - p.Dst = netip.AddrPortFrom(n.clientIP, p.Dst.Port()) - } - return p -} - -func (n trivialNAT) HandleOut(p *Packet, iface *Interface) *Packet { - return p -} - -func (n *trivialNAT) HandleForward(p *Packet, iif, oif *Interface) *Packet { - // Outbound from LAN -> apply NAT, continue - if iif == n.lanIf && oif == n.wanIf { - if p.Src.Addr() == n.clientIP { - p.Src = netip.AddrPortFrom(n.wanIf.V4(), p.Src.Port()) - } - return p - } - // Return traffic to LAN, allow if right dst. - if iif == n.wanIf && oif == n.lanIf && p.Dst.Addr() == n.clientIP { - return p - } - // Else drop. - return nil -} - -func TestPacketHandler(t *testing.T) { - lan := &Network{ - Name: "lan", - Prefix4: mustPrefix("192.168.0.0/24"), - Prefix6: mustPrefix("fd00:916::/64"), - } - internet := NewInternet() - - client := &Machine{Name: "client"} - nat := &Machine{Name: "nat"} - server := &Machine{Name: "server"} - - ifClient := client.Attach("eth0", lan) - ifNATWAN := nat.Attach("wan", internet) - ifNATLAN := nat.Attach("lan", lan) - ifServer := server.Attach("server", internet) - - lan.SetDefaultGateway(ifNATLAN) - - nat.PacketHandler = &trivialNAT{ - clientIP: ifClient.V4(), - lanIf: ifNATLAN, - wanIf: ifNATWAN, - } - - ctx := context.Background() - clientPC, err := client.ListenPacket(ctx, "udp4", ":123") - if err != nil { - t.Fatal(err) - } - serverPC, err := server.ListenPacket(ctx, "udp4", ":456") - if err != nil { - t.Fatal(err) - } - - const msg = "some message" - serverAddr := netip.AddrPortFrom(ifServer.V4(), 456) - if _, err := clientPC.WriteTo([]byte(msg), net.UDPAddrFromAddrPort(serverAddr)); err != nil { - t.Fatal(err) - } - - buf := make([]byte, 1500) // TODO: care about MTUs in the natlab package somewhere - n, addr, err := serverPC.ReadFrom(buf) - if err != nil { - t.Fatal(err) - } - buf = buf[:n] - if string(buf) != msg { - t.Errorf("read %q; want %q", buf, msg) - } - mappedAddr := netip.AddrPortFrom(ifNATWAN.V4(), 123) - if addr.String() != mappedAddr.String() { - t.Errorf("addr = %q; want %q", addr, mappedAddr) - } -} - -func TestFirewall(t *testing.T) { - wan := NewInternet() - lan := &Network{ - Name: "lan", - Prefix4: mustPrefix("10.0.0.0/8"), - } - m := &Machine{Name: "test"} - trust := m.Attach("trust", lan) - untrust := m.Attach("untrust", wan) - - client := ipp("192.168.0.2:1234") - serverA := ipp("2.2.2.2:5678") - serverB1 := ipp("7.7.7.7:9012") - serverB2 := ipp("7.7.7.7:3456") - - t.Run("ip_port_dependent", func(t *testing.T) { - f := &Firewall{ - TrustedInterface: trust, - SessionTimeout: 30 * time.Second, - Type: AddressAndPortDependentFirewall, - } - testFirewall(t, f, []fwTest{ - // client -> A authorizes A -> client - {trust, untrust, client, serverA, true}, - {untrust, trust, serverA, client, true}, - {untrust, trust, serverA, client, true}, - - // B1 -> client fails until client -> B1 - {untrust, trust, serverB1, client, false}, - {trust, untrust, client, serverB1, true}, - {untrust, trust, serverB1, client, true}, - - // B2 -> client still fails - {untrust, trust, serverB2, client, false}, - }) - }) - t.Run("ip_dependent", func(t *testing.T) { - f := &Firewall{ - TrustedInterface: trust, - SessionTimeout: 30 * time.Second, - Type: AddressDependentFirewall, - } - testFirewall(t, f, []fwTest{ - // client -> A authorizes A -> client - {trust, untrust, client, serverA, true}, - {untrust, trust, serverA, client, true}, - {untrust, trust, serverA, client, true}, - - // B1 -> client fails until client -> B1 - {untrust, trust, serverB1, client, false}, - {trust, untrust, client, serverB1, true}, - {untrust, trust, serverB1, client, true}, - - // B2 -> client also works now - {untrust, trust, serverB2, client, true}, - }) - }) - t.Run("endpoint_independent", func(t *testing.T) { - f := &Firewall{ - TrustedInterface: trust, - SessionTimeout: 30 * time.Second, - Type: EndpointIndependentFirewall, - } - testFirewall(t, f, []fwTest{ - // client -> A authorizes A -> client - {trust, untrust, client, serverA, true}, - {untrust, trust, serverA, client, true}, - {untrust, trust, serverA, client, true}, - - // B1 -> client also works - {untrust, trust, serverB1, client, true}, - - // B2 -> client also works - {untrust, trust, serverB2, client, true}, - }) - }) -} - -type fwTest struct { - iif, oif *Interface - src, dst netip.AddrPort - ok bool -} - -func testFirewall(t *testing.T, f *Firewall, tests []fwTest) { - t.Helper() - clock := &tstest.Clock{} - f.TimeNow = clock.Now - for _, test := range tests { - clock.Advance(time.Second) - p := &Packet{ - Src: test.src, - Dst: test.dst, - Payload: []byte{}, - } - got := f.HandleForward(p, test.iif, test.oif) - gotOK := got != nil - if gotOK != test.ok { - t.Errorf("iif=%s oif=%s src=%s dst=%s got ok=%v, want ok=%v", test.iif, test.oif, test.src, test.dst, gotOK, test.ok) - } - } -} - -func ipp(str string) netip.AddrPort { - ipp, err := netip.ParseAddrPort(str) - if err != nil { - panic(err) - } - return ipp -} - -func TestNAT(t *testing.T) { - internet := NewInternet() - lan := &Network{ - Name: "LAN", - Prefix4: mustPrefix("192.168.0.0/24"), - } - m := &Machine{Name: "NAT"} - wanIf := m.Attach("wan", internet) - lanIf := m.Attach("lan", lan) - - t.Run("endpoint_independent_mapping", func(t *testing.T) { - n := &SNAT44{ - Machine: m, - ExternalInterface: wanIf, - Type: EndpointIndependentNAT, - Firewall: &Firewall{ - TrustedInterface: lanIf, - }, - } - testNAT(t, n, lanIf, wanIf, []natTest{ - { - src: ipp("192.168.0.20:1234"), - dst: ipp("2.2.2.2:5678"), - wantNewMapping: true, - }, - { - src: ipp("192.168.0.20:1234"), - dst: ipp("7.7.7.7:9012"), - wantNewMapping: false, - }, - { - src: ipp("192.168.0.20:2345"), - dst: ipp("7.7.7.7:9012"), - wantNewMapping: true, - }, - }) - }) - - t.Run("address_dependent_mapping", func(t *testing.T) { - n := &SNAT44{ - Machine: m, - ExternalInterface: wanIf, - Type: AddressDependentNAT, - Firewall: &Firewall{ - TrustedInterface: lanIf, - }, - } - testNAT(t, n, lanIf, wanIf, []natTest{ - { - src: ipp("192.168.0.20:1234"), - dst: ipp("2.2.2.2:5678"), - wantNewMapping: true, - }, - { - src: ipp("192.168.0.20:1234"), - dst: ipp("2.2.2.2:9012"), - wantNewMapping: false, - }, - { - src: ipp("192.168.0.20:1234"), - dst: ipp("7.7.7.7:9012"), - wantNewMapping: true, - }, - { - src: ipp("192.168.0.20:1234"), - dst: ipp("7.7.7.7:1234"), - wantNewMapping: false, - }, - }) - }) - - t.Run("address_and_port_dependent_mapping", func(t *testing.T) { - n := &SNAT44{ - Machine: m, - ExternalInterface: wanIf, - Type: AddressAndPortDependentNAT, - Firewall: &Firewall{ - TrustedInterface: lanIf, - }, - } - testNAT(t, n, lanIf, wanIf, []natTest{ - { - src: ipp("192.168.0.20:1234"), - dst: ipp("2.2.2.2:5678"), - wantNewMapping: true, - }, - { - src: ipp("192.168.0.20:1234"), - dst: ipp("2.2.2.2:9012"), - wantNewMapping: true, - }, - { - src: ipp("192.168.0.20:1234"), - dst: ipp("7.7.7.7:9012"), - wantNewMapping: true, - }, - { - src: ipp("192.168.0.20:1234"), - dst: ipp("7.7.7.7:1234"), - wantNewMapping: true, - }, - }) - }) -} - -type natTest struct { - src, dst netip.AddrPort - wantNewMapping bool -} - -func testNAT(t *testing.T, n *SNAT44, lanIf, wanIf *Interface, tests []natTest) { - clock := &tstest.Clock{} - n.TimeNow = clock.Now - - mappings := map[netip.AddrPort]bool{} - for _, test := range tests { - clock.Advance(time.Second) - p := &Packet{ - Src: test.src, - Dst: test.dst, - Payload: []byte("foo"), - } - gotPacket := n.HandleForward(p.Clone(), lanIf, wanIf) - if gotPacket == nil { - t.Errorf("n.HandleForward(%v) dropped packet", p) - continue - } - - if gotPacket.Dst != p.Dst { - t.Errorf("n.HandleForward(%v) mutated dest ip:port, got %v", p, gotPacket.Dst) - } - gotNewMapping := !mappings[gotPacket.Src] - if gotNewMapping != test.wantNewMapping { - t.Errorf("n.HandleForward(%v) mapping was new=%v, want %v", p, gotNewMapping, test.wantNewMapping) - } - mappings[gotPacket.Src] = true - - // Check that the return path works and translates back - // correctly. - clock.Advance(time.Second) - p2 := &Packet{ - Src: test.dst, - Dst: gotPacket.Src, - Payload: []byte("bar"), - } - gotPacket2 := n.HandleIn(p2.Clone(), wanIf) - - if gotPacket2 == nil { - t.Errorf("return packet was dropped") - continue - } - - if gotPacket2.Src != test.dst { - t.Errorf("return packet has src=%v, want %v", gotPacket2.Src, test.dst) - } - if gotPacket2.Dst != test.src { - t.Errorf("return packet has dst=%v, want %v", gotPacket2.Dst, test.src) - } - } -} diff --git a/tstest/natlab/vnet/conf.go b/tstest/natlab/vnet/conf.go deleted file mode 100644 index a37c22a6c8023..0000000000000 --- a/tstest/natlab/vnet/conf.go +++ /dev/null @@ -1,461 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vnet - -import ( - "cmp" - "fmt" - "iter" - "net/netip" - "os" - "slices" - "time" - - "github.com/google/gopacket/layers" - "github.com/google/gopacket/pcapgo" - "tailscale.com/types/logger" - "tailscale.com/util/must" - "tailscale.com/util/set" -) - -// Note: the exported Node and Network are the configuration types; -// the unexported node and network are the runtime types that are actually -// used once the server is created. - -// Config is the requested state of the natlab virtual network. -// -// The zero value is a valid empty configuration. Call AddNode -// and AddNetwork to methods on the returned Node and Network -// values to modify the config before calling NewServer. -// Once the NewServer is called, Config is no longer used. -type Config struct { - nodes []*Node - networks []*Network - pcapFile string - blendReality bool -} - -// SetPCAPFile sets the filename to write a pcap file to, -// or empty to disable pcap file writing. -func (c *Config) SetPCAPFile(file string) { - c.pcapFile = file -} - -// NumNodes returns the number of nodes in the configuration. -func (c *Config) NumNodes() int { - return len(c.nodes) -} - -// SetBlendReality sets whether to blend the real controlplane.tailscale.com and -// DERP servers into the virtual network. This is mostly useful for interactive -// testing when working on natlab. -func (c *Config) SetBlendReality(v bool) { - c.blendReality = v -} - -// FirstNetwork returns the first network in the config, or nil if none. -func (c *Config) FirstNetwork() *Network { - if len(c.networks) == 0 { - return nil - } - return c.networks[0] -} - -func (c *Config) Nodes() iter.Seq2[int, *Node] { - return slices.All(c.nodes) -} - -func nodeMac(n int) MAC { - // 52=TS then 0xcc for cccclient - return MAC{0x52, 0xcc, 0xcc, 0xcc, 0xcc, byte(n)} -} - -func routerMac(n int) MAC { - // 52=TS then 0xee for 'etwork - return MAC{0x52, 0xee, 0xee, 0xee, 0xee, byte(n)} -} - -var lanSLAACBase = netip.MustParseAddr("fe80::50cc:ccff:fecc:cc01") - -// nodeLANIP6 returns a node number's Link Local SLAAC IPv6 address, -// such as fe80::50cc:ccff:fecc:cc03 for node 3. -func nodeLANIP6(n int) netip.Addr { - a := lanSLAACBase.As16() - a[15] = byte(n) - return netip.AddrFrom16(a) -} - -// AddNode creates a new node in the world. -// -// The opts may be of the following types: -// - *Network: zero, one, or more networks to add this node to -// - TODO: more -// -// On an error or unknown opt type, AddNode returns a -// node with a carried error that gets returned later. -func (c *Config) AddNode(opts ...any) *Node { - num := len(c.nodes) + 1 - n := &Node{ - num: num, - mac: nodeMac(num), - } - c.nodes = append(c.nodes, n) - for _, o := range opts { - switch o := o.(type) { - case *Network: - if !slices.Contains(o.nodes, n) { - o.nodes = append(o.nodes, n) - } - n.nets = append(n.nets, o) - case TailscaledEnv: - n.env = append(n.env, o) - case NodeOption: - switch o { - case HostFirewall: - n.hostFW = true - case VerboseSyslog: - n.verboseSyslog = true - default: - if n.err == nil { - n.err = fmt.Errorf("unknown NodeOption %q", o) - } - } - default: - if n.err == nil { - n.err = fmt.Errorf("unknown AddNode option type %T", o) - } - } - } - return n -} - -// NodeOption is an option that can be passed to Config.AddNode. -type NodeOption string - -const ( - HostFirewall NodeOption = "HostFirewall" - VerboseSyslog NodeOption = "VerboseSyslog" -) - -// TailscaledEnv is а option that can be passed to Config.AddNode -// to set an environment variable for tailscaled. -type TailscaledEnv struct { - Key, Value string -} - -// AddNetwork add a new network. -// -// The opts may be of the following types: -// - string IP address, for the network's WAN IP (if any) -// - string netip.Prefix, for the network's LAN IP (defaults to 192.168.0.0/24) -// if IPv4, or its WAN IPv6 + CIDR (e.g. "2000:52::1/64") -// - NAT, the type of NAT to use -// - NetworkService, a service to add to the network -// -// On an error or unknown opt type, AddNetwork returns a -// network with a carried error that gets returned later. -func (c *Config) AddNetwork(opts ...any) *Network { - num := len(c.networks) + 1 - n := &Network{ - num: num, - mac: routerMac(num), - } - c.networks = append(c.networks, n) - for _, o := range opts { - switch o := o.(type) { - case string: - if ip, err := netip.ParseAddr(o); err == nil { - n.wanIP4 = ip - } else if ip, err := netip.ParsePrefix(o); err == nil { - // If the prefix is IPv4, treat it as the router's internal IPv4 address + CIDR. - // If the prefix is IPv6, treat it as the router's WAN IPv6 + CIDR (typically a /64). - if ip.Addr().Is4() { - n.lanIP4 = ip - } else if ip.Addr().Is6() { - n.wanIP6 = ip - } - } else { - if n.err == nil { - n.err = fmt.Errorf("unknown string option %q", o) - } - } - case NAT: - n.natType = o - case NetworkService: - n.AddService(o) - default: - if n.err == nil { - n.err = fmt.Errorf("unknown AddNetwork option type %T", o) - } - } - } - return n -} - -// Node is the configuration of a node in the virtual network. -type Node struct { - err error - num int // 1-based node number - n *node // nil until NewServer called - - env []TailscaledEnv - hostFW bool - verboseSyslog bool - - // TODO(bradfitz): this is halfway converted to supporting multiple NICs - // but not done. We need a MAC-per-Network. - - mac MAC - nets []*Network -} - -// Num returns the 1-based node number. -func (n *Node) Num() int { - return n.num -} - -// String returns the string "nodeN" where N is the 1-based node number. -func (n *Node) String() string { - return fmt.Sprintf("node%d", n.num) -} - -// MAC returns the MAC address of the node. -func (n *Node) MAC() MAC { - return n.mac -} - -func (n *Node) Env() []TailscaledEnv { - return n.env -} - -func (n *Node) HostFirewall() bool { - return n.hostFW -} - -func (n *Node) VerboseSyslog() bool { - return n.verboseSyslog -} - -func (n *Node) SetVerboseSyslog(v bool) { - n.verboseSyslog = v -} - -// IsV6Only reports whether this node is only connected to IPv6 networks. -func (n *Node) IsV6Only() bool { - for _, net := range n.nets { - if net.CanV4() { - return false - } - } - for _, net := range n.nets { - if net.CanV6() { - return true - } - } - return false -} - -// Network returns the first network this node is connected to, -// or nil if none. -func (n *Node) Network() *Network { - if len(n.nets) == 0 { - return nil - } - return n.nets[0] -} - -// Network is the configuration of a network in the virtual network. -type Network struct { - num int // 1-based - mac MAC // MAC address of the router/gateway - natType NAT - - wanIP6 netip.Prefix // global unicast router in host bits; CIDR is /64 delegated to LAN - - wanIP4 netip.Addr // IPv4 WAN IP, if any - lanIP4 netip.Prefix - nodes []*Node - breakWAN4 bool // whether to break WAN IPv4 connectivity - - svcs set.Set[NetworkService] - - latency time.Duration // latency applied to interface writes - lossRate float64 // chance of packet loss (0.0 to 1.0) - - // ... - err error // carried error -} - -// SetLatency sets the simulated network latency for this network. -func (n *Network) SetLatency(d time.Duration) { - n.latency = d -} - -// SetPacketLoss sets the packet loss rate for this network 0.0 (no loss) to 1.0 (total loss). -func (n *Network) SetPacketLoss(rate float64) { - if rate < 0 { - rate = 0 - } else if rate > 1 { - rate = 1 - } - n.lossRate = rate -} - -// SetBlackholedIPv4 sets whether the network should blackhole all IPv4 traffic -// out to the Internet. (DHCP etc continues to work on the LAN.) -func (n *Network) SetBlackholedIPv4(v bool) { - n.breakWAN4 = v -} - -func (n *Network) CanV4() bool { - return n.lanIP4.IsValid() || n.wanIP4.IsValid() -} - -func (n *Network) CanV6() bool { - return n.wanIP6.IsValid() -} - -func (n *Network) CanTakeMoreNodes() bool { - if n.natType == One2OneNAT { - return len(n.nodes) == 0 - } - return len(n.nodes) < 150 -} - -// NetworkService is a service that can be added to a network. -type NetworkService string - -const ( - NATPMP NetworkService = "NAT-PMP" - PCP NetworkService = "PCP" - UPnP NetworkService = "UPnP" -) - -// AddService adds a network service (such as port mapping protocols) to a -// network. -func (n *Network) AddService(s NetworkService) { - if n.svcs == nil { - n.svcs = set.Of(s) - } else { - n.svcs.Add(s) - } -} - -// initFromConfig initializes the server from the previous calls -// to NewNode and NewNetwork and returns an error if -// there were any configuration issues. -func (s *Server) initFromConfig(c *Config) error { - netOfConf := map[*Network]*network{} - if c.pcapFile != "" { - pcf, err := os.OpenFile(c.pcapFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) - if err != nil { - return err - } - nw, err := pcapgo.NewNgWriter(pcf, layers.LinkTypeEthernet) - if err != nil { - return err - } - pw := &pcapWriter{ - f: pcf, - w: nw, - } - s.pcapWriter = pw - } - for i, conf := range c.networks { - if conf.err != nil { - return conf.err - } - if !conf.lanIP4.IsValid() && !conf.wanIP6.IsValid() { - conf.lanIP4 = netip.MustParsePrefix("192.168.0.0/24") - } - n := &network{ - num: conf.num, - s: s, - mac: conf.mac, - portmap: conf.svcs.Contains(NATPMP), // TODO: expand network.portmap - wanIP6: conf.wanIP6, - v4: conf.lanIP4.IsValid(), - v6: conf.wanIP6.IsValid(), - wanIP4: conf.wanIP4, - lanIP4: conf.lanIP4, - breakWAN4: conf.breakWAN4, - latency: conf.latency, - lossRate: conf.lossRate, - nodesByIP4: map[netip.Addr]*node{}, - nodesByMAC: map[MAC]*node{}, - logf: logger.WithPrefix(s.logf, fmt.Sprintf("[net-%v] ", conf.mac)), - } - netOfConf[conf] = n - s.networks.Add(n) - if conf.wanIP4.IsValid() { - if conf.wanIP4.Is6() { - return fmt.Errorf("invalid IPv6 address in wanIP") - } - if _, ok := s.networkByWAN.Lookup(conf.wanIP4); ok { - return fmt.Errorf("two networks have the same WAN IP %v; Anycast not (yet?) supported", conf.wanIP4) - } - s.networkByWAN.Insert(netip.PrefixFrom(conf.wanIP4, 32), n) - } - if conf.wanIP6.IsValid() { - if conf.wanIP6.Addr().Is4() { - return fmt.Errorf("invalid IPv4 address in wanIP6") - } - if _, ok := s.networkByWAN.LookupPrefix(conf.wanIP6); ok { - return fmt.Errorf("two networks have the same WAN IPv6 %v; Anycast not (yet?) supported", conf.wanIP6) - } - s.networkByWAN.Insert(conf.wanIP6, n) - } - n.lanInterfaceID = must.Get(s.pcapWriter.AddInterface(pcapgo.NgInterface{ - Name: fmt.Sprintf("network%d-lan", i+1), - LinkType: layers.LinkTypeIPv4, - })) - n.wanInterfaceID = must.Get(s.pcapWriter.AddInterface(pcapgo.NgInterface{ - Name: fmt.Sprintf("network%d-wan", i+1), - LinkType: layers.LinkTypeIPv4, - })) - } - for _, conf := range c.nodes { - if conf.err != nil { - return conf.err - } - n := &node{ - num: conf.num, - mac: conf.mac, - net: netOfConf[conf.Network()], - verboseSyslog: conf.VerboseSyslog(), - } - n.interfaceID = must.Get(s.pcapWriter.AddInterface(pcapgo.NgInterface{ - Name: n.String(), - LinkType: layers.LinkTypeEthernet, - })) - conf.n = n - if _, ok := s.nodeByMAC[n.mac]; ok { - return fmt.Errorf("two nodes have the same MAC %v", n.mac) - } - s.nodes = append(s.nodes, n) - s.nodeByMAC[n.mac] = n - - if n.net.v4 { - // Allocate a lanIP for the node. Use the network's CIDR and use final - // octet 101 (for first node), 102, etc. The node number comes from the - // last octent of the MAC address (0-based) - ip4 := n.net.lanIP4.Addr().As4() - ip4[3] = 100 + n.mac[5] - n.lanIP = netip.AddrFrom4(ip4) - n.net.nodesByIP4[n.lanIP] = n - } - n.net.nodesByMAC[n.mac] = n - } - - // Now that nodes are populated, set up NAT: - for _, conf := range c.networks { - n := netOfConf[conf] - natType := cmp.Or(conf.natType, EasyNAT) - if err := n.InitNAT(natType); err != nil { - return err - } - } - - return nil -} diff --git a/tstest/natlab/vnet/conf_test.go b/tstest/natlab/vnet/conf_test.go deleted file mode 100644 index 6566ac8cf4610..0000000000000 --- a/tstest/natlab/vnet/conf_test.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vnet - -import ( - "testing" - "time" -) - -func TestConfig(t *testing.T) { - tests := []struct { - name string - setup func(*Config) - wantErr string - }{ - { - name: "simple", - setup: func(c *Config) { - c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", EasyNAT, NATPMP)) - c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", HardNAT)) - }, - }, - { - name: "latency-and-loss", - setup: func(c *Config) { - n1 := c.AddNetwork("2.1.1.1", "192.168.1.1/24", EasyNAT, NATPMP) - n1.SetLatency(time.Second) - n1.SetPacketLoss(0.1) - c.AddNode(n1) - c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", HardNAT)) - }, - }, - { - name: "indirect", - setup: func(c *Config) { - n1 := c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", HardNAT)) - n1.Network().AddService(NATPMP) - c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", NAT("hard"))) - }, - }, - { - name: "multi-node-in-net", - setup: func(c *Config) { - net1 := c.AddNetwork("2.1.1.1", "192.168.1.1/24") - c.AddNode(net1) - c.AddNode(net1) - }, - }, - { - name: "dup-wan-ip", - setup: func(c *Config) { - c.AddNetwork("2.1.1.1", "192.168.1.1/24") - c.AddNetwork("2.1.1.1", "10.2.0.1/16") - }, - wantErr: "two networks have the same WAN IP 2.1.1.1; Anycast not (yet?) supported", - }, - { - name: "one-to-one-nat-with-multiple-nodes", - setup: func(c *Config) { - net1 := c.AddNetwork("2.1.1.1", "192.168.1.1/24", One2OneNAT) - c.AddNode(net1) - c.AddNode(net1) - }, - wantErr: "error creating NAT type \"one2one\" for network 2.1.1.1: can't use one2one NAT type on networks other than single-node networks", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var c Config - tt.setup(&c) - _, err := New(&c) - if err == nil { - if tt.wantErr == "" { - return - } - t.Fatalf("got success; wanted error %q", tt.wantErr) - } - if err.Error() != tt.wantErr { - t.Fatalf("got error %q; want %q", err, tt.wantErr) - } - }) - } -} - -func TestNodeString(t *testing.T) { - if g, w := (&Node{num: 1}).String(), "node1"; g != w { - t.Errorf("got %q; want %q", g, w) - } - if g, w := (&node{num: 1}).String(), "node1"; g != w { - t.Errorf("got %q; want %q", g, w) - } -} diff --git a/tstest/natlab/vnet/easyaf.go b/tstest/natlab/vnet/easyaf.go deleted file mode 100644 index 0901bbdffdd7d..0000000000000 --- a/tstest/natlab/vnet/easyaf.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vnet - -import ( - "log" - "math/rand/v2" - "net/netip" - "time" - - "tailscale.com/util/mak" -) - -// easyAFNAT is an "Endpoint Independent" NAT, like Linux and most home routers -// (many of which are Linux), but with only address filtering, not address+port -// filtering. -// -// James says these are used by "anyone with “voip helpers” turned on" -// "which is a lot of home modem routers" ... "probably like most of the zyxel -// type things". -type easyAFNAT struct { - pool IPPool - wanIP netip.Addr - out map[netip.Addr]portMappingAndTime - in map[uint16]lanAddrAndTime - lastOut map[srcAPDstAddrTuple]time.Time // (lan:port, wan:port) => last packet out time -} - -type srcAPDstAddrTuple struct { - src netip.AddrPort - dst netip.Addr -} - -func init() { - registerNATType(EasyAFNAT, func(p IPPool) (NATTable, error) { - return &easyAFNAT{pool: p, wanIP: p.WANIP()}, nil - }) -} - -func (n *easyAFNAT) IsPublicPortUsed(ap netip.AddrPort) bool { - if ap.Addr() != n.wanIP { - return false - } - _, ok := n.in[ap.Port()] - return ok -} - -func (n *easyAFNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) { - mak.Set(&n.lastOut, srcAPDstAddrTuple{src, dst.Addr()}, at) - if pm, ok := n.out[src.Addr()]; ok { - // Existing flow. - // TODO: bump timestamp - return netip.AddrPortFrom(n.wanIP, pm.port) - } - - // Loop through all 32k high (ephemeral) ports, starting at a random - // position and looping back around to the start. - start := rand.N(uint16(32 << 10)) - for off := range uint16(32 << 10) { - port := 32<<10 + (start+off)%(32<<10) - if _, ok := n.in[port]; !ok { - wanAddr := netip.AddrPortFrom(n.wanIP, port) - if n.pool.IsPublicPortUsed(wanAddr) { - continue - } - - // Found a free port. - mak.Set(&n.out, src.Addr(), portMappingAndTime{port: port, at: at}) - mak.Set(&n.in, port, lanAddrAndTime{lanAddr: src, at: at}) - return wanAddr - } - } - return netip.AddrPort{} // failed to allocate a mapping; TODO: fire an alert? -} - -func (n *easyAFNAT) PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort) { - if dst.Addr() != n.wanIP { - return netip.AddrPort{} // drop; not for us. shouldn't happen if natlabd routing isn't broken. - } - lanDst = n.in[dst.Port()].lanAddr - - // Stateful firewall: drop incoming packets that don't have traffic out. - // TODO(bradfitz): verify Linux does this in the router code, not in the NAT code. - if t, ok := n.lastOut[srcAPDstAddrTuple{lanDst, src.Addr()}]; !ok || at.Sub(t) > 300*time.Second { - log.Printf("Drop incoming packet from %v to %v; no recent outgoing packet", src, dst) - return netip.AddrPort{} - } - - return lanDst -} diff --git a/tstest/natlab/vnet/nat.go b/tstest/natlab/vnet/nat.go deleted file mode 100644 index ad6f29b3adb58..0000000000000 --- a/tstest/natlab/vnet/nat.go +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vnet - -import ( - "errors" - "log" - "math/rand/v2" - "net/netip" - "time" - - "tailscale.com/util/mak" -) - -const ( - One2OneNAT NAT = "one2one" - EasyNAT NAT = "easy" // address+port filtering - EasyAFNAT NAT = "easyaf" // address filtering (not port) - HardNAT NAT = "hard" -) - -// IPPool is the interface that a NAT implementation uses to get information -// about a network. -// -// Outside of tests, this is typically a *network. -type IPPool interface { - // WANIP returns the primary WAN IP address. - // - // TODO: add another method for networks with multiple WAN IP addresses. - WANIP() netip.Addr - - // SoleLanIP reports whether this network has a sole LAN client - // and if so, its IP address. - SoleLANIP() (_ netip.Addr, ok bool) - - // IsPublicPortUsed reports whether the provided WAN IP+port is in use by - // anything. (In particular, the NAT-PMP/etc port mappers might have taken - // a port.) Implementations should check this before allocating a port, - // and then they should report IsPublicPortUsed themselves for that port. - IsPublicPortUsed(netip.AddrPort) bool -} - -// newTableFunc is a constructor for a NAT table. -// The provided IPPool is typically (outside of tests) a *network. -type newTableFunc func(IPPool) (NATTable, error) - -// NAT is a type of NAT that's known to natlab. -// -// For example, "easy" for Linux-style NAT, "hard" for FreeBSD-style NAT, etc. -type NAT string - -// natTypes are the known NAT types. -var natTypes = map[NAT]newTableFunc{} - -// registerNATType registers a NAT type. -func registerNATType(name NAT, f newTableFunc) { - if _, ok := natTypes[name]; ok { - panic("duplicate NAT type: " + name) - } - natTypes[name] = f -} - -// NATTable is what a NAT implementation is expected to do. -// -// This project tests Tailscale as it faces various combinations various NAT -// implementations (e.g. Linux easy style NAT vs FreeBSD hard/endpoint dependent -// NAT vs Cloud 1:1 NAT, etc) -// -// Implementations of NATTable need not handle concurrency; the natlab serializes -// all calls into a NATTable. -// -// The provided `at` value will typically be time.Now, except for tests. -// Implementations should not use real time and should only compare -// previously provided time values. -type NATTable interface { - // PickOutgoingSrc returns the source address to use for an outgoing packet. - // - // The result should either be invalid (to drop the packet) or a WAN (not - // private) IP address. - // - // Typically, the src is a LAN source IP address, but it might also be a WAN - // IP address if the packet is being forwarded for a source machine that has - // a public IP address. - PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) - - // PickIncomingDst returns the destination address to use for an incoming - // packet. The incoming src address is always a public WAN IP. - // - // The result should either be invalid (to drop the packet) or the IP - // address of a machine on the local network address, usually a private - // LAN IP. - PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort) - - // IsPublicPortUsed reports whether the provided WAN IP+port is in use by - // anything. The port mapper uses this to avoid grabbing an in-use port. - IsPublicPortUsed(netip.AddrPort) bool -} - -// oneToOneNAT is a 1:1 NAT, like a typical EC2 VM. -type oneToOneNAT struct { - lanIP netip.Addr - wanIP netip.Addr -} - -func init() { - registerNATType(One2OneNAT, func(p IPPool) (NATTable, error) { - lanIP, ok := p.SoleLANIP() - if !ok { - return nil, errors.New("can't use one2one NAT type on networks other than single-node networks") - } - return &oneToOneNAT{lanIP: lanIP, wanIP: p.WANIP()}, nil - }) -} - -func (n *oneToOneNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) { - return netip.AddrPortFrom(n.wanIP, src.Port()) -} - -func (n *oneToOneNAT) PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort) { - return netip.AddrPortFrom(n.lanIP, dst.Port()) -} - -func (n *oneToOneNAT) IsPublicPortUsed(netip.AddrPort) bool { - return true // all ports are owned by the 1:1 NAT -} - -type srcDstTuple struct { - src netip.AddrPort - dst netip.AddrPort -} - -type hardKeyIn struct { - wanPort uint16 - src netip.AddrPort -} - -type portMappingAndTime struct { - port uint16 - at time.Time -} - -type lanAddrAndTime struct { - lanAddr netip.AddrPort - at time.Time -} - -// hardNAT is an "Endpoint Dependent" NAT, like FreeBSD/pfSense/OPNsense. -// This is shown as "MappingVariesByDestIP: true" by netcheck, and what -// Tailscale calls "Hard NAT". -type hardNAT struct { - pool IPPool - wanIP netip.Addr - - out map[srcDstTuple]portMappingAndTime - in map[hardKeyIn]lanAddrAndTime -} - -func init() { - registerNATType(HardNAT, func(p IPPool) (NATTable, error) { - return &hardNAT{pool: p, wanIP: p.WANIP()}, nil - }) -} - -func (n *hardNAT) IsPublicPortUsed(ap netip.AddrPort) bool { - if ap.Addr() != n.wanIP { - return false - } - for k := range n.in { - if k.wanPort == ap.Port() { - return true - } - } - return false -} - -func (n *hardNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) { - ko := srcDstTuple{src, dst} - if pm, ok := n.out[ko]; ok { - // Existing flow. - // TODO: bump timestamp - return netip.AddrPortFrom(n.wanIP, pm.port) - } - - // No existing mapping exists. Create one. - - // TODO: clean up old expired mappings - - // Instead of proper data structures that would be efficient, we instead - // just loop a bunch and look for a free port. This project is only used - // by tests and doesn't care about performance, this is good enough. - for { - port := rand.N(uint16(32<<10)) + 32<<10 // pick some "ephemeral" port - if n.pool.IsPublicPortUsed(netip.AddrPortFrom(n.wanIP, port)) { - continue - } - - ki := hardKeyIn{wanPort: port, src: dst} - if _, ok := n.in[ki]; ok { - // Port already in use. - continue - } - mak.Set(&n.in, ki, lanAddrAndTime{lanAddr: src, at: at}) - mak.Set(&n.out, ko, portMappingAndTime{port: port, at: at}) - return netip.AddrPortFrom(n.wanIP, port) - } -} - -func (n *hardNAT) PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort) { - if dst.Addr() != n.wanIP { - return netip.AddrPort{} // drop; not for us. shouldn't happen if natlabd routing isn't broken. - } - ki := hardKeyIn{wanPort: dst.Port(), src: src} - if pm, ok := n.in[ki]; ok { - // Existing flow. - return pm.lanAddr - } - return netip.AddrPort{} // drop; no mapping -} - -// easyNAT is an "Endpoint Independent" NAT, like Linux and most home routers -// (many of which are Linux). -// -// This is shown as "MappingVariesByDestIP: false" by netcheck, and what -// Tailscale calls "Easy NAT". -// -// Unlike Linux, this implementation is capped at 32k entries and doesn't resort -// to other allocation strategies when all 32k WAN ports are taken. -type easyNAT struct { - pool IPPool - wanIP netip.Addr - out map[netip.AddrPort]portMappingAndTime - in map[uint16]lanAddrAndTime - lastOut map[srcDstTuple]time.Time // (lan:port, wan:port) => last packet out time -} - -func init() { - registerNATType(EasyNAT, func(p IPPool) (NATTable, error) { - return &easyNAT{pool: p, wanIP: p.WANIP()}, nil - }) -} - -func (n *easyNAT) IsPublicPortUsed(ap netip.AddrPort) bool { - if ap.Addr() != n.wanIP { - return false - } - _, ok := n.in[ap.Port()] - return ok -} - -func (n *easyNAT) PickOutgoingSrc(src, dst netip.AddrPort, at time.Time) (wanSrc netip.AddrPort) { - mak.Set(&n.lastOut, srcDstTuple{src, dst}, at) - if pm, ok := n.out[src]; ok { - // Existing flow. - // TODO: bump timestamp - return netip.AddrPortFrom(n.wanIP, pm.port) - } - - // Loop through all 32k high (ephemeral) ports, starting at a random - // position and looping back around to the start. - start := rand.N(uint16(32 << 10)) - for off := range uint16(32 << 10) { - port := 32<<10 + (start+off)%(32<<10) - if _, ok := n.in[port]; !ok { - wanAddr := netip.AddrPortFrom(n.wanIP, port) - if n.pool.IsPublicPortUsed(wanAddr) { - continue - } - - // Found a free port. - mak.Set(&n.out, src, portMappingAndTime{port: port, at: at}) - mak.Set(&n.in, port, lanAddrAndTime{lanAddr: src, at: at}) - return wanAddr - } - } - return netip.AddrPort{} // failed to allocate a mapping; TODO: fire an alert? -} - -func (n *easyNAT) PickIncomingDst(src, dst netip.AddrPort, at time.Time) (lanDst netip.AddrPort) { - if dst.Addr() != n.wanIP { - return netip.AddrPort{} // drop; not for us. shouldn't happen if natlabd routing isn't broken. - } - lanDst = n.in[dst.Port()].lanAddr - - // Stateful firewall: drop incoming packets that don't have traffic out. - // TODO(bradfitz): verify Linux does this in the router code, not in the NAT code. - if t, ok := n.lastOut[srcDstTuple{lanDst, src}]; !ok || at.Sub(t) > 300*time.Second { - log.Printf("Drop incoming packet from %v to %v; no recent outgoing packet", src, dst) - return netip.AddrPort{} - } - - return lanDst -} diff --git a/tstest/natlab/vnet/pcap.go b/tstest/natlab/vnet/pcap.go deleted file mode 100644 index 41a443e30b6c5..0000000000000 --- a/tstest/natlab/vnet/pcap.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vnet - -import ( - "io" - "os" - "sync" - - "github.com/google/gopacket" - "github.com/google/gopacket/pcapgo" -) - -// pcapWriter is a pcapgo.NgWriter that writes to a file. -// It is safe for concurrent use. The nil value is a no-op. -type pcapWriter struct { - f *os.File - - mu sync.Mutex - w *pcapgo.NgWriter -} - -func do(fs ...func() error) error { - for _, f := range fs { - if err := f(); err != nil { - return err - } - } - return nil -} - -func (p *pcapWriter) WritePacket(ci gopacket.CaptureInfo, data []byte) error { - if p == nil { - return nil - } - p.mu.Lock() - defer p.mu.Unlock() - if p.w == nil { - return io.ErrClosedPipe - } - return do( - func() error { return p.w.WritePacket(ci, data) }, - p.w.Flush, - p.f.Sync, - ) -} - -func (p *pcapWriter) AddInterface(i pcapgo.NgInterface) (int, error) { - if p == nil { - return 0, nil - } - p.mu.Lock() - defer p.mu.Unlock() - return p.w.AddInterface(i) -} - -func (p *pcapWriter) Close() error { - if p == nil { - return nil - } - p.mu.Lock() - defer p.mu.Unlock() - if p.w != nil { - p.w.Flush() - p.w = nil - } - return p.f.Close() -} diff --git a/tstest/natlab/vnet/vip.go b/tstest/natlab/vnet/vip.go deleted file mode 100644 index c75f17cee5393..0000000000000 --- a/tstest/natlab/vnet/vip.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vnet - -import ( - "fmt" - "net/netip" -) - -var vips = map[string]virtualIP{} // DNS name => details - -var ( - fakeDNS = newVIP("dns", "4.11.4.11", "2411::411") - fakeProxyControlplane = newVIP("controlplane.tailscale.com", 1) - fakeTestAgent = newVIP("test-driver.tailscale", 2) - fakeControl = newVIP("control.tailscale", 3) - fakeDERP1 = newVIP("derp1.tailscale", "33.4.0.1") // 3340=DERP; 1=derp 1 - fakeDERP2 = newVIP("derp2.tailscale", "33.4.0.2") // 3340=DERP; 2=derp 2 - fakeLogCatcher = newVIP("log.tailscale.io", 4) - fakeSyslog = newVIP("syslog.tailscale", 9) -) - -type virtualIP struct { - name string // for DNS - v4 netip.Addr - v6 netip.Addr -} - -func (v virtualIP) Match(a netip.Addr) bool { - return v.v4 == a.Unmap() || v.v6 == a -} - -// FakeDNSIPv4 returns the fake DNS IPv4 address. -func FakeDNSIPv4() netip.Addr { return fakeDNS.v4 } - -// FakeDNSIPv6 returns the fake DNS IPv6 address. -func FakeDNSIPv6() netip.Addr { return fakeDNS.v6 } - -// FakeSyslogIPv4 returns the fake syslog IPv4 address. -func FakeSyslogIPv4() netip.Addr { return fakeSyslog.v4 } - -// FakeSyslogIPv6 returns the fake syslog IPv6 address. -func FakeSyslogIPv6() netip.Addr { return fakeSyslog.v6 } - -// newVIP returns a new virtual IP. -// -// opts may be an IPv4 an IPv6 (in string form) or an int (bounded by uint8) to -// use IPv4 of 52.52.0.x. -// -// If the IPv6 is omitted, one is derived from the IPv4. -// -// If an opt is invalid or the DNS name is already used, it panics. -func newVIP(name string, opts ...any) (v virtualIP) { - if _, ok := vips[name]; ok { - panic(fmt.Sprintf("duplicate VIP %q", name)) - } - v.name = name - for _, o := range opts { - switch o := o.(type) { - case string: - if ip, err := netip.ParseAddr(o); err == nil { - if ip.Is4() { - v.v4 = ip - } else if ip.Is6() { - v.v6 = ip - } - } else { - panic(fmt.Sprintf("unsupported string option %q", o)) - } - case int: - if o <= 0 || o > 255 { - panic(fmt.Sprintf("bad octet %d", o)) - } - v.v4 = netip.AddrFrom4([4]byte{52, 52, 0, byte(o)}) - default: - panic(fmt.Sprintf("unknown option type %T", o)) - } - } - if !v.v6.IsValid() && v.v4.IsValid() { - // Map 1.2.3.4 to 2052::0102:0304 - // But make 52.52.0.x map to 2052::x - a := [16]byte{0: 0x20, 1: 0x52} // 2052:: - v4 := v.v4.As4() - if v4[0] == 52 && v4[1] == 52 && v4[2] == 0 { - a[15] = v4[3] - } else { - copy(a[12:], v.v4.AsSlice()) - } - v.v6 = netip.AddrFrom16(a) - } - for _, b := range vips { - if b.Match(v.v4) || b.Match(v.v6) { - panic(fmt.Sprintf("VIP %q collides with %q", name, v.name)) - } - } - vips[name] = v - return v -} diff --git a/tstest/natlab/vnet/vnet.go b/tstest/natlab/vnet/vnet.go deleted file mode 100644 index 92312c039bfc9..0000000000000 --- a/tstest/natlab/vnet/vnet.go +++ /dev/null @@ -1,2242 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package vnet simulates a virtual Internet containing a set of networks with various -// NAT behaviors. You can then plug VMs into the virtual internet at different points -// to test Tailscale working end-to-end in various network conditions. -// -// See https://github.com/tailscale/tailscale/issues/13038 -package vnet - -// TODO: -// - [ ] tests for NAT tables - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "iter" - "log" - "maps" - "math/rand/v2" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "os/exec" - "strconv" - "sync" - "sync/atomic" - "time" - - "github.com/gaissmai/bart" - "github.com/google/gopacket" - "github.com/google/gopacket/layers" - "go4.org/mem" - "gvisor.dev/gvisor/pkg/buffer" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" - "gvisor.dev/gvisor/pkg/tcpip/header" - "gvisor.dev/gvisor/pkg/tcpip/link/channel" - "gvisor.dev/gvisor/pkg/tcpip/network/arp" - "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" - "gvisor.dev/gvisor/pkg/tcpip/network/ipv6" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "gvisor.dev/gvisor/pkg/tcpip/transport/icmp" - "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" - "gvisor.dev/gvisor/pkg/waiter" - "tailscale.com/client/tailscale" - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/net/netutil" - "tailscale.com/net/stun" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstest/integration/testcontrol" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/mak" - "tailscale.com/util/must" - "tailscale.com/util/set" - "tailscale.com/util/zstdframe" -) - -const nicID = 1 - -const ( - stunPort = 3478 - pcpPort = 5351 - ssdpPort = 1900 -) - -func (s *Server) PopulateDERPMapIPs() error { - out, err := exec.Command("tailscale", "debug", "derp-map").Output() - if err != nil { - return fmt.Errorf("tailscale debug derp-map: %v", err) - } - var dm tailcfg.DERPMap - if err := json.Unmarshal(out, &dm); err != nil { - return fmt.Errorf("unmarshal DERPMap: %v", err) - } - for _, r := range dm.Regions { - for _, n := range r.Nodes { - if n.IPv4 != "" { - s.derpIPs.Add(netip.MustParseAddr(n.IPv4)) - } - } - } - return nil -} - -func (n *network) InitNAT(natType NAT) error { - ctor, ok := natTypes[natType] - if !ok { - return fmt.Errorf("unknown NAT type %q", natType) - } - t, err := ctor(n) - if err != nil { - return fmt.Errorf("error creating NAT type %q for network %v: %w", natType, n.wanIP4, err) - } - n.setNATTable(t) - n.natStyle.Store(natType) - return nil -} - -func (n *network) setNATTable(nt NATTable) { - n.natMu.Lock() - defer n.natMu.Unlock() - n.natTable = nt -} - -// SoleLANIP implements [IPPool]. -func (n *network) SoleLANIP() (netip.Addr, bool) { - if len(n.nodesByIP4) != 1 { - return netip.Addr{}, false - } - for ip := range n.nodesByIP4 { - return ip, true - } - return netip.Addr{}, false -} - -// WANIP implements [IPPool]. -func (n *network) WANIP() netip.Addr { return n.wanIP4 } - -func (n *network) initStack() error { - n.ns = stack.New(stack.Options{ - NetworkProtocols: []stack.NetworkProtocolFactory{ - ipv4.NewProtocol, - ipv6.NewProtocol, - arp.NewProtocol, - }, - TransportProtocols: []stack.TransportProtocolFactory{ - tcp.NewProtocol, - icmp.NewProtocol4, - }, - }) - sackEnabledOpt := tcpip.TCPSACKEnabled(true) // TCP SACK is disabled by default - tcpipErr := n.ns.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt) - if tcpipErr != nil { - return fmt.Errorf("SetTransportProtocolOption SACK: %v", tcpipErr) - } - n.linkEP = channel.New(512, 1500, tcpip.LinkAddress(n.mac.HWAddr())) - if tcpipProblem := n.ns.CreateNIC(nicID, n.linkEP); tcpipProblem != nil { - return fmt.Errorf("CreateNIC: %v", tcpipProblem) - } - n.ns.SetPromiscuousMode(nicID, true) - n.ns.SetSpoofing(nicID, true) - - var routes []tcpip.Route - - if n.v4 { - prefix := tcpip.AddrFrom4Slice(n.lanIP4.Addr().AsSlice()).WithPrefix() - prefix.PrefixLen = n.lanIP4.Bits() - if tcpProb := n.ns.AddProtocolAddress(nicID, tcpip.ProtocolAddress{ - Protocol: ipv4.ProtocolNumber, - AddressWithPrefix: prefix, - }, stack.AddressProperties{}); tcpProb != nil { - return errors.New(tcpProb.String()) - } - - ipv4Subnet, err := tcpip.NewSubnet(tcpip.AddrFromSlice(make([]byte, 4)), tcpip.MaskFromBytes(make([]byte, 4))) - if err != nil { - return fmt.Errorf("could not create IPv4 subnet: %v", err) - } - routes = append(routes, tcpip.Route{ - Destination: ipv4Subnet, - NIC: nicID, - }) - } - if n.v6 { - prefix := tcpip.AddrFrom16(n.wanIP6.Addr().As16()).WithPrefix() - prefix.PrefixLen = n.wanIP6.Bits() - if tcpProb := n.ns.AddProtocolAddress(nicID, tcpip.ProtocolAddress{ - Protocol: ipv6.ProtocolNumber, - AddressWithPrefix: prefix, - }, stack.AddressProperties{}); tcpProb != nil { - return errors.New(tcpProb.String()) - } - - ipv6Subnet, err := tcpip.NewSubnet(tcpip.AddrFromSlice(make([]byte, 16)), tcpip.MaskFromBytes(make([]byte, 16))) - if err != nil { - return fmt.Errorf("could not create IPv6 subnet: %v", err) - } - routes = append(routes, tcpip.Route{ - Destination: ipv6Subnet, - NIC: nicID, - }) - } - - n.ns.SetRouteTable(routes) - - const tcpReceiveBufferSize = 0 // default - const maxInFlightConnectionAttempts = 8192 - tcpFwd := tcp.NewForwarder(n.ns, tcpReceiveBufferSize, maxInFlightConnectionAttempts, n.acceptTCP) - n.ns.SetTransportProtocolHandler(tcp.ProtocolNumber, func(tei stack.TransportEndpointID, pb *stack.PacketBuffer) (handled bool) { - return tcpFwd.HandlePacket(tei, pb) - }) - - go func() { - for { - pkt := n.linkEP.ReadContext(n.s.shutdownCtx) - if pkt == nil { - if n.s.shutdownCtx.Err() != nil { - // Return without logging. - return - } - continue - } - n.handleIPPacketFromGvisor(pkt.ToView().AsSlice()) - } - }() - return nil -} - -func (n *network) handleIPPacketFromGvisor(ipRaw []byte) { - if len(ipRaw) == 0 { - panic("empty packet from gvisor") - } - var goPkt gopacket.Packet - ipVer := ipRaw[0] >> 4 // 4 or 6 - switch ipVer { - case 4: - goPkt = gopacket.NewPacket( - ipRaw, - layers.LayerTypeIPv4, gopacket.Lazy) - case 6: - goPkt = gopacket.NewPacket( - ipRaw, - layers.LayerTypeIPv6, gopacket.Lazy) - default: - panic(fmt.Sprintf("unexpected IP packet version %v", ipVer)) - } - flow, ok := flow(goPkt) - if !ok { - panic("unexpected gvisor packet") - } - node, ok := n.nodeByIP(flow.dst) - if !ok { - n.logf("no node for netstack dest IP %v", flow.dst) - return - } - eth := &layers.Ethernet{ - SrcMAC: n.mac.HWAddr(), - DstMAC: node.mac.HWAddr(), - } - sls := []gopacket.SerializableLayer{ - eth, - } - for _, layer := range goPkt.Layers() { - sl, ok := layer.(gopacket.SerializableLayer) - if !ok { - log.Fatalf("layer %s is not serializable", layer.LayerType().String()) - } - sls = append(sls, sl) - } - - resPkt, err := mkPacket(sls...) - if err != nil { - n.logf("gvisor: serialize error: %v", err) - return - } - if nw, ok := n.writers.Load(node.mac); ok { - nw.write(resPkt) - } else { - n.logf("gvisor write: no writeFunc for %v", node.mac) - } -} - -func netaddrIPFromNetstackIP(s tcpip.Address) netip.Addr { - switch s.Len() { - case 4: - return netip.AddrFrom4(s.As4()) - case 16: - return netip.AddrFrom16(s.As16()).Unmap() - } - return netip.Addr{} -} - -func stringifyTEI(tei stack.TransportEndpointID) string { - localHostPort := net.JoinHostPort(tei.LocalAddress.String(), strconv.Itoa(int(tei.LocalPort))) - remoteHostPort := net.JoinHostPort(tei.RemoteAddress.String(), strconv.Itoa(int(tei.RemotePort))) - return fmt.Sprintf("%s -> %s", remoteHostPort, localHostPort) -} - -func (n *network) acceptTCP(r *tcp.ForwarderRequest) { - reqDetails := r.ID() - - clientRemoteIP := netaddrIPFromNetstackIP(reqDetails.RemoteAddress) - destIP := netaddrIPFromNetstackIP(reqDetails.LocalAddress) - destPort := reqDetails.LocalPort - if !clientRemoteIP.IsValid() { - r.Complete(true) // sends a RST - return - } - - log.Printf("vnet-AcceptTCP: %v", stringifyTEI(reqDetails)) - - var wq waiter.Queue - ep, err := r.CreateEndpoint(&wq) - if err != nil { - log.Printf("CreateEndpoint error for %s: %v", stringifyTEI(reqDetails), err) - r.Complete(true) // sends a RST - return - } - ep.SocketOptions().SetKeepAlive(true) - - if destPort == 123 { - r.Complete(false) - tc := gonet.NewTCPConn(&wq, ep) - io.WriteString(tc, "Hello from Go\nGoodbye.\n") - tc.Close() - return - } - - if destPort == 8008 && fakeTestAgent.Match(destIP) { - node, ok := n.nodeByIP(clientRemoteIP) - if !ok { - n.logf("unknown client IP %v trying to connect to test driver", clientRemoteIP) - r.Complete(true) - return - } - r.Complete(false) - tc := gonet.NewTCPConn(&wq, ep) - ac := &agentConn{node, tc} - n.s.addIdleAgentConn(ac) - return - } - - if destPort == 80 && fakeControl.Match(destIP) { - r.Complete(false) - tc := gonet.NewTCPConn(&wq, ep) - hs := &http.Server{Handler: n.s.control} - go hs.Serve(netutil.NewOneConnListener(tc, nil)) - return - } - - if fakeDERP1.Match(destIP) || fakeDERP2.Match(destIP) { - if destPort == 443 { - ds := n.s.derps[0] - if fakeDERP2.Match(destIP) { - ds = n.s.derps[1] - } - - r.Complete(false) - tc := gonet.NewTCPConn(&wq, ep) - tlsConn := tls.Server(tc, ds.tlsConfig) - hs := &http.Server{Handler: ds.handler} - go hs.Serve(netutil.NewOneConnListener(tlsConn, nil)) - return - } - if destPort == 80 { - r.Complete(false) - tc := gonet.NewTCPConn(&wq, ep) - hs := &http.Server{Handler: n.s.derps[0].handler} - go hs.Serve(netutil.NewOneConnListener(tc, nil)) - return - } - } - if destPort == 443 && fakeLogCatcher.Match(destIP) { - r.Complete(false) - tc := gonet.NewTCPConn(&wq, ep) - go n.serveLogCatcherConn(clientRemoteIP, tc) - return - } - - var targetDial string - if n.s.derpIPs.Contains(destIP) { - targetDial = destIP.String() + ":" + strconv.Itoa(int(destPort)) - } else if fakeProxyControlplane.Match(destIP) { - targetDial = "controlplane.tailscale.com:" + strconv.Itoa(int(destPort)) - } - if targetDial != "" { - c, err := net.Dial("tcp", targetDial) - if err != nil { - r.Complete(true) - log.Printf("Dial controlplane: %v", err) - return - } - defer c.Close() - tc := gonet.NewTCPConn(&wq, ep) - defer tc.Close() - r.Complete(false) - errc := make(chan error, 2) - go func() { _, err := io.Copy(tc, c); errc <- err }() - go func() { _, err := io.Copy(c, tc); errc <- err }() - <-errc - } else { - r.Complete(true) // sends a RST - } -} - -// serveLogCatchConn serves a TCP connection to "log.tailscale.io", speaking the -// logtail/logcatcher protocol. -// -// We terminate TLS with an arbitrary cert; the client is configured to not -// validate TLS certs for this hostname when running under these integration -// tests. -func (n *network) serveLogCatcherConn(clientRemoteIP netip.Addr, c net.Conn) { - tlsConfig := n.s.derps[0].tlsConfig // self-signed (stealing DERP's); test client configure to not check - tlsConn := tls.Server(c, tlsConfig) - var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - all, _ := io.ReadAll(r.Body) - if r.Header.Get("Content-Encoding") == "zstd" { - var err error - all, err = zstdframe.AppendDecode(nil, all) - if err != nil { - log.Printf("LOGS DECODE ERROR zstd decode: %v", err) - http.Error(w, "zstd decode error", http.StatusBadRequest) - return - } - } - var logs []struct { - Logtail struct { - Client_Time time.Time - } - Text string - } - if err := json.Unmarshal(all, &logs); err != nil { - log.Printf("Logs decode error: %v", err) - return - } - node := n.nodesByIP4[clientRemoteIP] - if node != nil { - node.logMu.Lock() - defer node.logMu.Unlock() - node.logCatcherWrites++ - for _, lg := range logs { - tStr := lg.Logtail.Client_Time.Round(time.Millisecond).Format(time.RFC3339Nano) - fmt.Fprintf(&node.logBuf, "[%v] %s\n", tStr, lg.Text) - } - } - }) - hs := &http.Server{Handler: handler} - hs.Serve(netutil.NewOneConnListener(tlsConn, nil)) -} - -type EthernetPacket struct { - le *layers.Ethernet - gp gopacket.Packet -} - -func (ep EthernetPacket) SrcMAC() MAC { - return MAC(ep.le.SrcMAC) -} - -func (ep EthernetPacket) DstMAC() MAC { - return MAC(ep.le.DstMAC) -} - -type MAC [6]byte - -func (m MAC) IsBroadcast() bool { - return m == MAC{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} -} - -// IsIPv6Multicast reports whether m is an IPv6 multicast MAC address, -// typically one containing a solicited-node multicast address. -func (m MAC) IsIPv6Multicast() bool { - return m[0] == 0x33 && m[1] == 0x33 -} - -func macOf(hwa net.HardwareAddr) (_ MAC, ok bool) { - if len(hwa) != 6 { - return MAC{}, false - } - return MAC(hwa), true -} - -func (m MAC) HWAddr() net.HardwareAddr { - return net.HardwareAddr(m[:]) -} - -func (m MAC) String() string { - return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", m[0], m[1], m[2], m[3], m[4], m[5]) -} - -type portMapping struct { - dst netip.AddrPort // LAN IP:port - expiry time.Time -} - -// writerFunc is a function that writes an Ethernet frame to a connected client. -// -// ethFrame is the Ethernet frame to write. -// -// interfaceIndexID is the interface ID for the pcap file. -type writerFunc func(dst vmClient, ethFrame []byte, interfaceIndexID int) - -// networkWriter are the arguments to a writerFunc and the writerFunc. -type networkWriter struct { - writer writerFunc // Function to write packets to the network - c vmClient - interfaceID int // The interface ID of the src node (for writing pcaps) -} - -func (nw networkWriter) write(b []byte) { - nw.writer(nw.c, b, nw.interfaceID) -} - -type network struct { - s *Server - num int // 1-based - mac MAC // of router - portmap bool - lanInterfaceID int - wanInterfaceID int - v4 bool // network supports IPv4 - v6 bool // network support IPv6 - wanIP6 netip.Prefix // router's WAN IPv6, if any, as a /64. - wanIP4 netip.Addr // router's LAN IPv4, if any - lanIP4 netip.Prefix // router's LAN IP + CIDR (e.g. 192.168.2.1/24) - breakWAN4 bool // break WAN IPv4 connectivity - latency time.Duration // latency applied to interface writes - lossRate float64 // probability of dropping a packet (0.0 to 1.0) - nodesByIP4 map[netip.Addr]*node // by LAN IPv4 - nodesByMAC map[MAC]*node - logf func(format string, args ...any) - - ns *stack.Stack - linkEP *channel.Endpoint - - natStyle syncs.AtomicValue[NAT] - natMu sync.Mutex // held while using + changing natTable - natTable NATTable - portMap map[netip.AddrPort]portMapping // WAN ip:port -> LAN ip:port - portMapFlow map[portmapFlowKey]netip.AddrPort // (lanAP, peerWANAP) -> portmapped wanAP - - macMu sync.Mutex - macOfIPv6 map[netip.Addr]MAC // IPv6 source IP -> MAC - - // writers is a map of MAC -> networkWriters to write packets to that MAC. - // It contains entries for connected nodes only. - writers syncs.Map[MAC, networkWriter] // MAC -> to networkWriter for that MAC -} - -// registerWriter registers a client address with a MAC address. -func (n *network) registerWriter(mac MAC, c vmClient) { - nw := networkWriter{ - writer: n.s.writeEthernetFrameToVM, - c: c, - } - if node, ok := n.s.nodeByMAC[mac]; ok { - nw.interfaceID = node.interfaceID - } - n.writers.Store(mac, nw) -} - -func (n *network) unregisterWriter(mac MAC) { - n.writers.Delete(mac) -} - -// RegisteredWritersForTest returns the number of registered connections (VM -// guests with a known MAC to whom a packet can be sent) there are to the -// server. It exists for testing. -func (s *Server) RegisteredWritersForTest() int { - num := 0 - for n := range s.networks { - num += n.writers.Len() - } - return num -} - -func (n *network) MACOfIP(ip netip.Addr) (_ MAC, ok bool) { - if n.lanIP4.Addr() == ip { - return n.mac, true - } - if n, ok := n.nodesByIP4[ip]; ok { - return n.mac, true - } - return MAC{}, false -} - -type node struct { - mac MAC - num int // 1-based node number - interfaceID int - net *network - lanIP netip.Addr // must be in net.lanIP prefix + unique in net - verboseSyslog bool - - // logMu guards logBuf. - // TODO(bradfitz): conditionally write these out to separate files at the end? - // Currently they only hold logcatcher logs. - logMu sync.Mutex - logBuf bytes.Buffer - logCatcherWrites int -} - -// String returns the string "nodeN" where N is the 1-based node number. -func (n *node) String() string { - return fmt.Sprintf("node%d", n.num) -} - -type derpServer struct { - srv *derp.Server - handler http.Handler - tlsConfig *tls.Config -} - -func newDERPServer() *derpServer { - // Just to get a self-signed TLS cert: - ts := httptest.NewTLSServer(nil) - ts.Close() - - ds := &derpServer{ - srv: derp.NewServer(key.NewNode(), logger.Discard), - tlsConfig: ts.TLS, // self-signed; test client configure to not check - } - var mux http.ServeMux - mux.Handle("/derp", derphttp.Handler(ds.srv)) - mux.HandleFunc("/generate_204", derphttp.ServeNoContent) - - ds.handler = &mux - return ds -} - -type Server struct { - shutdownCtx context.Context - shutdownCancel context.CancelFunc - shuttingDown atomic.Bool - wg sync.WaitGroup - blendReality bool - - optLogf func(format string, args ...any) // or nil to use log.Printf - - derpIPs set.Set[netip.Addr] - - nodes []*node - nodeByMAC map[MAC]*node - networks set.Set[*network] - networkByWAN *bart.Table[*network] - - control *testcontrol.Server - derps []*derpServer - pcapWriter *pcapWriter - - // writeMu serializes all writes to VM clients. - writeMu sync.Mutex - scratch []byte - - mu sync.Mutex - agentConnWaiter map[*node]chan<- struct{} // signaled after added to set - agentConns set.Set[*agentConn] // not keyed by node; should be small/cheap enough to scan all - agentDialer map[*node]DialFunc -} - -func (s *Server) logf(format string, args ...any) { - if s.optLogf != nil { - s.optLogf(format, args...) - } else { - log.Printf(format, args...) - } -} - -func (s *Server) SetLoggerForTest(logf func(format string, args ...any)) { - s.optLogf = logf -} - -type DialFunc func(ctx context.Context, network, address string) (net.Conn, error) - -var derpMap = &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - RegionID: 1, - RegionCode: "atlantis", - RegionName: "Atlantis", - Nodes: []*tailcfg.DERPNode{ - { - Name: "1a", - RegionID: 1, - HostName: "derp1.tailscale", - IPv4: fakeDERP1.v4.String(), - IPv6: fakeDERP1.v6.String(), - InsecureForTests: true, - CanPort80: true, - }, - }, - }, - 2: { - RegionID: 2, - RegionCode: "northpole", - RegionName: "North Pole", - Nodes: []*tailcfg.DERPNode{ - { - Name: "2a", - RegionID: 2, - HostName: "derp2.tailscale", - IPv4: fakeDERP2.v4.String(), - IPv6: fakeDERP2.v6.String(), - InsecureForTests: true, - CanPort80: true, - }, - }, - }, - }, -} - -func New(c *Config) (*Server, error) { - ctx, cancel := context.WithCancel(context.Background()) - s := &Server{ - shutdownCtx: ctx, - shutdownCancel: cancel, - - control: &testcontrol.Server{ - DERPMap: derpMap, - ExplicitBaseURL: "http://control.tailscale", - }, - - blendReality: c.blendReality, - derpIPs: set.Of[netip.Addr](), - - nodeByMAC: map[MAC]*node{}, - networkByWAN: &bart.Table[*network]{}, - networks: set.Of[*network](), - } - for range 2 { - s.derps = append(s.derps, newDERPServer()) - } - if err := s.initFromConfig(c); err != nil { - return nil, err - } - for n := range s.networks { - if err := n.initStack(); err != nil { - return nil, fmt.Errorf("newServer: initStack: %v", err) - } - } - - return s, nil -} - -func (s *Server) Close() { - if shutdown := s.shuttingDown.Swap(true); !shutdown { - s.shutdownCancel() - s.pcapWriter.Close() - } - s.wg.Wait() -} - -// MACs returns the MAC addresses of the configured nodes. -func (s *Server) MACs() iter.Seq[MAC] { - return maps.Keys(s.nodeByMAC) -} - -func (s *Server) RegisterSinkForTest(mac MAC, fn func(eth []byte)) { - n, ok := s.nodeByMAC[mac] - if !ok { - log.Fatalf("RegisterSinkForTest: unknown MAC %v", mac) - } - n.net.writers.Store(mac, networkWriter{ - writer: func(_ vmClient, eth []byte, _ int) { - fn(eth) - }, - }) -} - -func (s *Server) HWAddr(mac MAC) net.HardwareAddr { - // TODO: cache - return net.HardwareAddr(mac[:]) -} - -type Protocol int - -const ( - ProtocolQEMU = Protocol(iota + 1) - ProtocolUnixDGRAM // for macOS Virtualization.Framework and VZFileHandleNetworkDeviceAttachment -) - -func (s *Server) writeEthernetFrameToVM(c vmClient, ethPkt []byte, interfaceID int) { - s.writeMu.Lock() - defer s.writeMu.Unlock() - - if ethPkt == nil { - return - } - switch c.proto() { - case ProtocolQEMU: - s.scratch = binary.BigEndian.AppendUint32(s.scratch[:0], uint32(len(ethPkt))) - s.scratch = append(s.scratch, ethPkt...) - if _, err := c.uc.Write(s.scratch); err != nil { - s.logf("Write pkt: %v", err) - } - - case ProtocolUnixDGRAM: - if _, err := c.uc.WriteToUnix(ethPkt, c.raddr); err != nil { - s.logf("Write pkt : %v", err) - return - } - } - - must.Do(s.pcapWriter.WritePacket(gopacket.CaptureInfo{ - Timestamp: time.Now(), - CaptureLength: len(ethPkt), - Length: len(ethPkt), - InterfaceIndex: interfaceID, - }, ethPkt)) -} - -// vmClient is a comparable value representing a connection from a VM, either a -// QEMU-style client (with streams over a Unix socket) or a datagram based -// client (such as macOS Virtualization.framework clients). -type vmClient struct { - uc *net.UnixConn - raddr *net.UnixAddr // nil for QEMU-style clients using streams; else datagram source -} - -func (c vmClient) proto() Protocol { - if c.raddr == nil { - return ProtocolQEMU - } - return ProtocolUnixDGRAM -} - -func parseEthernet(pkt []byte) (dst, src MAC, ethType layers.EthernetType, payload []byte, ok bool) { - // headerLen is the length of an Ethernet header: - // 6 bytes of destination MAC, 6 bytes of source MAC, 2 bytes of EtherType. - const headerLen = 14 - if len(pkt) < headerLen { - return - } - dst = MAC(pkt[0:6]) - src = MAC(pkt[6:12]) - ethType = layers.EthernetType(binary.BigEndian.Uint16(pkt[12:14])) - payload = pkt[headerLen:] - ok = true - return -} - -// Handles a single connection from a QEMU-style client or muxd connections for dgram mode -func (s *Server) ServeUnixConn(uc *net.UnixConn, proto Protocol) { - if s.shuttingDown.Load() { - return - } - s.wg.Add(1) - defer s.wg.Done() - context.AfterFunc(s.shutdownCtx, func() { - uc.SetDeadline(time.Now()) - }) - s.logf("Got conn %T %p", uc, uc) - defer uc.Close() - - buf := make([]byte, 16<<10) - didReg := map[MAC]bool{} - for { - var packetRaw []byte - var raddr *net.UnixAddr - - switch proto { - case ProtocolUnixDGRAM: - n, addr, err := uc.ReadFromUnix(buf) - raddr = addr - if err != nil { - if s.shutdownCtx.Err() != nil { - // Return without logging. - return - } - s.logf("ReadFromUnix: %#v", err) - continue - } - packetRaw = buf[:n] - case ProtocolQEMU: - if _, err := io.ReadFull(uc, buf[:4]); err != nil { - if s.shutdownCtx.Err() != nil { - // Return without logging. - return - } - s.logf("ReadFull header: %v", err) - return - } - n := binary.BigEndian.Uint32(buf[:4]) - - if _, err := io.ReadFull(uc, buf[4:4+n]); err != nil { - if s.shutdownCtx.Err() != nil { - // Return without logging. - return - } - s.logf("ReadFull pkt: %v", err) - return - } - packetRaw = buf[4 : 4+n] // raw ethernet frame - } - c := vmClient{uc, raddr} - - // For the first packet from a MAC, register a writerFunc to write to the VM. - _, srcMAC, _, _, ok := parseEthernet(packetRaw) - if !ok { - continue - } - srcNode, ok := s.nodeByMAC[srcMAC] - if !ok { - s.logf("[conn %p] got frame from unknown MAC %v", c.uc, srcMAC) - continue - } - if !didReg[srcMAC] { - didReg[srcMAC] = true - s.logf("[conn %p] Registering writer for MAC %v, node %v", c.uc, srcMAC, srcNode.lanIP) - srcNode.net.registerWriter(srcMAC, c) - defer srcNode.net.unregisterWriter(srcMAC) - } - - if err := s.handleEthernetFrameFromVM(packetRaw); err != nil { - srcNode.net.logf("handleEthernetFrameFromVM: [conn %p], %v", c.uc, err) - } - } -} - -func (s *Server) handleEthernetFrameFromVM(packetRaw []byte) error { - packet := gopacket.NewPacket(packetRaw, layers.LayerTypeEthernet, gopacket.Lazy) - le, ok := packet.LinkLayer().(*layers.Ethernet) - if !ok || len(le.SrcMAC) != 6 || len(le.DstMAC) != 6 { - return fmt.Errorf("ignoring non-Ethernet packet: % 02x", packetRaw) - } - ep := EthernetPacket{le, packet} - - srcMAC := ep.SrcMAC() - srcNode, ok := s.nodeByMAC[srcMAC] - if !ok { - return fmt.Errorf("got frame from unknown MAC %v", srcMAC) - } - - must.Do(s.pcapWriter.WritePacket(gopacket.CaptureInfo{ - Timestamp: time.Now(), - CaptureLength: len(packetRaw), - Length: len(packetRaw), - InterfaceIndex: srcNode.interfaceID, - }, packetRaw)) - srcNode.net.HandleEthernetPacket(ep) - return nil -} - -func (s *Server) routeUDPPacket(up UDPPacket) { - // Find which network owns this based on the destination IP - // and all the known networks' wan IPs. - - // But certain things (like STUN) we do in-process. - if up.Dst.Port() == stunPort { - // TODO(bradfitz): fake latency; time.AfterFunc the response - if res, ok := makeSTUNReply(up); ok { - //log.Printf("STUN reply: %+v", res) - s.routeUDPPacket(res) - } else { - log.Printf("weird: STUN packet not handled") - } - return - } - - dstIP := up.Dst.Addr() - netw, ok := s.networkByWAN.Lookup(dstIP) - if !ok { - if dstIP.IsPrivate() { - // Not worth spamming logs. RFC 1918 space doesn't route. - return - } - log.Printf("no network to route UDP packet for %v", up.Dst) - return - } - netw.HandleUDPPacket(up) -} - -// writeEth writes a raw Ethernet frame to all (0, 1, or multiple) connected -// clients on the network. -// -// This only delivers to client devices and not the virtual router/gateway -// device. -// -// It reports whether a packet was written to any clients. -func (n *network) writeEth(res []byte) bool { - dstMAC, srcMAC, etherType, _, ok := parseEthernet(res) - if !ok { - return false - } - - if dstMAC.IsBroadcast() || (n.v6 && etherType == layers.EthernetTypeIPv6 && dstMAC == macAllNodes) { - num := 0 - for mac, nw := range n.writers.All() { - if mac != srcMAC { - num++ - n.conditionedWrite(nw, res) - } - } - return num > 0 - } - if srcMAC == dstMAC { - n.logf("dropping write of packet from %v to itself", srcMAC) - return false - } - if nw, ok := n.writers.Load(dstMAC); ok { - n.conditionedWrite(nw, res) - return true - } - - const debugMiss = false - if debugMiss { - gp := gopacket.NewPacket(res, layers.LayerTypeEthernet, gopacket.Lazy) - n.logf("no writeFunc for dst %v from src %v; pkt=%v", dstMAC, srcMAC, gp) - } - - return false -} - -func (n *network) conditionedWrite(nw networkWriter, packet []byte) { - if n.lossRate > 0 && rand.Float64() < n.lossRate { - // packet lost - return - } - if n.latency > 0 { - // copy the packet as there's no guarantee packet is owned long enough. - // TODO(raggi): this could be optimized substantially if necessary, - // a pool of buffers and a cheaper delay mechanism are both obvious improvements. - var pkt = make([]byte, len(packet)) - copy(pkt, packet) - time.AfterFunc(n.latency, func() { nw.write(pkt) }) - } else { - nw.write(packet) - } -} - -var ( - macAllNodes = MAC{0: 0x33, 1: 0x33, 5: 0x01} - macAllRouters = MAC{0: 0x33, 1: 0x33, 5: 0x02} - macBroadcast = MAC{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} -) - -const ( - testingEthertype layers.EthernetType = 0x1234 -) - -func (n *network) HandleEthernetPacket(ep EthernetPacket) { - packet := ep.gp - dstMAC := ep.DstMAC() - isBroadcast := dstMAC.IsBroadcast() || (n.v6 && ep.le.EthernetType == layers.EthernetTypeIPv6 && dstMAC == macAllNodes) - isV6SpecialMAC := dstMAC[0] == 0x33 && dstMAC[1] == 0x33 - - // forRouter is whether the packet is destined for the router itself - // or if it's a special thing (like V6 NDP) that the router should handle. - forRouter := dstMAC == n.mac || isBroadcast || isV6SpecialMAC - - const debug = false - if debug { - n.logf("HandleEthernetPacket: %v => %v; type %v, bcast=%v, forRouter=%v", ep.SrcMAC(), ep.DstMAC(), ep.le.EthernetType, isBroadcast, forRouter) - } - - switch ep.le.EthernetType { - default: - n.logf("Dropping non-IP packet: %v", ep.le.EthernetType) - return - case 0x1234: - // Permitted for testing. Not a real ethertype. - case layers.EthernetTypeARP: - res, err := n.createARPResponse(packet) - if err != nil { - n.logf("createARPResponse: %v", err) - } else { - n.writeEth(res) - } - return - case layers.EthernetTypeIPv6: - if !n.v6 { - n.logf("dropping IPv6 packet on v4-only network") - return - } - if dstMAC == macAllRouters { - if rs, ok := ep.gp.Layer(layers.LayerTypeICMPv6RouterSolicitation).(*layers.ICMPv6RouterSolicitation); ok { - n.handleIPv6RouterSolicitation(ep, rs) - } else { - n.logf("unexpected IPv6 packet to all-routers: %v", ep.gp) - } - return - } - isMcast := dstMAC.IsIPv6Multicast() - if isMcast || dstMAC == n.mac { - if ns, ok := ep.gp.Layer(layers.LayerTypeICMPv6NeighborSolicitation).(*layers.ICMPv6NeighborSolicitation); ok { - n.handleIPv6NeighborSolicitation(ep, ns) - return - } - if ep.gp.Layer(layers.LayerTypeMLDv2MulticastListenerReport) != nil { - // We don't care about these (yet?) and Linux spams a bunch - // a bunch of them out, so explicitly ignore them to prevent - // log spam when verbose logging is enabled. - return - } - if isMcast && !isBroadcast { - return - } - } - - // TODO(bradfitz): handle packets to e.g. [fe80::50cc:ccff:fecc:cc01]:43619 - // and don't fall through to the router below. - - case layers.EthernetTypeIPv4: - // Below - } - - // Send ethernet broadcasts and unicast ethernet frames to peers - // on the same network. This is all LAN traffic that isn't meant - // for the router/gw itself: - if isBroadcast || !forRouter { - n.writeEth(ep.gp.Data()) - } - - if forRouter { - n.HandleEthernetPacketForRouter(ep) - } -} - -// HandleUDPPacket handles a UDP packet arriving from the internet, -// addressed to the router's WAN IP. It is then NATed back to a -// LAN IP here and wrapped in an ethernet layer and delivered -// to the network. -func (n *network) HandleUDPPacket(p UDPPacket) { - buf, err := n.serializedUDPPacket(p.Src, p.Dst, p.Payload, nil) - if err != nil { - n.logf("serializing UDP packet: %v", err) - return - } - n.s.pcapWriter.WritePacket(gopacket.CaptureInfo{ - Timestamp: time.Now(), - CaptureLength: len(buf), - Length: len(buf), - InterfaceIndex: n.wanInterfaceID, - }, buf) - if p.Dst.Addr().Is4() && n.breakWAN4 { - // Blackhole the packet. - return - } - dst := n.doNATIn(p.Src, p.Dst) - if !dst.IsValid() { - n.logf("Warning: NAT dropped packet; no mapping for %v=>%v", p.Src, p.Dst) - return - } - p.Dst = dst - buf, err = n.serializedUDPPacket(p.Src, p.Dst, p.Payload, nil) - if err != nil { - n.logf("serializing UDP packet: %v", err) - return - } - n.s.pcapWriter.WritePacket(gopacket.CaptureInfo{ - Timestamp: time.Now(), - CaptureLength: len(buf), - Length: len(buf), - InterfaceIndex: n.lanInterfaceID, - }, buf) - n.WriteUDPPacketNoNAT(p) -} - -func (n *network) nodeByIP(ip netip.Addr) (node *node, ok bool) { - if ip.Is4() { - node, ok = n.nodesByIP4[ip] - } - if !ok && ip.Is6() { - var mac MAC - n.macMu.Lock() - mac, ok = n.macOfIPv6[ip] - n.macMu.Unlock() - if !ok { - log.Printf("warning: no known MAC for IPv6 %v", ip) - return nil, false - } - node, ok = n.nodesByMAC[mac] - if !ok { - log.Printf("warning: no known node for MAC %v (IP %v)", mac, ip) - } - } - return node, ok -} - -// WriteUDPPacketNoNAT writes a UDP packet to the network, without -// doing any NAT translation. -// -// The packet will always have the ethernet src MAC of the router -// so this should not be used for packets between clients on the -// same ethernet segment. -func (n *network) WriteUDPPacketNoNAT(p UDPPacket) { - src, dst := p.Src, p.Dst - node, ok := n.nodeByIP(dst.Addr()) - if !ok { - n.logf("no node for dest IP %v in UDP packet %v=>%v", dst.Addr(), p.Src, p.Dst) - return - } - - eth := &layers.Ethernet{ - SrcMAC: n.mac.HWAddr(), // of gateway - DstMAC: node.mac.HWAddr(), - } - ethRaw, err := n.serializedUDPPacket(src, dst, p.Payload, eth) - if err != nil { - n.logf("serializing UDP packet: %v", err) - return - } - n.writeEth(ethRaw) -} - -type serializableNetworkLayer interface { - gopacket.SerializableLayer - gopacket.NetworkLayer -} - -func mkIPLayer(proto layers.IPProtocol, src, dst netip.Addr) serializableNetworkLayer { - if src.Is4() { - return &layers.IPv4{ - Protocol: proto, - SrcIP: src.AsSlice(), - DstIP: dst.AsSlice(), - } - } - if src.Is6() { - return &layers.IPv6{ - NextHeader: proto, - SrcIP: src.AsSlice(), - DstIP: dst.AsSlice(), - } - } - panic("invalid src IP") -} - -// serializedUDPPacket serializes a UDP packet with the given source and -// destination IP:port pairs, and payload. -// -// If eth is non-nil, it will be used as the Ethernet layer, otherwise the -// Ethernet layer will be omitted from the serialization. -func (n *network) serializedUDPPacket(src, dst netip.AddrPort, payload []byte, eth *layers.Ethernet) ([]byte, error) { - ip := mkIPLayer(layers.IPProtocolUDP, src.Addr(), dst.Addr()) - udp := &layers.UDP{ - SrcPort: layers.UDPPort(src.Port()), - DstPort: layers.UDPPort(dst.Port()), - } - if eth == nil { - return mkPacket(ip, udp, gopacket.Payload(payload)) - } else { - return mkPacket(eth, ip, udp, gopacket.Payload(payload)) - } -} - -// HandleEthernetPacketForRouter handles a packet that is -// directed to the router/gateway itself. The packet may be to the -// broadcast MAC address, or to the router's MAC address. The target -// IP may be the router's IP, or an internet (routed) IP. -func (n *network) HandleEthernetPacketForRouter(ep EthernetPacket) { - packet := ep.gp - flow, ok := flow(packet) - if !ok { - n.logf("dropping non-IP packet: %v", packet) - return - } - dstIP := flow.dst - toForward := dstIP != n.lanIP4.Addr() && dstIP != netip.IPv4Unspecified() && !dstIP.IsLinkLocalUnicast() - - // Pre-NAT mapping, for DNS/etc responses: - if flow.src.Is6() { - n.macMu.Lock() - mak.Set(&n.macOfIPv6, flow.src, ep.SrcMAC()) - n.macMu.Unlock() - } - - if udp, ok := packet.Layer(layers.LayerTypeUDP).(*layers.UDP); ok { - n.handleUDPPacketForRouter(ep, udp, toForward, flow) - return - } - - if toForward && n.s.shouldInterceptTCP(packet) { - if flow.dst.Is4() && n.breakWAN4 { - // Blackhole the packet. - return - } - var base *layers.BaseLayer - proto := header.IPv4ProtocolNumber - if v4, ok := packet.Layer(layers.LayerTypeIPv4).(*layers.IPv4); ok { - base = &v4.BaseLayer - } else if v6, ok := packet.Layer(layers.LayerTypeIPv6).(*layers.IPv6); ok { - base = &v6.BaseLayer - proto = header.IPv6ProtocolNumber - } else { - panic("not v4, not v6") - } - pktCopy := make([]byte, 0, len(base.Contents)+len(base.Payload)) - pktCopy = append(pktCopy, base.Contents...) - pktCopy = append(pktCopy, base.Payload...) - packetBuf := stack.NewPacketBuffer(stack.PacketBufferOptions{ - Payload: buffer.MakeWithData(pktCopy), - }) - n.linkEP.InjectInbound(proto, packetBuf) - packetBuf.DecRef() - return - } - - if flow.src.Is6() && flow.src.IsLinkLocalUnicast() && !flow.dst.IsLinkLocalUnicast() { - // Don't log. - return - } - - n.logf("router got unknown packet: %v", packet) -} - -func (n *network) handleUDPPacketForRouter(ep EthernetPacket, udp *layers.UDP, toForward bool, flow ipSrcDst) { - packet := ep.gp - srcIP, dstIP := flow.src, flow.dst - - if isDHCPRequest(packet) { - if !n.v4 { - n.logf("dropping DHCPv4 packet on v6-only network") - return - } - res, err := n.s.createDHCPResponse(packet) - if err != nil { - n.logf("createDHCPResponse: %v", err) - return - } - n.writeEth(res) - return - } - - if isMDNSQuery(packet) || isIGMP(packet) { - // Don't log. Spammy for now. - return - } - - if isDNSRequest(packet) { - res, err := n.s.createDNSResponse(packet) - if err != nil { - n.logf("createDNSResponse: %v", err) - return - } - n.writeEth(res) - return - } - - if fakeSyslog.Match(dstIP) { - node, ok := n.nodeByIP(srcIP) - if !ok { - return - } - if node.verboseSyslog { - // TODO(bradfitz): parse this and capture it, structured, into - // node's log buffer. - n.logf("syslog from %v: %s", node, udp.Payload) - } - return - } - - if dstIP == n.lanIP4.Addr() && isNATPMP(udp) { - n.handleNATPMPRequest(UDPPacket{ - Src: netip.AddrPortFrom(srcIP, uint16(udp.SrcPort)), - Dst: netip.AddrPortFrom(dstIP, uint16(udp.DstPort)), - Payload: udp.Payload, - }) - return - } - - if toForward { - if dstIP.Is4() && n.breakWAN4 { - // Blackhole the packet. - return - } - src := netip.AddrPortFrom(srcIP, uint16(udp.SrcPort)) - dst := netip.AddrPortFrom(dstIP, uint16(udp.DstPort)) - buf, err := n.serializedUDPPacket(src, dst, udp.Payload, nil) - if err != nil { - n.logf("serializing UDP packet: %v", err) - return - } - n.s.pcapWriter.WritePacket(gopacket.CaptureInfo{ - Timestamp: time.Now(), - CaptureLength: len(buf), - Length: len(buf), - InterfaceIndex: n.lanInterfaceID, - }, buf) - - lanSrc := src // the original src, before NAT (for logging only) - src = n.doNATOut(src, dst) - if !src.IsValid() { - n.logf("warning: NAT dropped packet; no NAT out mapping for %v=>%v", lanSrc, dst) - return - } - buf, err = n.serializedUDPPacket(src, dst, udp.Payload, nil) - if err != nil { - n.logf("serializing UDP packet: %v", err) - return - } - n.s.pcapWriter.WritePacket(gopacket.CaptureInfo{ - Timestamp: time.Now(), - CaptureLength: len(buf), - Length: len(buf), - InterfaceIndex: n.wanInterfaceID, - }, buf) - - if src.Addr().Is6() { - n.macMu.Lock() - mak.Set(&n.macOfIPv6, src.Addr(), ep.SrcMAC()) - n.macMu.Unlock() - } - - n.s.routeUDPPacket(UDPPacket{ - Src: src, - Dst: dst, - Payload: udp.Payload, - }) - return - } - - if udp.DstPort == pcpPort || udp.DstPort == ssdpPort { - // We handle NAT-PMP, but not these yet. - // TODO(bradfitz): handle? marginal utility so far. - // Don't log about them being unknown. - return - } - - n.logf("router got unknown UDP packet: %v", packet) -} - -func (n *network) handleIPv6RouterSolicitation(ep EthernetPacket, rs *layers.ICMPv6RouterSolicitation) { - v6 := ep.gp.Layer(layers.LayerTypeIPv6).(*layers.IPv6) - - // Send a router advertisement back. - eth := &layers.Ethernet{ - SrcMAC: n.mac.HWAddr(), - DstMAC: ep.SrcMAC().HWAddr(), - EthernetType: layers.EthernetTypeIPv6, - } - n.logf("sending IPv6 router advertisement to %v from %v", eth.DstMAC, eth.SrcMAC) - ip := &layers.IPv6{ - NextHeader: layers.IPProtocolICMPv6, - HopLimit: 255, // per RFC 4861, 7.1.1 etc (all NDP messages); don't use mkPacket's default of 64 - SrcIP: net.ParseIP("fe80::1"), - DstIP: v6.SrcIP, - } - icmp := &layers.ICMPv6{ - TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeRouterAdvertisement, 0), - } - pfx := make([]byte, 0, 30) // it's 32 on the wire, once gopacket adds two byte header - pfx = append(pfx, byte(64)) // CIDR length - pfx = append(pfx, byte(0xc0)) // flags: On-Link, Autonomous - pfx = binary.BigEndian.AppendUint32(pfx, 86400) // valid lifetime - pfx = binary.BigEndian.AppendUint32(pfx, 14400) // preferred lifetime - pfx = binary.BigEndian.AppendUint32(pfx, 0) // reserved - wanIP := n.wanIP6.Addr().As16() - pfx = append(pfx, wanIP[:]...) - - ra := &layers.ICMPv6RouterAdvertisement{ - RouterLifetime: 1800, - Options: []layers.ICMPv6Option{ - { - Type: layers.ICMPv6OptPrefixInfo, - Data: pfx, - }, - }, - } - pkt, err := mkPacket(eth, ip, icmp, ra) - if err != nil { - n.logf("serializing ICMPv6 RA: %v", err) - return - } - n.writeEth(pkt) -} - -func (n *network) handleIPv6NeighborSolicitation(ep EthernetPacket, ns *layers.ICMPv6NeighborSolicitation) { - v6 := ep.gp.Layer(layers.LayerTypeIPv6).(*layers.IPv6) - - targetIP, ok := netip.AddrFromSlice(ns.TargetAddress) - if !ok { - return - } - var srcMAC MAC - if targetIP == netip.MustParseAddr("fe80::1") { - srcMAC = n.mac - } else { - n.logf("Ignoring IPv6 NS request from %v for target %v", ep.SrcMAC(), targetIP) - return - } - n.logf("replying to IPv6 NS %v->%v about target %v (replySrc=%v)", ep.SrcMAC(), ep.DstMAC(), targetIP, srcMAC) - - // Send a neighbor advertisement back. - eth := &layers.Ethernet{ - SrcMAC: srcMAC.HWAddr(), - DstMAC: ep.SrcMAC().HWAddr(), - EthernetType: layers.EthernetTypeIPv6, - } - ip := &layers.IPv6{ - HopLimit: 255, // per RFC 4861, 7.1.1 etc (all NDP messages); don't use mkPacket's default of 64 - NextHeader: layers.IPProtocolICMPv6, - SrcIP: ns.TargetAddress, - DstIP: v6.SrcIP, - } - icmp := &layers.ICMPv6{ - TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeNeighborAdvertisement, 0), - } - var flags uint8 = 0x40 // solicited - if srcMAC == n.mac { - flags |= 0x80 // router - } - flags |= 0x20 // override - - na := &layers.ICMPv6NeighborAdvertisement{ - TargetAddress: ns.TargetAddress, - Flags: flags, - } - na.Options = append(na.Options, layers.ICMPv6Option{ - Type: layers.ICMPv6OptTargetAddress, - Data: srcMAC.HWAddr(), - }) - pkt, err := mkPacket(eth, ip, icmp, na) - if err != nil { - n.logf("serializing ICMPv6 NA: %v", err) - } - if !n.writeEth(pkt) { - n.logf("failed to writeEth for IPv6 NA reply for %v", targetIP) - } -} - -// createDHCPResponse creates a DHCPv4 response for the given DHCPv4 request. -func (s *Server) createDHCPResponse(request gopacket.Packet) ([]byte, error) { - ethLayer := request.Layer(layers.LayerTypeEthernet).(*layers.Ethernet) - srcMAC, ok := macOf(ethLayer.SrcMAC) - if !ok { - return nil, nil - } - node, ok := s.nodeByMAC[srcMAC] - if !ok { - log.Printf("DHCP request from unknown node %v; ignoring", srcMAC) - return nil, nil - } - gwIP := node.net.lanIP4.Addr() - - ipLayer := request.Layer(layers.LayerTypeIPv4).(*layers.IPv4) - udpLayer := request.Layer(layers.LayerTypeUDP).(*layers.UDP) - dhcpLayer := request.Layer(layers.LayerTypeDHCPv4).(*layers.DHCPv4) - - response := &layers.DHCPv4{ - Operation: layers.DHCPOpReply, - HardwareType: layers.LinkTypeEthernet, - HardwareLen: 6, - Xid: dhcpLayer.Xid, - ClientHWAddr: dhcpLayer.ClientHWAddr, - Flags: dhcpLayer.Flags, - YourClientIP: node.lanIP.AsSlice(), - Options: []layers.DHCPOption{ - { - Type: layers.DHCPOptServerID, - Data: gwIP.AsSlice(), // DHCP server's IP - Length: 4, - }, - }, - } - - var msgType layers.DHCPMsgType - for _, opt := range dhcpLayer.Options { - if opt.Type == layers.DHCPOptMessageType && opt.Length > 0 { - msgType = layers.DHCPMsgType(opt.Data[0]) - } - } - switch msgType { - case layers.DHCPMsgTypeDiscover: - response.Options = append(response.Options, layers.DHCPOption{ - Type: layers.DHCPOptMessageType, - Data: []byte{byte(layers.DHCPMsgTypeOffer)}, - Length: 1, - }) - case layers.DHCPMsgTypeRequest: - response.Options = append(response.Options, - layers.DHCPOption{ - Type: layers.DHCPOptMessageType, - Data: []byte{byte(layers.DHCPMsgTypeAck)}, - Length: 1, - }, - layers.DHCPOption{ - Type: layers.DHCPOptLeaseTime, - Data: binary.BigEndian.AppendUint32(nil, 3600), // hour? sure. - Length: 4, - }, - layers.DHCPOption{ - Type: layers.DHCPOptRouter, - Data: gwIP.AsSlice(), - Length: 4, - }, - layers.DHCPOption{ - Type: layers.DHCPOptDNS, - Data: fakeDNS.v4.AsSlice(), - Length: 4, - }, - layers.DHCPOption{ - Type: layers.DHCPOptSubnetMask, - Data: net.CIDRMask(node.net.lanIP4.Bits(), 32), - Length: 4, - }, - ) - } - - eth := &layers.Ethernet{ - SrcMAC: node.net.mac.HWAddr(), - DstMAC: ethLayer.SrcMAC, - EthernetType: layers.EthernetTypeIPv4, // never IPv6 for DHCP - } - ip := &layers.IPv4{ - Protocol: layers.IPProtocolUDP, - SrcIP: ipLayer.DstIP, - DstIP: ipLayer.SrcIP, - } - udp := &layers.UDP{ - SrcPort: udpLayer.DstPort, - DstPort: udpLayer.SrcPort, - } - return mkPacket(eth, ip, udp, response) -} - -// isDHCPRequest reports whether pkt is a DHCPv4 request. -func isDHCPRequest(pkt gopacket.Packet) bool { - v4, ok := pkt.Layer(layers.LayerTypeIPv4).(*layers.IPv4) - if !ok || v4.Protocol != layers.IPProtocolUDP { - return false - } - udp, ok := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP) - return ok && udp.DstPort == 67 && udp.SrcPort == 68 -} - -func isIGMP(pkt gopacket.Packet) bool { - return pkt.Layer(layers.LayerTypeIGMP) != nil -} - -func isMDNSQuery(pkt gopacket.Packet) bool { - udp, ok := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP) - // TODO(bradfitz): also check IPv4 DstIP=224.0.0.251 (or whatever) - return ok && udp.SrcPort == 5353 && udp.DstPort == 5353 -} - -func (s *Server) shouldInterceptTCP(pkt gopacket.Packet) bool { - tcp, ok := pkt.Layer(layers.LayerTypeTCP).(*layers.TCP) - if !ok { - return false - } - if tcp.DstPort == 123 { - // Test port for TCP interception. Not really useful, but cute for - // demos. - return true - } - flow, ok := flow(pkt) - if !ok { - return false - } - if flow.src.Is6() && flow.src.IsLinkLocalUnicast() { - return false - } - - if tcp.DstPort == 80 || tcp.DstPort == 443 { - for _, v := range []virtualIP{fakeControl, fakeDERP1, fakeDERP2, fakeLogCatcher} { - if v.Match(flow.dst) { - return true - } - } - if fakeProxyControlplane.Match(flow.dst) { - return s.blendReality - } - if s.derpIPs.Contains(flow.dst) { - return true - } - } - if tcp.DstPort == 8008 && fakeTestAgent.Match(flow.dst) { - // Connection from cmd/tta. - return true - } - return false -} - -type ipSrcDst struct { - src netip.Addr - dst netip.Addr -} - -func flow(gp gopacket.Packet) (f ipSrcDst, ok bool) { - if gp == nil { - return f, false - } - n := gp.NetworkLayer() - if n == nil { - return f, false - } - sb, db := n.NetworkFlow().Endpoints() - src, _ := netip.AddrFromSlice(sb.Raw()) - dst, _ := netip.AddrFromSlice(db.Raw()) - return ipSrcDst{src: src, dst: dst}, src.IsValid() && dst.IsValid() -} - -// isDNSRequest reports whether pkt is a DNS request to the fake DNS server. -func isDNSRequest(pkt gopacket.Packet) bool { - udp, ok := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP) - if !ok || udp.DstPort != 53 { - return false - } - f, ok := flow(pkt) - if !ok { - return false - } - if !fakeDNS.Match(f.dst) { - // TODO(bradfitz): maybe support configs where DNS is local in the LAN - return false - } - dns, ok := pkt.Layer(layers.LayerTypeDNS).(*layers.DNS) - return ok && dns.QR == false && len(dns.Questions) > 0 -} - -func isNATPMP(udp *layers.UDP) bool { - return udp.DstPort == 5351 && len(udp.Payload) > 0 && udp.Payload[0] == 0 // version 0, not 2 for PCP -} - -func makeSTUNReply(req UDPPacket) (res UDPPacket, ok bool) { - txid, err := stun.ParseBindingRequest(req.Payload) - if err != nil { - log.Printf("invalid STUN request: %v", err) - return res, false - } - return UDPPacket{ - Src: req.Dst, - Dst: req.Src, - Payload: stun.Response(txid, req.Src), - }, true -} - -func (s *Server) createDNSResponse(pkt gopacket.Packet) ([]byte, error) { - flow, ok := flow(pkt) - if !ok { - return nil, nil - } - ethLayer := pkt.Layer(layers.LayerTypeEthernet).(*layers.Ethernet) - udpLayer := pkt.Layer(layers.LayerTypeUDP).(*layers.UDP) - dnsLayer := pkt.Layer(layers.LayerTypeDNS).(*layers.DNS) - - if dnsLayer.OpCode != layers.DNSOpCodeQuery || dnsLayer.QR || len(dnsLayer.Questions) == 0 { - return nil, nil - } - - response := &layers.DNS{ - ID: dnsLayer.ID, - QR: true, - AA: true, - TC: false, - RD: dnsLayer.RD, - RA: true, - OpCode: layers.DNSOpCodeQuery, - ResponseCode: layers.DNSResponseCodeNoErr, - } - - var names []string - for _, q := range dnsLayer.Questions { - response.QDCount++ - response.Questions = append(response.Questions, q) - - if mem.HasSuffix(mem.B(q.Name), mem.S(".pool.ntp.org")) { - // Just drop DNS queries for NTP servers. For Debian/etc guests used - // during development. Not needed. Assume VM guests get correct time - // via their hypervisor. - return nil, nil - } - - names = append(names, q.Type.String()+"/"+string(q.Name)) - if q.Class != layers.DNSClassIN { - continue - } - - if q.Type == layers.DNSTypeA || q.Type == layers.DNSTypeAAAA { - if v, ok := vips[string(q.Name)]; ok { - ip := v.v4 - if q.Type == layers.DNSTypeAAAA { - ip = v.v6 - } - response.ANCount++ - response.Answers = append(response.Answers, layers.DNSResourceRecord{ - Name: q.Name, - Type: q.Type, - Class: q.Class, - IP: ip.AsSlice(), - TTL: 60, - }) - } - } - } - - // Make reply layers, all reversed. - eth2 := &layers.Ethernet{ - SrcMAC: ethLayer.DstMAC, - DstMAC: ethLayer.SrcMAC, - } - ip2 := mkIPLayer(layers.IPProtocolUDP, flow.dst, flow.src) - udp2 := &layers.UDP{ - SrcPort: udpLayer.DstPort, - DstPort: udpLayer.SrcPort, - } - - resPkt, err := mkPacket(eth2, ip2, udp2, response) - if err != nil { - return nil, err - } - - const debugDNS = false - if debugDNS { - if len(response.Answers) > 0 { - back := gopacket.NewPacket(resPkt, layers.LayerTypeEthernet, gopacket.Lazy) - log.Printf("createDNSResponse generated answers: %v", back) - } else { - log.Printf("made empty response for %q", names) - } - } - - return resPkt, nil -} - -// doNATOut performs NAT on an outgoing packet from src to dst, where -// src is a LAN IP and dst is a WAN IP. -// -// It returns the source WAN ip:port to use. -// -// If newSrc is invalid, the packet should be dropped. -func (n *network) doNATOut(src, dst netip.AddrPort) (newSrc netip.AddrPort) { - if src.Addr().Is6() { - // TODO(bradfitz): IPv6 NAT? For now, normal IPv6 only. - return src - } - - n.natMu.Lock() - defer n.natMu.Unlock() - - // First see if there's a port mapping, before doing NAT. - if wanAP, ok := n.portMapFlow[portmapFlowKey{ - peerWAN: dst, - lanAP: src, - }]; ok { - return wanAP - } - - return n.natTable.PickOutgoingSrc(src, dst, time.Now()) -} - -type portmapFlowKey struct { - peerWAN netip.AddrPort // the peer's WAN ip:port - lanAP netip.AddrPort -} - -// doNATIn performs NAT on an incoming packet from WAN src to WAN dst, returning -// a new destination LAN ip:port to use. -// -// If newDst is invalid, the packet should be dropped. -func (n *network) doNATIn(src, dst netip.AddrPort) (newDst netip.AddrPort) { - if dst.Addr().Is6() { - // TODO(bradfitz): IPv6 NAT? For now, normal IPv6 only. - return dst - } - - n.natMu.Lock() - defer n.natMu.Unlock() - - now := time.Now() - - // First see if there's a port mapping, before doing NAT. - if lanAP, ok := n.portMap[dst]; ok { - if now.Before(lanAP.expiry) { - mak.Set(&n.portMapFlow, portmapFlowKey{ - peerWAN: src, - lanAP: lanAP.dst, - }, dst) - //n.logf("NAT: doNatIn: port mapping %v=>%v", dst, lanAP.dst) - return lanAP.dst - } - n.logf("NAT: doNatIn: port mapping EXPIRED for %v=>%v", dst, lanAP.dst) - delete(n.portMap, dst) - return netip.AddrPort{} - } - - return n.natTable.PickIncomingDst(src, dst, now) -} - -// IsPublicPortUsed reports whether the given public port is currently in use. -// -// n.natMu must be held by the caller. (It's only called by nat implementations -// which are always called with natMu held)) -func (n *network) IsPublicPortUsed(ap netip.AddrPort) bool { - _, ok := n.portMap[ap] - return ok -} - -func (n *network) doPortMap(src netip.Addr, dstLANPort, wantExtPort uint16, sec int) (gotPort uint16, ok bool) { - n.natMu.Lock() - defer n.natMu.Unlock() - - if !n.portmap { - return 0, false - } - - wanAP := netip.AddrPortFrom(n.wanIP4, wantExtPort) - dst := netip.AddrPortFrom(src, dstLANPort) - - if sec == 0 { - lanAP, ok := n.portMap[wanAP] - if ok && lanAP.dst.Addr() == src { - delete(n.portMap, wanAP) - } - return 0, false - } - - // See if they already have a mapping and extend expiry if so. - for k, v := range n.portMap { - if v.dst == dst { - n.portMap[k] = portMapping{ - dst: dst, - expiry: time.Now().Add(time.Duration(sec) * time.Second), - } - return k.Port(), true - } - } - - for try := 0; try < 20_000; try++ { - if wanAP.Port() > 0 && !n.natTable.IsPublicPortUsed(wanAP) { - mak.Set(&n.portMap, wanAP, portMapping{ - dst: dst, - expiry: time.Now().Add(time.Duration(sec) * time.Second), - }) - n.logf("vnet: allocated NAT mapping from %v to %v", wanAP, dst) - return wanAP.Port(), true - } - wantExtPort = rand.N(uint16(32<<10)) + 32<<10 - wanAP = netip.AddrPortFrom(n.wanIP4, wantExtPort) - } - return 0, false -} - -func (n *network) createARPResponse(pkt gopacket.Packet) ([]byte, error) { - ethLayer, ok := pkt.Layer(layers.LayerTypeEthernet).(*layers.Ethernet) - if !ok { - return nil, nil - } - arpLayer, ok := pkt.Layer(layers.LayerTypeARP).(*layers.ARP) - if !ok || - arpLayer.Operation != layers.ARPRequest || - arpLayer.AddrType != layers.LinkTypeEthernet || - arpLayer.Protocol != layers.EthernetTypeIPv4 || - arpLayer.HwAddressSize != 6 || - arpLayer.ProtAddressSize != 4 || - len(arpLayer.DstProtAddress) != 4 { - return nil, nil - } - - wantIP := netip.AddrFrom4([4]byte(arpLayer.DstProtAddress)) - foundMAC, ok := n.MACOfIP(wantIP) - if !ok { - return nil, nil - } - - eth := &layers.Ethernet{ - SrcMAC: foundMAC.HWAddr(), - DstMAC: ethLayer.SrcMAC, - EthernetType: layers.EthernetTypeARP, - } - - a2 := &layers.ARP{ - AddrType: layers.LinkTypeEthernet, - Protocol: layers.EthernetTypeIPv4, // never IPv6; IPv6 equivalent of ARP is handleIPv6NeighborSolicitation - HwAddressSize: 6, - ProtAddressSize: 4, - Operation: layers.ARPReply, - SourceHwAddress: foundMAC.HWAddr(), - SourceProtAddress: arpLayer.DstProtAddress, - DstHwAddress: ethLayer.SrcMAC, - DstProtAddress: arpLayer.SourceProtAddress, - } - - buffer := gopacket.NewSerializeBuffer() - options := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true} - if err := gopacket.SerializeLayers(buffer, options, eth, a2); err != nil { - return nil, err - } - - return buffer.Bytes(), nil -} - -func (n *network) handleNATPMPRequest(req UDPPacket) { - if !n.portmap { - return - } - if string(req.Payload) == "\x00\x00" { - // https://www.rfc-editor.org/rfc/rfc6886#section-3.2 - - res := make([]byte, 0, 12) - res = append(res, - 0, // version 0 (NAT-PMP) - 128, // response to op 0 (128+0) - 0, 0, // result code success - ) - res = binary.BigEndian.AppendUint32(res, uint32(time.Now().Unix())) - wan4 := n.wanIP4.As4() - res = append(res, wan4[:]...) - n.WriteUDPPacketNoNAT(UDPPacket{ - Src: req.Dst, - Dst: req.Src, - Payload: res, - }) - return - } - - // Map UDP request - if len(req.Payload) == 12 && req.Payload[0] == 0 && req.Payload[1] == 1 { - // https://www.rfc-editor.org/rfc/rfc6886#section-3.3 - // "00 01 00 00 ed 40 00 00 00 00 1c 20" => - // 00 ver - // 01 op=map UDP - // 00 00 reserved (0 in request; in response, this is the result code) - // ed 40 internal port 60736 - // 00 00 suggested external port - // 00 00 1c 20 suggested lifetime in seconds (7200 sec = 2 hours) - internalPort := binary.BigEndian.Uint16(req.Payload[4:6]) - wantExtPort := binary.BigEndian.Uint16(req.Payload[6:8]) - lifetimeSec := binary.BigEndian.Uint32(req.Payload[8:12]) - gotPort, ok := n.doPortMap(req.Src.Addr(), internalPort, wantExtPort, int(lifetimeSec)) - if !ok { - n.logf("NAT-PMP map request for %v:%d failed", req.Src.Addr(), internalPort) - return - } - res := make([]byte, 0, 16) - res = append(res, - 0, // version 0 (NAT-PMP) - 1+128, // response to op 1 - 0, 0, // result code success - ) - res = binary.BigEndian.AppendUint32(res, uint32(time.Now().Unix())) - res = binary.BigEndian.AppendUint16(res, internalPort) - res = binary.BigEndian.AppendUint16(res, gotPort) - res = binary.BigEndian.AppendUint32(res, lifetimeSec) - n.WriteUDPPacketNoNAT(UDPPacket{ - Src: req.Dst, - Dst: req.Src, - Payload: res, - }) - return - } - - n.logf("TODO: handle NAT-PMP packet % 02x", req.Payload) -} - -// UDPPacket is a UDP packet. -// -// For the purposes of this project, a UDP packet -// (not a general IP packet) is the unit to be NAT'ed, -// as that's all that Tailscale uses. -type UDPPacket struct { - Src netip.AddrPort - Dst netip.AddrPort - Payload []byte // everything after UDP header -} - -func (s *Server) WriteStartingBanner(w io.Writer) { - fmt.Fprintf(w, "vnet serving clients:\n") - - for _, n := range s.nodes { - fmt.Fprintf(w, " %v %15v (%v, %v)\n", n.mac, n.lanIP, n.net.wanIP4, n.net.natStyle.Load()) - } -} - -type agentConn struct { - node *node - tc *gonet.TCPConn -} - -func (s *Server) addIdleAgentConn(ac *agentConn) { - //log.Printf("got agent conn from %v", ac.node.mac) - s.mu.Lock() - defer s.mu.Unlock() - - s.agentConns.Make() - s.agentConns.Add(ac) - - if waiter, ok := s.agentConnWaiter[ac.node]; ok { - select { - case waiter <- struct{}{}: - default: - } - } -} - -func (s *Server) takeAgentConn(ctx context.Context, n *node) (_ *agentConn, ok bool) { - const debug = false - for { - ac, ok := s.takeAgentConnOne(n) - if ok { - if debug { - log.Printf("takeAgentConn: got agent conn for %v", n.mac) - } - return ac, true - } - s.mu.Lock() - ready := make(chan struct{}) - mak.Set(&s.agentConnWaiter, n, ready) - s.mu.Unlock() - - if debug { - log.Printf("takeAgentConn: waiting for agent conn for %v", n.mac) - } - select { - case <-ctx.Done(): - return nil, false - case <-ready: - case <-time.After(time.Second): - // Try again regularly anyway, in case we have multiple clients - // trying to hit the same node, or if a race means we weren't in the - // select by the time addIdleAgentConn tried to signal us. - } - } -} - -func (s *Server) takeAgentConnOne(n *node) (_ *agentConn, ok bool) { - s.mu.Lock() - defer s.mu.Unlock() - miss := 0 - for ac := range s.agentConns { - if ac.node == n { - s.agentConns.Delete(ac) - return ac, true - } - miss++ - } - if miss > 0 { - log.Printf("takeAgentConnOne: missed %d times for %v", miss, n.mac) - } - return nil, false -} - -type NodeAgentClient struct { - *tailscale.LocalClient - HTTPClient *http.Client -} - -func (s *Server) NodeAgentDialer(n *Node) DialFunc { - s.mu.Lock() - defer s.mu.Unlock() - - if d, ok := s.agentDialer[n.n]; ok { - return d - } - d := func(ctx context.Context, network, addr string) (net.Conn, error) { - ac, ok := s.takeAgentConn(ctx, n.n) - if !ok { - return nil, ctx.Err() - } - return ac.tc, nil - } - mak.Set(&s.agentDialer, n.n, d) - return d -} - -func (s *Server) NodeAgentClient(n *Node) *NodeAgentClient { - d := s.NodeAgentDialer(n) - return &NodeAgentClient{ - LocalClient: &tailscale.LocalClient{ - UseSocketOnly: true, - OmitAuth: true, - Dial: d, - }, - HTTPClient: &http.Client{ - Transport: &http.Transport{ - DialContext: d, - }, - }, - } -} - -// EnableHostFirewall enables the host's stateful firewall. -func (c *NodeAgentClient) EnableHostFirewall(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/fw", nil) - if err != nil { - return err - } - res, err := c.HTTPClient.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - all, _ := io.ReadAll(res.Body) - if res.StatusCode != 200 { - return fmt.Errorf("unexpected status code %v: %s", res.Status, all) - } - return nil -} - -// mkPacket is a serializes a number of layers into a packet. -// -// It's a convenience wrapper around gopacket.SerializeLayers -// that does some things automatically: -// -// * layers.Ethernet.EthernetType is set to IPv4 or IPv6 if not already set -// * layers.IPv4/IPv6 Version is set to 4/6 if not already set -// * layers.IPv4/IPv6 TTL/HopLimit is set to 64 if not already set -// * the TCP/UDP/ICMPv6 checksum is set based on the network layer -// -// The provided layers in ll must be sorted from lowest (e.g. *layers.Ethernet) -// to highest. (Depending on the need, the first layer will be either *layers.Ethernet -// or *layers.IPv4/IPv6). -func mkPacket(ll ...gopacket.SerializableLayer) ([]byte, error) { - var el *layers.Ethernet - var nl gopacket.NetworkLayer - for _, la := range ll { - switch la := la.(type) { - case *layers.IPv4: - nl = la - if el != nil && el.EthernetType == 0 { - el.EthernetType = layers.EthernetTypeIPv4 - } - if la.Version == 0 { - la.Version = 4 - } - if la.TTL == 0 { - la.TTL = 64 - } - case *layers.IPv6: - nl = la - if el != nil && el.EthernetType == 0 { - el.EthernetType = layers.EthernetTypeIPv6 - } - if la.Version == 0 { - la.Version = 6 - } - if la.HopLimit == 0 { - la.HopLimit = 64 - } - case *layers.Ethernet: - el = la - } - } - for _, la := range ll { - switch la := la.(type) { - case *layers.TCP: - la.SetNetworkLayerForChecksum(nl) - case *layers.UDP: - la.SetNetworkLayerForChecksum(nl) - case *layers.ICMPv6: - la.SetNetworkLayerForChecksum(nl) - } - } - buf := gopacket.NewSerializeBuffer() - opts := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true} - if err := gopacket.SerializeLayers(buf, opts, ll...); err != nil { - return nil, fmt.Errorf("serializing packet: %v", err) - } - return buf.Bytes(), nil -} diff --git a/tstest/natlab/vnet/vnet_test.go b/tstest/natlab/vnet/vnet_test.go deleted file mode 100644 index 5ffa2b1049c88..0000000000000 --- a/tstest/natlab/vnet/vnet_test.go +++ /dev/null @@ -1,664 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vnet - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "net" - "net/netip" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/google/gopacket" - "github.com/google/gopacket/layers" - "tailscale.com/util/must" -) - -const ( - ethType4 = layers.EthernetTypeIPv4 - ethType6 = layers.EthernetTypeIPv6 -) - -// TestPacketSideEffects tests that upon receiving certain -// packets, other packets and/or log statements are generated. -func TestPacketSideEffects(t *testing.T) { - type netTest struct { - name string - pkt []byte // to send - check func(*sideEffects) error - } - tests := []struct { - netName string // name of the Server returned by setup - setup func() (*Server, error) - tests []netTest // to run against setup's Server - }{ - { - netName: "basic", - setup: newTwoNodesSameNetwork, - tests: []netTest{ - { - name: "drop-rando-ethertype", - pkt: mkEth(nodeMac(2), nodeMac(1), 0x4321, []byte("hello")), - check: all( - logSubstr("Dropping non-IP packet"), - ), - }, - { - name: "dst-mac-between-nodes", - pkt: mkEth(nodeMac(2), nodeMac(1), testingEthertype, []byte("hello")), - check: all( - numPkts(1), - pktSubstr("SrcMAC=52:cc:cc:cc:cc:01 DstMAC=52:cc:cc:cc:cc:02 EthernetType=UnknownEthernetType"), - pktSubstr("Unable to decode EthernetType 4660"), - ), - }, - { - name: "broadcast-mac", - pkt: mkEth(macBroadcast, nodeMac(1), testingEthertype, []byte("hello")), - check: all( - numPkts(1), - pktSubstr("SrcMAC=52:cc:cc:cc:cc:01 DstMAC=ff:ff:ff:ff:ff:ff EthernetType=UnknownEthernetType"), - pktSubstr("Unable to decode EthernetType 4660"), - ), - }, - { - name: "dns-request-v4", - pkt: mkDNSReq(4), - check: all( - numPkts(1), - pktSubstr("Data=[52, 52, 0, 3] IP=52.52.0.3"), - ), - }, - { - name: "dns-request-v6", - pkt: mkDNSReq(6), - check: all( - numPkts(1), - pktSubstr(" IP=2052::3 "), - ), - }, - { - name: "syslog-v4", - pkt: mkSyslogPacket(clientIPv4(1), "<6>2024-08-30T10:36:06-07:00 natlabapp tailscaled[1]: 2024/08/30 10:36:06 some-message"), - check: all( - numPkts(0), - logSubstr("some-message"), - ), - }, - { - name: "syslog-v6", - pkt: mkSyslogPacket(nodeWANIP6(1), "<6>2024-08-30T10:36:06-07:00 natlabapp tailscaled[1]: 2024/08/30 10:36:06 some-message"), - check: all( - numPkts(0), - logSubstr("some-message"), - ), - }, - }, - }, - { - netName: "v4", - setup: newTwoNodesSameV4Network, - tests: []netTest{ - { - name: "no-v6-reply-on-v4-only", - pkt: mkIPv6RouterSolicit(nodeMac(1), nodeLANIP6(1)), - check: all( - numPkts(0), - logSubstr("dropping IPv6 packet on v4-only network"), - ), - }, - { - name: "dhcp-discover", - pkt: mkDHCP(nodeMac(1), layers.DHCPMsgTypeDiscover), - check: all( - numPkts(2), // DHCP discover broadcast to node2 also, and the DHCP reply from router - pktSubstr("SrcMAC=52:cc:cc:cc:cc:01 DstMAC=ff:ff:ff:ff:ff:ff"), - pktSubstr("Options=[Option(ServerID:192.168.0.1), Option(MessageType:Offer)]}"), - ), - }, - { - name: "dhcp-request", - pkt: mkDHCP(nodeMac(1), layers.DHCPMsgTypeRequest), - check: all( - numPkts(2), // DHCP discover broadcast to node2 also, and the DHCP reply from router - pktSubstr("SrcMAC=52:cc:cc:cc:cc:01 DstMAC=ff:ff:ff:ff:ff:ff"), - pktSubstr("YourClientIP=192.168.0.101"), - pktSubstr("Options=[Option(ServerID:192.168.0.1), Option(MessageType:Ack), Option(LeaseTime:3600), Option(Router:[192 168 0 1]), Option(DNS:[4 11 4 11]), Option(SubnetMask:255.255.255.0)]}"), - ), - }, - }, - }, - { - netName: "v6", - setup: func() (*Server, error) { - var c Config - nw := c.AddNetwork("2000:52::1/64") - c.AddNode(nw) - c.AddNode(nw) - return New(&c) - }, - tests: []netTest{ - { - name: "router-solicit", - pkt: mkIPv6RouterSolicit(nodeMac(1), nodeLANIP6(1)), - check: all( - logSubstr("sending IPv6 router advertisement to 52:cc:cc:cc:cc:01 from 52:ee:ee:ee:ee:01"), - numPkts(1), - pktSubstr("TypeCode=RouterAdvertisement"), - pktSubstr("HopLimit=255 "), // per RFC 4861, 7.1.1 etc (all NDP messages) - pktSubstr("= ICMPv6RouterAdvertisement"), - pktSubstr("SrcMAC=52:ee:ee:ee:ee:01 DstMAC=52:cc:cc:cc:cc:01 EthernetType=IPv6"), - ), - }, - { - name: "all-nodes", - pkt: mkAllNodesPing(nodeMac(1), nodeLANIP6(1)), - check: all( - numPkts(1), - pktSubstr("SrcMAC=52:cc:cc:cc:cc:01 DstMAC=33:33:00:00:00:01"), - pktSubstr("SrcIP=fe80::50cc:ccff:fecc:cc01 DstIP=ff02::1"), - pktSubstr("TypeCode=EchoRequest"), - ), - }, - { - name: "no-dhcp-on-v6-disco", - pkt: mkDHCP(nodeMac(1), layers.DHCPMsgTypeDiscover), - check: all( - numPkts(1), // DHCP discover broadcast to node2 only - logSubstr("dropping DHCPv4 packet on v6-only network"), - pktSubstr("SrcMAC=52:cc:cc:cc:cc:01 DstMAC=ff:ff:ff:ff:ff:ff"), - ), - }, - { - name: "no-dhcp-on-v6-request", - pkt: mkDHCP(nodeMac(1), layers.DHCPMsgTypeRequest), - check: all( - numPkts(1), // DHCP request broadcast to node2 only - pktSubstr("SrcMAC=52:cc:cc:cc:cc:01 DstMAC=ff:ff:ff:ff:ff:ff"), - logSubstr("dropping DHCPv4 packet on v6-only network"), - ), - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.netName, func(t *testing.T) { - s, err := tt.setup() - if err != nil { - t.Fatal(err) - } - defer s.Close() - - for _, tt := range tt.tests { - t.Run(tt.name, func(t *testing.T) { - se := newSideEffects(s) - - if err := s.handleEthernetFrameFromVM(tt.pkt); err != nil { - t.Fatal(err) - } - if tt.check != nil { - if err := tt.check(se); err != nil { - t.Error(err) - } - } - if t.Failed() { - t.Logf("logs were:\n%s", strings.Join(se.logs, "\n")) - for i, rp := range se.got { - p := gopacket.NewPacket(rp.eth, layers.LayerTypeEthernet, gopacket.Lazy) - got := p.String() - t.Logf("[pkt%d, port %v]:\n%s\n", i, rp.port, got) - } - } - }) - } - }) - } - -} - -// mustPacket is like mkPacket but panics on error. -func mustPacket(layers ...gopacket.SerializableLayer) []byte { - return must.Get(mkPacket(layers...)) -} - -// mkEth encodes an ethernet frame with the given payload. -func mkEth(dst, src MAC, ethType layers.EthernetType, payload []byte) []byte { - ret := make([]byte, 0, 14+len(payload)) - ret = append(ret, dst.HWAddr()...) - ret = append(ret, src.HWAddr()...) - ret = binary.BigEndian.AppendUint16(ret, uint16(ethType)) - return append(ret, payload...) -} - -// mkLenPrefixed prepends a uint32 length to the given packet. -func mkLenPrefixed(pkt []byte) []byte { - ret := make([]byte, 4+len(pkt)) - binary.BigEndian.PutUint32(ret, uint32(len(pkt))) - copy(ret[4:], pkt) - return ret -} - -// mkIPv6RouterSolicit makes a IPv6 router solicitation packet -// ethernet frame. -func mkIPv6RouterSolicit(srcMAC MAC, srcIP netip.Addr) []byte { - ip := &layers.IPv6{ - Version: 6, - HopLimit: 255, - NextHeader: layers.IPProtocolICMPv6, - SrcIP: srcIP.AsSlice(), - DstIP: net.ParseIP("ff02::2"), // all routers - } - icmp := &layers.ICMPv6{ - TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeRouterSolicitation, 0), - } - - ra := &layers.ICMPv6RouterSolicitation{ - Options: []layers.ICMPv6Option{{ - Type: layers.ICMPv6OptSourceAddress, - Data: srcMAC.HWAddr(), - }}, - } - icmp.SetNetworkLayerForChecksum(ip) - return mkEth(macAllRouters, srcMAC, ethType6, mustPacket(ip, icmp, ra)) -} - -func mkAllNodesPing(srcMAC MAC, srcIP netip.Addr) []byte { - ip := &layers.IPv6{ - Version: 6, - HopLimit: 255, - NextHeader: layers.IPProtocolICMPv6, - SrcIP: srcIP.AsSlice(), - DstIP: net.ParseIP("ff02::1"), // all nodes - } - icmp := &layers.ICMPv6{ - TypeCode: layers.CreateICMPv6TypeCode(layers.ICMPv6TypeEchoRequest, 0), - } - icmp.SetNetworkLayerForChecksum(ip) - return mkEth(macAllNodes, srcMAC, ethType6, mustPacket(ip, icmp)) -} - -// mkDNSReq makes a DNS request to "control.tailscale" using the source IPs as -// defined in this test file. -// -// ipVer must be 4 or 6: -// If 4, it makes an A record request. -// If 6, it makes a AAAA record request. -// -// (Yes, this is technically unrelated (you can request A records over IPv6 or -// AAAA records over IPv4), but for test coverage reasons, assume that the ipVer -// of 6 means to also request an AAAA record.) -func mkDNSReq(ipVer int) []byte { - eth := &layers.Ethernet{ - SrcMAC: nodeMac(1).HWAddr(), - DstMAC: routerMac(1).HWAddr(), - EthernetType: layers.EthernetTypeIPv4, - } - if ipVer == 6 { - eth.EthernetType = layers.EthernetTypeIPv6 - } - - var ip serializableNetworkLayer - switch ipVer { - case 4: - ip = &layers.IPv4{ - Version: 4, - Protocol: layers.IPProtocolUDP, - SrcIP: clientIPv4(1).AsSlice(), - TTL: 64, - DstIP: FakeDNSIPv4().AsSlice(), - } - case 6: - ip = &layers.IPv6{ - Version: 6, - HopLimit: 64, - NextHeader: layers.IPProtocolUDP, - SrcIP: net.ParseIP("2000:52::1"), - DstIP: FakeDNSIPv6().AsSlice(), - } - default: - panic("bad ipVer") - } - - udp := &layers.UDP{ - SrcPort: 12345, - DstPort: 53, - } - udp.SetNetworkLayerForChecksum(ip) - dns := &layers.DNS{ - ID: 789, - Questions: []layers.DNSQuestion{{ - Name: []byte("control.tailscale"), - Type: layers.DNSTypeA, - Class: layers.DNSClassIN, - }}, - } - if ipVer == 6 { - dns.Questions[0].Type = layers.DNSTypeAAAA - } - return mustPacket(eth, ip, udp, dns) -} - -func mkDHCP(srcMAC MAC, typ layers.DHCPMsgType) []byte { - eth := &layers.Ethernet{ - SrcMAC: srcMAC.HWAddr(), - DstMAC: macBroadcast.HWAddr(), - EthernetType: layers.EthernetTypeIPv4, - } - ip := &layers.IPv4{ - Version: 4, - Protocol: layers.IPProtocolUDP, - SrcIP: net.ParseIP("0.0.0.0"), - DstIP: net.ParseIP("255.255.255.255"), - } - udp := &layers.UDP{ - SrcPort: 68, - DstPort: 67, - } - dhcp := &layers.DHCPv4{ - Operation: layers.DHCPOpRequest, - HardwareType: layers.LinkTypeEthernet, - HardwareLen: 6, - Xid: 0, - Secs: 0, - Flags: 0, - ClientHWAddr: srcMAC[:], - Options: []layers.DHCPOption{ - {Type: layers.DHCPOptMessageType, Length: 1, Data: []byte{byte(typ)}}, - }, - } - return mustPacket(eth, ip, udp, dhcp) -} - -func mkSyslogPacket(srcIP netip.Addr, msg string) []byte { - eth := &layers.Ethernet{ - SrcMAC: nodeMac(1).HWAddr(), - DstMAC: routerMac(1).HWAddr(), - } - ip := mkIPLayer(layers.IPProtocolUDP, srcIP, matchingIP(srcIP, FakeSyslogIPv4(), FakeSyslogIPv6())) - udp := &layers.UDP{ - SrcPort: 123, - DstPort: 456, // unused; only IP matches - } - return mustPacket(eth, ip, udp, gopacket.Payload([]byte(msg))) -} - -// matchingIP returns ip4 if toMatch is an IPv4 address, otherwise ip6. -func matchingIP(toMatch, if4, if6 netip.Addr) netip.Addr { - if toMatch.Is4() { - return if4 - } - return if6 -} - -// receivedPacket is an ethernet frame that was received during a test. -type receivedPacket struct { - port MAC // MAC address of client that received the packet - eth []byte // ethernet frame; dst MAC might be ff:ff:ff:ff:ff:ff, etc -} - -// sideEffects gathers side effects as a result of sending a packet and tests -// whether those effects were as desired. -type sideEffects struct { - logs []string - got []receivedPacket // ethernet packets received -} - -// newSideEffects creates a new sideEffects recorder, registering itself with s. -func newSideEffects(s *Server) *sideEffects { - se := &sideEffects{} - s.SetLoggerForTest(se.logf) - for mac := range s.MACs() { - s.RegisterSinkForTest(mac, func(eth []byte) { - se.got = append(se.got, receivedPacket{ - port: mac, - eth: eth, - }) - }) - } - return se -} - -func (se *sideEffects) logf(format string, args ...any) { - se.logs = append(se.logs, fmt.Sprintf(format, args...)) -} - -// all aggregates several side effects checkers into one. -func all(checks ...func(*sideEffects) error) func(*sideEffects) error { - return func(se *sideEffects) error { - var errs []error - for _, check := range checks { - if err := check(se); err != nil { - errs = append(errs, err) - } - } - return errors.Join(errs...) - } -} - -// logSubstr returns a side effect checker func that checks -// whether a log statement was output containing substring sub. -func logSubstr(sub string) func(*sideEffects) error { - return func(se *sideEffects) error { - for _, log := range se.logs { - if strings.Contains(log, sub) { - return nil - } - } - return fmt.Errorf("expected log substring %q not found", sub) - } -} - -// pkgSubstr returns a side effect checker func that checks whether an ethernet -// packet was received that, once decoded and stringified by gopacket, contains -// substring sub. -func pktSubstr(sub string) func(*sideEffects) error { - return func(se *sideEffects) error { - for _, pkt := range se.got { - pkt := gopacket.NewPacket(pkt.eth, layers.LayerTypeEthernet, gopacket.Lazy) - got := pkt.String() - if strings.Contains(got, sub) { - return nil - } - } - return fmt.Errorf("packet summary with substring %q not found", sub) - } -} - -// numPkts returns a side effect checker func that checks whether -// the received number of ethernet packets was the given number. -func numPkts(want int) func(*sideEffects) error { - return func(se *sideEffects) error { - if len(se.got) == want { - return nil - } - return fmt.Errorf("got %d packets, want %d", len(se.got), want) - } -} - -func clientIPv4(n int) netip.Addr { - return netip.AddrFrom4([4]byte{192, 168, 0, byte(100 + n)}) -} - -var wanSLAACBase = netip.MustParseAddr("2052::50cc:ccff:fecc:cc01") - -// nodeLANIP6 returns a node number's Link Local SLAAC IPv6 address, -// such as fe80::50cc:ccff:fecc:cc03 for node 3. -func nodeWANIP6(n int) netip.Addr { - a := wanSLAACBase.As16() - a[15] = byte(n) - return netip.AddrFrom16(a) -} - -func newTwoNodesSameNetwork() (*Server, error) { - var c Config - nw := c.AddNetwork("192.168.0.1/24", "2052::1/64") - c.AddNode(nw) - c.AddNode(nw) - for _, c := range c.Nodes() { - c.SetVerboseSyslog(true) - } - return New(&c) -} - -func newTwoNodesSameV4Network() (*Server, error) { - var c Config - nw := c.AddNetwork("192.168.0.1/24") - c.AddNode(nw) - c.AddNode(nw) - for _, c := range c.Nodes() { - c.SetVerboseSyslog(true) - } - return New(&c) -} - -// TestProtocolQEMU tests the protocol that qemu uses to connect to natlab's -// vnet. (uint32-length prefixed ethernet frames over a unix stream socket) -// -// This test makes two clients (as qemu would act) and has one send an ethernet -// packet to the other virtual LAN segment. -func TestProtocolQEMU(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skipf("skipping on %s", runtime.GOOS) - } - s := must.Get(newTwoNodesSameNetwork()) - defer s.Close() - s.SetLoggerForTest(t.Logf) - - td := t.TempDir() - serverSock := filepath.Join(td, "vnet.sock") - - ln, err := net.Listen("unix", serverSock) - if err != nil { - t.Fatal(err) - } - defer ln.Close() - - var clientc [2]*net.UnixConn - for i := range clientc { - c, err := net.Dial("unix", serverSock) - if err != nil { - t.Fatal(err) - } - defer c.Close() - clientc[i] = c.(*net.UnixConn) - } - - for range clientc { - conn, err := ln.Accept() - if err != nil { - t.Fatal(err) - } - go s.ServeUnixConn(conn.(*net.UnixConn), ProtocolQEMU) - } - - sendBetweenClients(t, clientc, s, mkLenPrefixed) -} - -// TestProtocolUnixDgram tests the protocol that macOS Virtualization.framework -// uses to connect to vnet. (unix datagram sockets) -// -// It is similar to TestProtocolQEMU but uses unix datagram sockets instead of -// streams. -func TestProtocolUnixDgram(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skipf("skipping on %s", runtime.GOOS) - } - s := must.Get(newTwoNodesSameNetwork()) - defer s.Close() - s.SetLoggerForTest(t.Logf) - - td := t.TempDir() - serverSock := filepath.Join(td, "vnet.sock") - serverAddr := must.Get(net.ResolveUnixAddr("unixgram", serverSock)) - - var clientSock [2]string - for i := range clientSock { - clientSock[i] = filepath.Join(td, fmt.Sprintf("c%d.sock", i)) - } - - uc, err := net.ListenUnixgram("unixgram", serverAddr) - if err != nil { - t.Fatal(err) - } - go s.ServeUnixConn(uc, ProtocolUnixDGRAM) - - var clientc [2]*net.UnixConn - for i := range clientc { - c, err := net.DialUnix("unixgram", - must.Get(net.ResolveUnixAddr("unixgram", clientSock[i])), - serverAddr) - if err != nil { - t.Fatal(err) - } - defer c.Close() - clientc[i] = c - } - - sendBetweenClients(t, clientc, s, nil) -} - -// sendBetweenClients is a test helper that tries to send an ethernet frame from -// one client to another. -// -// It first makes the two clients send a packet to a fictitious node 3, which -// forces their src MACs to be registered with a networkWriter internally so -// they can receive traffic. -// -// Normally a node starts up spamming DHCP + NDP but we don't get that as a side -// effect here, so this does it manually. -// -// It also then waits for them to be registered. -// -// wrap is an optional function that wraps the packet before sending it. -func sendBetweenClients(t testing.TB, clientc [2]*net.UnixConn, s *Server, wrap func([]byte) []byte) { - t.Helper() - if wrap == nil { - wrap = func(b []byte) []byte { return b } - } - for i, c := range clientc { - must.Get(c.Write(wrap(mkEth(nodeMac(3), nodeMac(i+1), testingEthertype, []byte("hello"))))) - } - awaitCond(t, 5*time.Second, func() error { - if n := s.RegisteredWritersForTest(); n != 2 { - return fmt.Errorf("got %d registered writers, want 2", n) - } - return nil - }) - - // Now see if node1 can write to node2 and node2 receives it. - pkt := wrap(mkEth(nodeMac(2), nodeMac(1), testingEthertype, []byte("test-msg"))) - t.Logf("writing % 02x", pkt) - must.Get(clientc[0].Write(pkt)) - - buf := make([]byte, len(pkt)) - clientc[1].SetReadDeadline(time.Now().Add(5 * time.Second)) - n, err := clientc[1].Read(buf) - if err != nil { - t.Fatal(err) - } - got := buf[:n] - if !bytes.Equal(got, pkt) { - t.Errorf("bad packet\n got: % 02x\nwant: % 02x", got, pkt) - } -} - -func awaitCond(t testing.TB, timeout time.Duration, cond func() error) { - t.Helper() - t0 := time.Now() - for { - if err := cond(); err == nil { - return - } - if time.Since(t0) > timeout { - t.Fatalf("timed out after %v", timeout) - } - time.Sleep(10 * time.Millisecond) - } -} diff --git a/tstest/nettest/nettest.go b/tstest/nettest/nettest.go deleted file mode 100644 index 47c8857a57ce3..0000000000000 --- a/tstest/nettest/nettest.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package nettest contains additional test helpers related to network state -// that can't go into tstest for circular dependency reasons. -package nettest - -import ( - "testing" - - "tailscale.com/net/netmon" -) - -// SkipIfNoNetwork skips the test if it looks like there's no network -// access. -func SkipIfNoNetwork(t testing.TB) { - nm := netmon.NewStatic() - if !nm.InterfaceState().AnyInterfaceUp() { - t.Skip("skipping; test requires network but no interface is up") - } -} diff --git a/tstest/reflect.go b/tstest/reflect.go deleted file mode 100644 index 125391349a941..0000000000000 --- a/tstest/reflect.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import ( - "net/netip" - "reflect" - "testing" - "time" - - "tailscale.com/types/ptr" -) - -// IsZeroable is the interface for things with an IsZero method. -type IsZeroable interface { - IsZero() bool -} - -var ( - netipAddrType = reflect.TypeFor[netip.Addr]() - netipAddrPortType = reflect.TypeFor[netip.AddrPort]() - netipPrefixType = reflect.TypeFor[netip.Prefix]() - timeType = reflect.TypeFor[time.Time]() - timePtrType = reflect.TypeFor[*time.Time]() -) - -// CheckIsZero checks that the IsZero method of a given type functions -// correctly, by instantiating a new value of that type, changing a field, and -// then checking that the IsZero method returns false. -// -// The nonzeroValues map should contain non-zero values for each type that -// exists in the type T or any contained types. Basic types like string, bool, -// and numeric types are handled automatically. -func CheckIsZero[T IsZeroable](t testing.TB, nonzeroValues map[reflect.Type]any) { - t.Helper() - - var zero T - if !zero.IsZero() { - t.Errorf("zero value of %T is not IsZero", zero) - return - } - - var nonEmptyValue func(t reflect.Type) reflect.Value - nonEmptyValue = func(ty reflect.Type) reflect.Value { - if v, ok := nonzeroValues[ty]; ok { - return reflect.ValueOf(v) - } - - switch ty { - // Given that we're a networking company, probably fine to have - // a special case for netip.Addr :) - case netipAddrType: - return reflect.ValueOf(netip.MustParseAddr("1.2.3.4")) - case netipAddrPortType: - return reflect.ValueOf(netip.MustParseAddrPort("1.2.3.4:9999")) - case netipPrefixType: - return reflect.ValueOf(netip.MustParsePrefix("1.2.3.4/24")) - - case timeType: - return reflect.ValueOf(time.Unix(1704067200, 0)) - case timePtrType: - return reflect.ValueOf(ptr.To(time.Unix(1704067200, 0))) - } - - switch ty.Kind() { - case reflect.String: - return reflect.ValueOf("foo").Convert(ty) - case reflect.Bool: - return reflect.ValueOf(true) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return reflect.ValueOf(int64(-42)).Convert(ty) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return reflect.ValueOf(uint64(42)).Convert(ty) - case reflect.Float32, reflect.Float64: - return reflect.ValueOf(float64(3.14)).Convert(ty) - case reflect.Complex64, reflect.Complex128: - return reflect.ValueOf(complex(3.14, 2.71)).Convert(ty) - case reflect.Chan: - return reflect.MakeChan(ty, 1) - - // For slices, ensure that the slice is non-empty. - case reflect.Slice: - v := nonEmptyValue(ty.Elem()) - sl := reflect.MakeSlice(ty, 1, 1) - sl.Index(0).Set(v) - return sl - - case reflect.Map: - // Create a map with a single key-value pair, recursively creating each. - k := nonEmptyValue(ty.Key()) - v := nonEmptyValue(ty.Elem()) - - m := reflect.MakeMap(ty) - m.SetMapIndex(k, v) - return m - - default: - panic("unhandled type " + ty.String()) - } - } - - typ := reflect.TypeFor[T]() - for i, n := 0, typ.NumField(); i < n; i++ { - sf := typ.Field(i) - - var nonzero T - rv := reflect.ValueOf(&nonzero).Elem() - rv.Field(i).Set(nonEmptyValue(sf.Type)) - if nonzero.IsZero() { - t.Errorf("IsZero = true with %v set; want false\nvalue: %#v", sf.Name, nonzero) - } - } -} diff --git a/tstest/resource.go b/tstest/resource.go deleted file mode 100644 index b094c7911014f..0000000000000 --- a/tstest/resource.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import ( - "bytes" - "runtime" - "runtime/pprof" - "testing" - "time" - - "github.com/google/go-cmp/cmp" -) - -// ResourceCheck takes a snapshot of the current goroutines and registers a -// cleanup on tb to verify that after the rest, all goroutines created by the -// test go away. (well, at least that the count matches. Maybe in the future it -// can look at specific routines). -// -// It panics if called from a parallel test. -func ResourceCheck(tb testing.TB) { - tb.Helper() - - // Set an environment variable (anything at all) just for the - // side effect of tb.Setenv panicking if we're in a parallel test. - tb.Setenv("TS_CHECKING_RESOURCES", "1") - - startN, startStacks := goroutines() - tb.Cleanup(func() { - if tb.Failed() { - // Test has failed - but this doesn't catch panics due to - // https://github.com/golang/go/issues/49929. - return - } - // Goroutines might be still exiting. - for range 300 { - if runtime.NumGoroutine() <= startN { - return - } - time.Sleep(10 * time.Millisecond) - } - endN, endStacks := goroutines() - if endN <= startN { - return - } - tb.Logf("goroutine diff:\n%v\n", cmp.Diff(startStacks, endStacks)) - - // tb.Failed() above won't report on panics, so we shouldn't call Fatal - // here or we risk suppressing reporting of the panic. - tb.Errorf("goroutine count: expected %d, got %d\n", startN, endN) - }) -} - -func goroutines() (int, []byte) { - p := pprof.Lookup("goroutine") - b := new(bytes.Buffer) - p.WriteTo(b, 1) - return p.Count(), b.Bytes() -} diff --git a/tstest/tailmac/Host.entitlements b/tstest/tailmac/Host.entitlements deleted file mode 100644 index d7d0d6e8b6c29..0000000000000 --- a/tstest/tailmac/Host.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.virtualization - - - diff --git a/tstest/tailmac/LICENSE/LICENSE.txt b/tstest/tailmac/LICENSE/LICENSE.txt deleted file mode 100644 index 733d1795a97d1..0000000000000 --- a/tstest/tailmac/LICENSE/LICENSE.txt +++ /dev/null @@ -1,8 +0,0 @@ -Copyright © 2023 Apple Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/tstest/tailmac/Makefile b/tstest/tailmac/Makefile deleted file mode 100644 index b87e44ed1c49d..0000000000000 --- a/tstest/tailmac/Makefile +++ /dev/null @@ -1,23 +0,0 @@ -XCPRETTIFIER := xcpretty -ifeq (, $(shell which $(XCPRETTIFIER))) - XCPRETTIFIER := cat -endif - -.PHONY: tailmac -tailmac: - xcodebuild -scheme tailmac -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER) - cp -r ./build/Build/Products/Release/tailmac ./bin/tailmac - -.PHONY: host -host: - xcodebuild -scheme host -destination 'platform=macOS,arch=arm64' -derivedDataPath build -configuration Release build | $(XCPRETTIFIER) - cp -r ./build/Build/Products/Release/Host.app ./bin/Host.app - -.PHONY: clean -clean: - rm -rf ./bin - rm -rf ./build - mkdir -p ./bin - -.PHONY: all -all: clean tailmac host diff --git a/tstest/tailmac/README.md b/tstest/tailmac/README.md deleted file mode 100644 index a8b9f2598dde3..0000000000000 --- a/tstest/tailmac/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# Lightweight macOS VM's for tstest and natlab - -This utility is designed to provide custom virtual machine tooling support for macOS. The intent -is to quickly create and spin up small, preconfigured virtual machines, for executing integration -and unit tests. - -The primary driver is to provide support for VZVirtioNetworkDeviceConfiguration which is not -supported by other popular macOS VM hosts. This also gives us the freedom to fully customize and script -all virtual machine setup and interaction. VZVirtioNetworkDeviceConfiguration lets us -directly inject and sink network traffic for simulating various network conditions, -protocols, and topologies and ensure that the TailScale clients handle all of these situations correctly. - -This may also be used as a drop-in replacement for UTM or Tart on ARM Macs for quickly spinning up -test VMs. It has the added benefit that, unlike UTM which uses AppleScript, it can be run -via SSH. - -This uses Virtualization.framework which only supports arm64. The binaries only build for arm64. - - -## Components - -The application is built in two components: - -The tailmac command line utility is used to set up and configure VM instances. The Host.app does the heavy lifting. - -You will typically initiate all interactions via the tailmac command-line util. - -For a full list of options: -``` -tailmac -h -``` - - -## Building - -``` -% make all -``` - -Will build both the tailmac command line util and Host.app. You will need a developer account. The default bundle identifiers -default to TailScale owned ids, so if you don't have (or aren't using) a TailScale dev account, you will need to change this. -This should build automatically as long as you have a valid developer cert. Signing is automatic. The binaries both -require the virtualization entitlement, so they do need to be signed. - -There are separate recipes in the makefile to rebuild the individual components if needed. - -All binaries are copied to the bin directory. - - -## Locations - -All vm images, restore images, block device files, save states, and other supporting files are persisted at ~/VM.bundle - -Each vm gets its own directory. These can be archived for posterity to preserve a particular image and/or state. -The mere existence of a directory containing all of the required files in ~/VM.bundle is sufficient for tailmac to -be able to see and run it. ~/VM.bundle and it's contents *is* tailmac's state. No other state is maintained elsewhere. - -Each vm has its own custom configuration which can be modified while the vm is idle. It's simple JSON - you may -modify this directly, or using 'tailmac configure'. - - -## Installing - -### Default a parameters - -* The default virtio socket device port is 51009 -* The default server socket for the virtual network device is /tmp/qemu-dgram.sock -* The default memory size is 4Gb -* The default mac address for the socket based networking is 52:cc:cc:cc:cc:01 -* The default mac address for the standard ethernet interface is 52:cc:cc:cc:ce:01 - -### Creating and managing VMs - - You generally perform all interactions via the tailmac command line util. A NAT ethernet device is provided so - you can ssh into your instance. The ethernet IP will be dhcp assigned by the host and can be determined by parsing - the contents of /var/db/dhcpd_leases - -#### Creation - -To create a new VM (this will grab a restore image for what apples deems a 'latest; if needed). Restore images are large -(on the order of 10 Gb) and installation after downloading takes a few minutes. If you wish to use a custom restore image, -specify it with the --image option. If RestoreImage.ipsw exists in ~/VM.bundle, it will be used. macOS versions from -12 to 15 have been tested and appear to work correctly. -``` -tailmac create --id my_vm_id -``` - -With a custom restore image and parameters: -``` -tailmac create --id my_custom_vm_id --image "/images/macos_ventura.ipsw" --mac 52:cc:cc:cc:cc:07 --mem 8000000000 --sock "/temp/custom.sock" --port 52345 -``` - -A typical workflow would be to create single VM, manually set it up the way you wish including the installation of any required client side software -(tailscaled or the client-side test harness for example) then clone that images as required and back up your -images for future use. - -Fetching and persisting pre-configured images is left as an exercise for the reader (for now). A previously used image can simply be copied to the -~/VM.bundle directory under a unique path and tailmac will automatically pick it up. No versioning is supported so old images may stop working in -the future. - -To delete a VM image, you may simply remove it's directory under ~/VM.bundle or -``` -tailmac delete --id my_stale_vm -``` - -Note that the disk size is fixed, but should be sufficient (perhaps even excessive) for most lightweight workflows. - -#### Restore Images - -To refresh an existing restore image: -``` -tailmac refresh -``` - -Restore images can also be obtained directly from Apple for all macOS releases. Note Apple restore images are raw installs, and the OS will require -configuration, user setup, etc before being useful. Cloning a vm after clicking through the setup, creating a user and disabling things like the -lock screen and enabling auto-login will save you time in the future. - - -#### Cloning - -To clone an existing vm (this will clone the mac and port as well) -``` -tailmac clone --id old_vm_id --target-id new_vm_id -``` - -#### Configuration - -To reconfigure a existing vm: -``` -tailmac configure --id vm_id --mac 11:22:33:44:55:66 --port 12345 --ethermac 22:33:44:55:66:77 -sock "/tmp/my.sock" -``` - -## Running a VM - -To list the available VM images -``` -tailmac ls -``` - -To launch an VM -``` -tailmac run --id machine_1 -``` - - You may invoke multiple vms, but the limit on the number of concurrent instances is on the order of 2. Use the --tail option to watch the stdout of the - Host.app process. There is currently no way to list the running VM instances, but invoking stop or halt for a vm instance - that is not running is perfectly safe. - - To gracefully stop a running VM and save its state (this is a fire and forget thing): - - ``` - tailmac stop --id machine_1 - ``` - -Manually closing a VM's window will save the VM's state (if possible) and is the equivalent of running 'tailmac stop --id vm_id' - - To halt a running vm without saving its state: - ``` - tailmac halt --id machine_1 - ``` diff --git a/tstest/tailmac/Swift/Common/Config.swift b/tstest/tailmac/Swift/Common/Config.swift deleted file mode 100644 index 18b68ae9b9d14..0000000000000 --- a/tstest/tailmac/Swift/Common/Config.swift +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Foundation - -let kDefaultDiskSizeGb: Int64 = 72 -let kDefaultMemSizeGb: UInt64 = 72 - - -/// Represents a configuration for a virtual machine -class Config: Codable { - var serverSocket = "/tmp/qemu-dgram.sock" - var memorySize = (kDefaultMemSizeGb * 1024 * 1024 * 1024) as UInt64 - var mac = "52:cc:cc:cc:cc:01" - var ethermac = "52:cc:cc:cc:ce:01" - var port: UInt32 = 51009 - var sharedDir: String? - - // The virtual machines ID. Also double as the directory name under which - // we will store configuration, block device, etc. - let vmID: String - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if let ethermac = try container.decodeIfPresent(String.self, forKey: .ethermac) { - self.ethermac = ethermac - } - if let serverSocket = try container.decodeIfPresent(String.self, forKey: .serverSocket) { - self.serverSocket = serverSocket - } - if let memorySize = try container.decodeIfPresent(UInt64.self, forKey: .memorySize) { - self.memorySize = memorySize - } - if let port = try container.decodeIfPresent(UInt32.self, forKey: .port) { - self.port = port - } - if let mac = try container.decodeIfPresent(String.self, forKey: .mac) { - self.mac = mac - } - if let vmID = try container.decodeIfPresent(String.self, forKey: .vmID) { - self.vmID = vmID - } else { - self.vmID = "default" - } - } - - init(_ vmID: String = "default") { - self.vmID = vmID - let configFile = vmDataURL.appendingPathComponent("config.json") - if FileManager.default.fileExists(atPath: configFile.path()) { - print("Using config file at path \(configFile)") - if let jsonData = try? Data(contentsOf: configFile) { - let config = try! JSONDecoder().decode(Config.self, from: jsonData) - self.serverSocket = config.serverSocket - self.memorySize = config.memorySize - self.mac = config.mac - self.port = config.port - self.ethermac = config.ethermac - } - } - } - - func persist() { - let configFile = vmDataURL.appendingPathComponent("config.json") - let data = try! JSONEncoder().encode(self) - try! data.write(to: configFile) - } - - lazy var restoreImageURL: URL = { - vmBundleURL.appendingPathComponent("RestoreImage.ipsw") - }() - - // The VM Data URL holds the specific files composing a unique VM guest instance - // By default, VM's are persisted at ~/VM.bundle/ - lazy var vmDataURL = { - let dataURL = vmBundleURL.appendingPathComponent(vmID) - return dataURL - }() - - lazy var auxiliaryStorageURL = { - vmDataURL.appendingPathComponent("AuxiliaryStorage") - }() - - lazy var diskImageURL = { - vmDataURL.appendingPathComponent("Disk.img") - }() - - lazy var diskSize: Int64 = { - kDefaultDiskSizeGb * 1024 * 1024 * 1024 - }() - - lazy var hardwareModelURL = { - vmDataURL.appendingPathComponent("HardwareModel") - }() - - lazy var machineIdentifierURL = { - vmDataURL.appendingPathComponent("MachineIdentifier") - }() - - lazy var saveFileURL = { - vmDataURL.appendingPathComponent("SaveFile.vzvmsave") - }() - -} - -// The VM Bundle URL holds the restore image and a set of VM images -// By default, VM's are persisted at ~/VM.bundle -var vmBundleURL: URL = { - let vmBundlePath = NSHomeDirectory() + "/VM.bundle/" - createDir(vmBundlePath) - let bundleURL = URL(fileURLWithPath: vmBundlePath) - return bundleURL -}() - - -func createDir(_ path: String) { - do { - try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) - } catch { - fatalError("Unable to create dir at \(path) \(error)") - } -} - - - - diff --git a/tstest/tailmac/Swift/Common/Notifications.swift b/tstest/tailmac/Swift/Common/Notifications.swift deleted file mode 100644 index de2216e227eb7..0000000000000 --- a/tstest/tailmac/Swift/Common/Notifications.swift +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Foundation - -struct Notifications { - // Stops the virtual machine and saves its state - static var stop = Notification.Name("io.tailscale.macvmhost.stop") - - // Pauses the virtual machine and exits without saving its state - static var halt = Notification.Name("io.tailscale.macvmhost.halt") -} diff --git a/tstest/tailmac/Swift/Common/TailMacConfigHelper.swift b/tstest/tailmac/Swift/Common/TailMacConfigHelper.swift deleted file mode 100644 index c0961c883fdbb..0000000000000 --- a/tstest/tailmac/Swift/Common/TailMacConfigHelper.swift +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Foundation -import Virtualization - -struct TailMacConfigHelper { - let config: Config - - func computeCPUCount() -> Int { - let totalAvailableCPUs = ProcessInfo.processInfo.processorCount - - var virtualCPUCount = totalAvailableCPUs <= 1 ? 1 : totalAvailableCPUs - 1 - virtualCPUCount = max(virtualCPUCount, VZVirtualMachineConfiguration.minimumAllowedCPUCount) - virtualCPUCount = min(virtualCPUCount, VZVirtualMachineConfiguration.maximumAllowedCPUCount) - - return virtualCPUCount - } - - func computeMemorySize() -> UInt64 { - // Set the amount of system memory to 4 GB; this is a baseline value - // that you can change depending on your use case. - var memorySize = config.memorySize - memorySize = max(memorySize, VZVirtualMachineConfiguration.minimumAllowedMemorySize) - memorySize = min(memorySize, VZVirtualMachineConfiguration.maximumAllowedMemorySize) - - return memorySize - } - - func createBootLoader() -> VZMacOSBootLoader { - return VZMacOSBootLoader() - } - - func createGraphicsDeviceConfiguration() -> VZMacGraphicsDeviceConfiguration { - let graphicsConfiguration = VZMacGraphicsDeviceConfiguration() - graphicsConfiguration.displays = [ - // The system arbitrarily chooses the resolution of the display to be 1920 x 1200. - VZMacGraphicsDisplayConfiguration(widthInPixels: 1920, heightInPixels: 1200, pixelsPerInch: 80) - ] - - return graphicsConfiguration - } - - func createBlockDeviceConfiguration() -> VZVirtioBlockDeviceConfiguration { - do { - let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: config.diskImageURL, readOnly: false) - let disk = VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment) - return disk - } catch { - fatalError("Failed to create Disk image. \(error)") - } - } - - func createSocketDeviceConfiguration() -> VZVirtioSocketDeviceConfiguration { - return VZVirtioSocketDeviceConfiguration() - } - - func createNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration { - let networkDevice = VZVirtioNetworkDeviceConfiguration() - networkDevice.macAddress = VZMACAddress(string: config.ethermac)! - - /* Bridged networking requires special entitlements from Apple - if let interface = VZBridgedNetworkInterface.networkInterfaces.first(where: { $0.identifier == "en0" }) { - let networkAttachment = VZBridgedNetworkDeviceAttachment(interface: interface) - networkDevice.attachment = networkAttachment - } else { - print("Assuming en0 for bridged ethernet. Could not findd adapter") - }*/ - - /// But we can do NAT without Tim Apple's approval - let networkAttachment = VZNATNetworkDeviceAttachment() - networkDevice.attachment = networkAttachment - - return networkDevice - } - - func createSocketNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration { - let networkDevice = VZVirtioNetworkDeviceConfiguration() - networkDevice.macAddress = VZMACAddress(string: config.mac)! - - let socket = Darwin.socket(AF_UNIX, SOCK_DGRAM, 0) - - // Outbound network packets - let serverSocket = config.serverSocket - - // Inbound network packets - let clientSockId = config.vmID - let clientSocket = "/tmp/qemu-dgram-\(clientSockId).sock" - - unlink(clientSocket) - var clientAddr = sockaddr_un() - clientAddr.sun_family = sa_family_t(AF_UNIX) - clientSocket.withCString { ptr in - withUnsafeMutablePointer(to: &clientAddr.sun_path.0) { dest in - _ = strcpy(dest, ptr) - } - } - - let bindRes = Darwin.bind(socket, - withUnsafePointer(to: &clientAddr, { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { $0 } }), - socklen_t(MemoryLayout.size)) - - if bindRes == -1 { - print("Error binding virtual network client socket - \(String(cString: strerror(errno)))") - return networkDevice - } - - var serverAddr = sockaddr_un() - serverAddr.sun_family = sa_family_t(AF_UNIX) - serverSocket.withCString { ptr in - withUnsafeMutablePointer(to: &serverAddr.sun_path.0) { dest in - _ = strcpy(dest, ptr) - } - } - - let connectRes = Darwin.connect(socket, - withUnsafePointer(to: &serverAddr, { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { $0 } }), - socklen_t(MemoryLayout.size)) - - if connectRes == -1 { - print("Error binding virtual network server socket - \(String(cString: strerror(errno)))") - return networkDevice - } - - print("Virtual if mac address is \(config.mac)") - print("Client bound to \(clientSocket)") - print("Connected to server at \(serverSocket)") - print("Socket fd is \(socket)") - - - let handle = FileHandle(fileDescriptor: socket) - let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle) - networkDevice.attachment = device - return networkDevice - } - - func createPointingDeviceConfiguration() -> VZPointingDeviceConfiguration { - return VZMacTrackpadConfiguration() - } - - func createKeyboardConfiguration() -> VZKeyboardConfiguration { - return VZMacKeyboardConfiguration() - } - - func createDirectoryShareConfiguration(tag: String) -> VZDirectorySharingDeviceConfiguration? { - guard let dir = config.sharedDir else { return nil } - - let sharedDir = VZSharedDirectory(url: URL(fileURLWithPath: dir), readOnly: false) - let share = VZSingleDirectoryShare(directory: sharedDir) - - // Create the VZVirtioFileSystemDeviceConfiguration and assign it a unique tag. - let sharingConfiguration = VZVirtioFileSystemDeviceConfiguration(tag: tag) - sharingConfiguration.share = share - - return sharingConfiguration - } -} - diff --git a/tstest/tailmac/Swift/Host/AppDelegate.swift b/tstest/tailmac/Swift/Host/AppDelegate.swift deleted file mode 100644 index 63c0192da236e..0000000000000 --- a/tstest/tailmac/Swift/Host/AppDelegate.swift +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Cocoa -import Foundation -import Virtualization - -class AppDelegate: NSObject, NSApplicationDelegate { - @IBOutlet var window: NSWindow! - - @IBOutlet weak var virtualMachineView: VZVirtualMachineView! - - var runner: VMController! - - func applicationDidFinishLaunching(_ aNotification: Notification) { - DispatchQueue.main.async { [self] in - runner = VMController() - runner.createVirtualMachine() - virtualMachineView.virtualMachine = runner.virtualMachine - virtualMachineView.capturesSystemKeys = true - - // Configure the app to automatically respond to changes in the display size. - virtualMachineView.automaticallyReconfiguresDisplay = true - - let fileManager = FileManager.default - if fileManager.fileExists(atPath: config.saveFileURL.path) { - print("Restoring virtual machine state from \(config.saveFileURL)") - runner.restoreVirtualMachine() - } else { - print("Restarting virtual machine") - runner.startVirtualMachine() - } - - } - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - if runner.virtualMachine.state == .running { - runner.pauseAndSaveVirtualMachine(completionHandler: { - sender.reply(toApplicationShouldTerminate: true) - }) - - return .terminateLater - } - - return .terminateNow - } -} diff --git a/tstest/tailmac/Swift/Host/Assets.xcassets/AccentColor.colorset/Contents.json b/tstest/tailmac/Swift/Host/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897008164..0000000000000 --- a/tstest/tailmac/Swift/Host/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/tstest/tailmac/Swift/Host/Assets.xcassets/AppIcon.appiconset/Contents.json b/tstest/tailmac/Swift/Host/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 3f00db43ec3c8..0000000000000 --- a/tstest/tailmac/Swift/Host/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "images" : [ - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/tstest/tailmac/Swift/Host/Assets.xcassets/Contents.json b/tstest/tailmac/Swift/Host/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7fca..0000000000000 --- a/tstest/tailmac/Swift/Host/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/tstest/tailmac/Swift/Host/Base.lproj/MainMenu.xib b/tstest/tailmac/Swift/Host/Base.lproj/MainMenu.xib deleted file mode 100644 index 547e5f05dfb09..0000000000000 --- a/tstest/tailmac/Swift/Host/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,696 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Default - - - - - - - Left to Right - - - - - - - Right to Left - - - - - - - - - - - Default - - - - - - - Left to Right - - - - - - - Right to Left - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tstest/tailmac/Swift/Host/HostCli.swift b/tstest/tailmac/Swift/Host/HostCli.swift deleted file mode 100644 index c31478cc39d45..0000000000000 --- a/tstest/tailmac/Swift/Host/HostCli.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Cocoa -import Foundation -import Virtualization -import ArgumentParser - -@main -struct HostCli: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "A utility for running virtual machines", - subcommands: [Run.self], - defaultSubcommand: Run.self) -} - -var config: Config = Config() - -extension HostCli { - struct Run: ParsableCommand { - @Option var id: String - @Option var share: String? - - mutating func run() { - config = Config(id) - config.sharedDir = share - print("Running vm with identifier \(id) and sharedDir \(share ?? "")") - _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) - } - } -} - diff --git a/tstest/tailmac/Swift/Host/Info.plist b/tstest/tailmac/Swift/Host/Info.plist deleted file mode 100644 index 0c67376ebacb4..0000000000000 --- a/tstest/tailmac/Swift/Host/Info.plist +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/tstest/tailmac/Swift/Host/VMController.swift b/tstest/tailmac/Swift/Host/VMController.swift deleted file mode 100644 index fe4a3828b18fe..0000000000000 --- a/tstest/tailmac/Swift/Host/VMController.swift +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Cocoa -import Foundation -import Virtualization -import Foundation - -class VMController: NSObject, VZVirtualMachineDelegate { - var virtualMachine: VZVirtualMachine! - - lazy var helper = TailMacConfigHelper(config: config) - - override init() { - super.init() - listenForNotifications() - } - - func listenForNotifications() { - let nc = DistributedNotificationCenter() - nc.addObserver(forName: Notifications.stop, object: nil, queue: nil) { notification in - if let vmID = notification.userInfo?["id"] as? String { - if config.vmID == vmID { - print("We've been asked to stop... Saving state and exiting") - self.pauseAndSaveVirtualMachine { - exit(0) - } - } - } - } - - nc.addObserver(forName: Notifications.halt, object: nil, queue: nil) { notification in - if let vmID = notification.userInfo?["id"] as? String { - if config.vmID == vmID { - print("We've been asked to stop... Saving state and exiting") - self.virtualMachine.pause { (result) in - if case let .failure(error) = result { - fatalError("Virtual machine failed to pause with \(error)") - } - exit(0) - } - } - } - } - } - - func createMacPlaform() -> VZMacPlatformConfiguration { - let macPlatform = VZMacPlatformConfiguration() - - let auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: config.auxiliaryStorageURL) - macPlatform.auxiliaryStorage = auxiliaryStorage - - if !FileManager.default.fileExists(atPath: config.vmDataURL.path()) { - fatalError("Missing Virtual Machine Bundle at \(config.vmDataURL). Run InstallationTool first to create it.") - } - - // Retrieve the hardware model and save this value to disk during installation. - guard let hardwareModelData = try? Data(contentsOf: config.hardwareModelURL) else { - fatalError("Failed to retrieve hardware model data.") - } - - guard let hardwareModel = VZMacHardwareModel(dataRepresentation: hardwareModelData) else { - fatalError("Failed to create hardware model.") - } - - if !hardwareModel.isSupported { - fatalError("The hardware model isn't supported on the current host") - } - macPlatform.hardwareModel = hardwareModel - - // Retrieve the machine identifier and save this value to disk during installation. - guard let machineIdentifierData = try? Data(contentsOf: config.machineIdentifierURL) else { - fatalError("Failed to retrieve machine identifier data.") - } - - guard let machineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifierData) else { - fatalError("Failed to create machine identifier.") - } - macPlatform.machineIdentifier = machineIdentifier - - return macPlatform - } - - func createVirtualMachine() { - let virtualMachineConfiguration = VZVirtualMachineConfiguration() - - virtualMachineConfiguration.platform = createMacPlaform() - virtualMachineConfiguration.bootLoader = helper.createBootLoader() - virtualMachineConfiguration.cpuCount = helper.computeCPUCount() - virtualMachineConfiguration.memorySize = helper.computeMemorySize() - virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()] - virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()] - virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()] - virtualMachineConfiguration.pointingDevices = [helper.createPointingDeviceConfiguration()] - virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()] - virtualMachineConfiguration.socketDevices = [helper.createSocketDeviceConfiguration()] - - if let dir = config.sharedDir, let shareConfig = helper.createDirectoryShareConfiguration(tag: "vmshare") { - print("Sharing \(dir) as vmshare. Use: mount_virtiofs vmshare in the guest to mount.") - virtualMachineConfiguration.directorySharingDevices = [shareConfig] - } else { - print("No shared directory created. \(config.sharedDir ?? "none") was requested.") - } - - try! virtualMachineConfiguration.validate() - try! virtualMachineConfiguration.validateSaveRestoreSupport() - - virtualMachine = VZVirtualMachine(configuration: virtualMachineConfiguration) - virtualMachine.delegate = self - } - - - func startVirtualMachine() { - virtualMachine.start(completionHandler: { (result) in - if case let .failure(error) = result { - fatalError("Virtual machine failed to start with \(error)") - } - self.startSocketDevice() - }) - } - - func startSocketDevice() { - if let device = virtualMachine.socketDevices.first as? VZVirtioSocketDevice { - print("Configuring socket device at port \(config.port)") - device.connect(toPort: config.port) { connection in - //TODO: Anything? Or is this enough to bootstrap it on both ends? - } - } else { - print("Virtual machine could not start it's socket device") - } - } - - func resumeVirtualMachine() { - virtualMachine.resume(completionHandler: { (result) in - if case let .failure(error) = result { - fatalError("Virtual machine failed to resume with \(error)") - } - }) - } - - func restoreVirtualMachine() { - virtualMachine.restoreMachineStateFrom(url: config.saveFileURL, completionHandler: { [self] (error) in - // Remove the saved file. Whether success or failure, the state no longer matches the VM's disk. - let fileManager = FileManager.default - try! fileManager.removeItem(at: config.saveFileURL) - - if error == nil { - self.resumeVirtualMachine() - } else { - self.startVirtualMachine() - } - }) - } - - func saveVirtualMachine(completionHandler: @escaping () -> Void) { - virtualMachine.saveMachineStateTo(url: config.saveFileURL, completionHandler: { (error) in - guard error == nil else { - fatalError("Virtual machine failed to save with \(error!)") - } - - completionHandler() - }) - } - - func pauseAndSaveVirtualMachine(completionHandler: @escaping () -> Void) { - virtualMachine.pause { result in - if case let .failure(error) = result { - fatalError("Virtual machine failed to pause with \(error)") - } - - self.saveVirtualMachine(completionHandler: completionHandler) - } - } - - // MARK: - VZVirtualMachineDeleate - - func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) { - print("Virtual machine did stop with error: \(error.localizedDescription)") - exit(-1) - } - - func guestDidStop(_ virtualMachine: VZVirtualMachine) { - print("Guest did stop virtual machine.") - exit(0) - } -} diff --git a/tstest/tailmac/Swift/TailMac/RestoreImage.swift b/tstest/tailmac/Swift/TailMac/RestoreImage.swift deleted file mode 100644 index c2b8b3dd6a878..0000000000000 --- a/tstest/tailmac/Swift/TailMac/RestoreImage.swift +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Foundation -import Virtualization - -class RestoreImage: NSObject { - private var downloadObserver: NSKeyValueObservation? - - // MARK: Observe the download progress. - - var restoreImageURL: URL - - init(_ dest: URL) { - restoreImageURL = dest - } - - public func download(completionHandler: @escaping () -> Void) { - print("Attempting to download latest available restore image.") - VZMacOSRestoreImage.fetchLatestSupported { [self](result: Result) in - switch result { - case let .failure(error): - fatalError(error.localizedDescription) - - case let .success(restoreImage): - downloadRestoreImage(restoreImage: restoreImage, completionHandler: completionHandler) - } - } - } - - private func downloadRestoreImage(restoreImage: VZMacOSRestoreImage, completionHandler: @escaping () -> Void) { - let downloadTask = URLSession.shared.downloadTask(with: restoreImage.url) { localURL, response, error in - if let error = error { - fatalError("Download failed. \(error.localizedDescription).") - } - - do { - try FileManager.default.moveItem(at: localURL!, to: self.restoreImageURL) - } catch { - fatalError("Failed to move downloaded restore image to \(self.restoreImageURL) \(error).") - } - - - completionHandler() - } - - var lastPct = 0 - downloadObserver = downloadTask.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in - let pct = Int(change.newValue! * 100) - if pct != lastPct { - print("Restore image download progress: \(pct)%") - lastPct = pct - } - } - downloadTask.resume() - } -} - diff --git a/tstest/tailmac/Swift/TailMac/TailMac.swift b/tstest/tailmac/Swift/TailMac/TailMac.swift deleted file mode 100644 index 84aa5e498a008..0000000000000 --- a/tstest/tailmac/Swift/TailMac/TailMac.swift +++ /dev/null @@ -1,335 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Foundation -import Virtualization -import ArgumentParser - -var usage = -""" -Installs and configures VMs suitable for use with natlab - -To create a new VM (this will grab a restore image if needed) -tailmac create --id - -To refresh an existing restore image: -tailmac refresh - -To clone a vm (this will clone the mac and port as well) -tailmac clone --identfier --target-id - -To reconfigure a vm: -tailmac configure --id --mac 11:22:33:44:55:66 --port 12345 --mem 8000000000000 -sock "/tmp/mySock.sock" - -To run a vm: -tailmac run --id - -To stop a vm: (this may take a minute - the vm needs to persist it's state) -tailmac stop --id - -To halt a vm without persisting its state -tailmac halt --id - -To delete a vm: -tailmac delete --id - -To list the available VM images: -tailmac ls -""" - -@main -struct Tailmac: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "A utility for setting up VM images", - usage: usage, - subcommands: [Create.self, Clone.self, Delete.self, Configure.self, Stop.self, Run.self, Ls.self, Halt.self], - defaultSubcommand: Ls.self) -} - -extension Tailmac { - struct Ls: ParsableCommand { - mutating func run() { - do { - let dirs = try FileManager.default.contentsOfDirectory(atPath: vmBundleURL.path()) - var images = [String]() - - // This assumes we don't put anything else interesting in our VM.bundle dir - // You may need to add some other exclusions or checks here if that's the case. - for dir in dirs { - if !dir.contains("ipsw") { - images.append(URL(fileURLWithPath: dir).lastPathComponent) - } - } - print("Available images:\n\(images)") - } catch { - fatalError("Failed to query available images \(error)") - } - } - } -} - -extension Tailmac { - struct Stop: ParsableCommand { - @Option(help: "The vm identifier") var id: String - - mutating func run() { - print("Stopping vm with id \(id). This may take some time!") - let nc = DistributedNotificationCenter() - nc.post(name: Notifications.stop, object: nil, userInfo: ["id": id]) - } - } -} - -extension Tailmac { - struct Halt: ParsableCommand { - @Option(help: "The vm identifier") var id: String - - mutating func run() { - print("Halting vm with id \(id)") - let nc = DistributedNotificationCenter() - nc.post(name: Notifications.halt, object: nil, userInfo: ["id": id]) - } - } -} - -extension Tailmac { - struct Run: ParsableCommand { - @Option(help: "The vm identifier") var id: String - @Option(help: "Optional share directory") var share: String? - @Flag(help: "Tail the TailMac log output instead of returning immediatly") var tail - - mutating func run() { - let process = Process() - let stdOutPipe = Pipe() - - let executablePath = CommandLine.arguments[0] - let executableDirectory = (executablePath as NSString).deletingLastPathComponent - let appPath = executableDirectory + "/Host.app/Contents/MacOS/Host" - - process.executableURL = URL( - fileURLWithPath: appPath, - isDirectory: false, - relativeTo: NSRunningApplication.current.bundleURL - ) - - if !FileManager.default.fileExists(atPath: appPath) { - fatalError("Could not find Host.app at \(appPath). This must be co-located with the tailmac utility") - } - - var args = ["run", "--id", id] - if let share { - args.append("--share") - args.append(share) - } - process.arguments = args - - do { - process.standardOutput = stdOutPipe - try process.run() - } catch { - fatalError("Unable to launch the vm process") - } - - if tail != 0 { - // (jonathan)TODO: How do we get the process output in real time? - // The child process only seems to flush to stdout on completion - let outHandle = stdOutPipe.fileHandleForReading - outHandle.readabilityHandler = { handle in - let data = handle.availableData - if data.count > 0 { - if let str = String(data: data, encoding: String.Encoding.utf8) { - print(str) - } - } - } - process.waitUntilExit() - } - } - } -} - -extension Tailmac { - struct Configure: ParsableCommand { - @Option(help: "The vm identifier") var id: String - @Option(help: "The mac address of the socket network interface") var mac: String? - @Option(help: "The port for the virtio socket device") var port: String? - @Option(help: "The named socket for the socket network interface") var sock: String? - @Option(help: "The desired RAM in bytes") var mem: String? - @Option(help: "The ethernet address for a standard NAT adapter") var ethermac: String? - - mutating func run() { - let config = Config(id) - - let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path()) - if !vmExists { - print("VM with id \(id) doesn't exist. Cannot configure.") - return - } - - if let mac { - config.mac = mac - } - if let port, let portInt = UInt32(port) { - config.port = portInt - } - if let ethermac { - config.ethermac = ethermac - } - if let mem, let membytes = UInt64(mem) { - config.memorySize = membytes - } - if let sock { - config.serverSocket = sock - } - - config.persist() - - let str = String(data:try! JSONEncoder().encode(config), encoding: .utf8)! - print("New Config: \(str)") - } - } -} - -extension Tailmac { - struct Delete: ParsableCommand { - @Option(help: "The vm identifer") var id: String? - - mutating func run() { - guard let id else { - print("Usage: Installer delete --id=") - return - } - - let config = Config(id) - - let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path()) - if !vmExists { - print("VM with id \(id) doesn't exist. Cannot delete.") - return - } - - do { - try FileManager.default.removeItem(at: config.vmDataURL) - } catch { - print("Whoops... Deletion failed \(error)") - } - } - } -} - - -extension Tailmac { - struct Clone: ParsableCommand { - @Option(help: "The vm identifier") var id: String - @Option(help: "The vm identifier for the cloned vm") var targetId: String - - mutating func run() { - - let config = Config(id) - let targetConfig = Config(targetId) - - if id == targetId { - fatalError("The ids match. Clone failed.") - } - - let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path()) - if !vmExists { - print("VM with id \(id) doesn't exist. Cannot clone.") - return - } - - print("Cloning \(config.vmDataURL) to \(targetConfig.vmDataURL)") - do { - try FileManager.default.copyItem(at: config.vmDataURL, to: targetConfig.vmDataURL) - } catch { - print("Whoops... Cloning failed \(error)") - } - } - } -} - -extension Tailmac { - struct RefreshImage: ParsableCommand { - mutating func run() { - let config = Config() - let exists = FileManager.default.fileExists(atPath: config.restoreImageURL.path()) - if exists { - try? FileManager.default.removeItem(at: config.restoreImageURL) - } - let restoreImage = RestoreImage(config.restoreImageURL) - restoreImage.download { - print("Restore image refreshed") - } - } - } -} - -extension Tailmac { - struct Create: ParsableCommand { - @Option(help: "The vm identifier. Each VM instance needs a unique ID.") var id: String - @Option(help: "The mac address of the socket network interface") var mac: String? - @Option(help: "The port for the virtio socket device") var port: String? - @Option(help: "The named socket for the socket network interface") var sock: String? - @Option(help: "The desired RAM in bytes") var mem: String? - @Option(help: "The ethernet address for a standard NAT adapter") var ethermac: String? - @Option(help: "The image name to build from. If omitted we will use RestoreImage.ipsw in ~/VM.bundle and download it if needed") var image: String? - - mutating func run() { - buildVM(id) - } - - func buildVM(_ id: String) { - print("Configuring vm with id \(id)") - - let config = Config(id) - let installer = VMInstaller(config) - - let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path()) - if vmExists { - print("VM with id \(id) already exists. No action taken.") - return - } - - createDir(config.vmDataURL.path()) - - if let mac { - config.mac = mac - } - if let port, let portInt = UInt32(port) { - config.port = portInt - } - if let ethermac { - config.ethermac = ethermac - } - if let mem, let membytes = UInt64(mem) { - config.memorySize = membytes - } - if let sock { - config.serverSocket = sock - } - - config.persist() - - let restoreImagePath = image ?? config.restoreImageURL.path() - - let exists = FileManager.default.fileExists(atPath: restoreImagePath) - if exists { - print("Using existing restore image at \(restoreImagePath)") - installer.installMacOS(ipswURL: URL(fileURLWithPath: restoreImagePath)) - } else { - if image != nil { - fatalError("Unable to find custom restore image") - } - - print("Downloading default restore image to \(config.restoreImageURL)") - let restoreImage = RestoreImage(URL(fileURLWithPath: restoreImagePath)) - restoreImage.download { - // Install from the restore image that you downloaded. - installer.installMacOS(ipswURL: URL(fileURLWithPath: restoreImagePath)) - } - } - - dispatchMain() - } - } -} diff --git a/tstest/tailmac/Swift/TailMac/VMInstaller.swift b/tstest/tailmac/Swift/TailMac/VMInstaller.swift deleted file mode 100644 index 568b6efc4bfe0..0000000000000 --- a/tstest/tailmac/Swift/TailMac/VMInstaller.swift +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -import Foundation -import Virtualization - -class VMInstaller: NSObject { - private var installationObserver: NSKeyValueObservation? - private var virtualMachine: VZVirtualMachine! - - private var config: Config - private var helper: TailMacConfigHelper - - init(_ config: Config) { - self.config = config - helper = TailMacConfigHelper(config: config) - } - - public func installMacOS(ipswURL: URL) { - print("Attempting to install from IPSW at \(ipswURL).") - VZMacOSRestoreImage.load(from: ipswURL, completionHandler: { [self](result: Result) in - switch result { - case let .failure(error): - fatalError(error.localizedDescription) - - case let .success(restoreImage): - installMacOS(restoreImage: restoreImage) - } - }) - } - - // MARK: - Internal helper functions. - - private func installMacOS(restoreImage: VZMacOSRestoreImage) { - guard let macOSConfiguration = restoreImage.mostFeaturefulSupportedConfiguration else { - fatalError("No supported configuration available.") - } - - if !macOSConfiguration.hardwareModel.isSupported { - fatalError("macOSConfiguration configuration isn't supported on the current host.") - } - - DispatchQueue.main.async { [self] in - setupVirtualMachine(macOSConfiguration: macOSConfiguration) - startInstallation(restoreImageURL: restoreImage.url) - } - } - - // MARK: Create the Mac platform configuration. - - private func createMacPlatformConfiguration(macOSConfiguration: VZMacOSConfigurationRequirements) -> VZMacPlatformConfiguration { - let macPlatformConfiguration = VZMacPlatformConfiguration() - - - let auxiliaryStorage: VZMacAuxiliaryStorage - do { - auxiliaryStorage = try VZMacAuxiliaryStorage(creatingStorageAt: config.auxiliaryStorageURL, - hardwareModel: macOSConfiguration.hardwareModel, - options: []) - } catch { - fatalError("Unable to create aux storage at \(config.auxiliaryStorageURL) \(error)") - } - macPlatformConfiguration.auxiliaryStorage = auxiliaryStorage - macPlatformConfiguration.hardwareModel = macOSConfiguration.hardwareModel - macPlatformConfiguration.machineIdentifier = VZMacMachineIdentifier() - - // Store the hardware model and machine identifier to disk so that you - // can retrieve them for subsequent boots. - try! macPlatformConfiguration.hardwareModel.dataRepresentation.write(to: config.hardwareModelURL) - try! macPlatformConfiguration.machineIdentifier.dataRepresentation.write(to: config.machineIdentifierURL) - - return macPlatformConfiguration - } - - private func setupVirtualMachine(macOSConfiguration: VZMacOSConfigurationRequirements) { - let virtualMachineConfiguration = VZVirtualMachineConfiguration() - - virtualMachineConfiguration.platform = createMacPlatformConfiguration(macOSConfiguration: macOSConfiguration) - virtualMachineConfiguration.cpuCount = helper.computeCPUCount() - if virtualMachineConfiguration.cpuCount < macOSConfiguration.minimumSupportedCPUCount { - fatalError("CPUCount isn't supported by the macOS configuration.") - } - - virtualMachineConfiguration.memorySize = helper.computeMemorySize() - if virtualMachineConfiguration.memorySize < macOSConfiguration.minimumSupportedMemorySize { - fatalError("memorySize isn't supported by the macOS configuration.") - } - - createDiskImage() - - virtualMachineConfiguration.bootLoader = helper.createBootLoader() - virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()] - virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()] - virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()] - virtualMachineConfiguration.pointingDevices = [helper.createPointingDeviceConfiguration()] - virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()] - - try! virtualMachineConfiguration.validate() - try! virtualMachineConfiguration.validateSaveRestoreSupport() - - virtualMachine = VZVirtualMachine(configuration: virtualMachineConfiguration) - } - - private func startInstallation(restoreImageURL: URL) { - let installer = VZMacOSInstaller(virtualMachine: virtualMachine, restoringFromImageAt: restoreImageURL) - - print("Starting installation.") - installer.install(completionHandler: { (result: Result) in - if case let .failure(error) = result { - fatalError(error.localizedDescription) - } else { - print("Installation succeeded.") - } - }) - - // Observe installation progress. - installationObserver = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in - print("Installation progress: \(change.newValue! * 100).") - } - } - - // Create an empty disk image for the virtual machine. - private func createDiskImage() { - let diskFd = open(config.diskImageURL.path, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR) - if diskFd == -1 { - fatalError("Cannot create disk image.") - } - - // 72 GB disk space. - var result = ftruncate(diskFd, config.diskSize) - if result != 0 { - fatalError("ftruncate() failed.") - } - - result = close(diskFd) - if result != 0 { - fatalError("Failed to close the disk image.") - } - } -} diff --git a/tstest/tailmac/Swift/TailMac/main b/tstest/tailmac/Swift/TailMac/main deleted file mode 100755 index bbb1b051a6dde..0000000000000 Binary files a/tstest/tailmac/Swift/TailMac/main and /dev/null differ diff --git a/tstest/tailmac/TailMac.entitlements b/tstest/tailmac/TailMac.entitlements deleted file mode 100644 index d7d0d6e8b6c29..0000000000000 --- a/tstest/tailmac/TailMac.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.virtualization - - - diff --git a/tstest/tailmac/TailMac.xcodeproj/project.pbxproj b/tstest/tailmac/TailMac.xcodeproj/project.pbxproj deleted file mode 100644 index 542901554f69b..0000000000000 --- a/tstest/tailmac/TailMac.xcodeproj/project.pbxproj +++ /dev/null @@ -1,581 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 55; - objects = { - -/* Begin PBXBuildFile section */ - 8F87D52126C34111000EADA4 /* HostCli.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F87D52026C34111000EADA4 /* HostCli.swift */; }; - 8F87D52326C34111000EADA4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F87D52226C34111000EADA4 /* Assets.xcassets */; }; - 8F87D52626C34111000EADA4 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8F87D52426C34111000EADA4 /* MainMenu.xib */; }; - 8F87D53426C341AC000EADA4 /* TailMac.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F87D53326C341AC000EADA4 /* TailMac.swift */; }; - 8F87D54026C34259000EADA4 /* TailMacConfigHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F87D53D26C34259000EADA4 /* TailMacConfigHelper.swift */; }; - 8F87D54426C34269000EADA4 /* TailMacConfigHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F87D53D26C34259000EADA4 /* TailMacConfigHelper.swift */; }; - 8F87D54726C3427C000EADA4 /* Virtualization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F87D54626C3427C000EADA4 /* Virtualization.framework */; }; - 8F87D54826C34286000EADA4 /* Virtualization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F87D54626C3427C000EADA4 /* Virtualization.framework */; }; - C266EA7F2C5D2AD800DC57E3 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = C266EA7E2C5D2AD800DC57E3 /* Config.swift */; }; - C266EA802C5D2AE700DC57E3 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = C266EA7E2C5D2AD800DC57E3 /* Config.swift */; }; - C28759A42C6BB68D0032283D /* VMInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28759A32C6BB68D0032283D /* VMInstaller.swift */; }; - C28759A72C6BB7F90032283D /* RestoreImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28759A62C6BB7F90032283D /* RestoreImage.swift */; }; - C28759AC2C6C00840032283D /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = C28759AB2C6C00840032283D /* ArgumentParser */; }; - C28759AE2C6D0FC10032283D /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = C28759AD2C6D0FC10032283D /* ArgumentParser */; }; - C28759BC2C6D19D40032283D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28759BB2C6D19D40032283D /* AppDelegate.swift */; }; - C28759BE2C6D1A0F0032283D /* VMController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28759BD2C6D1A0F0032283D /* VMController.swift */; }; - C28759C02C6D1E980032283D /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28759BF2C6D1E980032283D /* Notifications.swift */; }; - C28759C12C6D1E980032283D /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28759BF2C6D1E980032283D /* Notifications.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 8F87D52F26C341AC000EADA4 /* CopyFiles */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = /usr/share/man/man1/; - dstSubfolderSpec = 0; - files = ( - ); - runOnlyForDeploymentPostprocessing = 1; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 8F87D51D26C34111000EADA4 /* Host.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Host.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 8F87D52026C34111000EADA4 /* HostCli.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostCli.swift; sourceTree = ""; }; - 8F87D52226C34111000EADA4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 8F87D52526C34111000EADA4 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 8F87D53126C341AC000EADA4 /* TailMac */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = TailMac; sourceTree = BUILT_PRODUCTS_DIR; }; - 8F87D53326C341AC000EADA4 /* TailMac.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TailMac.swift; sourceTree = ""; }; - 8F87D53826C3423F000EADA4 /* TailMac.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = TailMac.entitlements; sourceTree = ""; }; - 8F87D53B26C34250000EADA4 /* Host.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Host.entitlements; sourceTree = ""; }; - 8F87D53D26C34259000EADA4 /* TailMacConfigHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TailMacConfigHelper.swift; sourceTree = ""; }; - 8F87D54626C3427C000EADA4 /* Virtualization.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Virtualization.framework; path = System/Library/Frameworks/Virtualization.framework; sourceTree = SDKROOT; }; - 8FB90BE826D422FD00988F51 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B0E246092DFBF28FAEA2709F /* LICENSE.txt */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = LICENSE.txt; sourceTree = ""; }; - C266EA7E2C5D2AD800DC57E3 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; - C28759A32C6BB68D0032283D /* VMInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMInstaller.swift; sourceTree = ""; }; - C28759A62C6BB7F90032283D /* RestoreImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreImage.swift; sourceTree = ""; }; - C28759A92C6BF8800032283D /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; sourceTree = ""; }; - C28759AF2C6D10060032283D /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - C28759BB2C6D19D40032283D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - C28759BD2C6D1A0F0032283D /* VMController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMController.swift; sourceTree = ""; }; - C28759BF2C6D1E980032283D /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 8F87D51A26C34111000EADA4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - C28759AE2C6D0FC10032283D /* ArgumentParser in Frameworks */, - 8F87D54826C34286000EADA4 /* Virtualization.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8F87D52E26C341AC000EADA4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - C28759AC2C6C00840032283D /* ArgumentParser in Frameworks */, - 8F87D54726C3427C000EADA4 /* Virtualization.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 09E329497FB7E44895839D88 /* LICENSE */ = { - isa = PBXGroup; - children = ( - B0E246092DFBF28FAEA2709F /* LICENSE.txt */, - ); - path = LICENSE; - sourceTree = ""; - }; - 8F87D51426C34111000EADA4 = { - isa = PBXGroup; - children = ( - C28759AF2C6D10060032283D /* README.md */, - C28759A92C6BF8800032283D /* Makefile */, - 8F87D53B26C34250000EADA4 /* Host.entitlements */, - 8F87D53826C3423F000EADA4 /* TailMac.entitlements */, - 8FDABC17270D0F9100D7FC60 /* Swift */, - 8F87D51E26C34111000EADA4 /* Products */, - 8F87D54526C3427C000EADA4 /* Frameworks */, - 09E329497FB7E44895839D88 /* LICENSE */, - ); - sourceTree = ""; - }; - 8F87D51E26C34111000EADA4 /* Products */ = { - isa = PBXGroup; - children = ( - 8F87D51D26C34111000EADA4 /* Host.app */, - 8F87D53126C341AC000EADA4 /* TailMac */, - ); - name = Products; - sourceTree = ""; - }; - 8F87D51F26C34111000EADA4 /* Host */ = { - isa = PBXGroup; - children = ( - 8F87D52026C34111000EADA4 /* HostCli.swift */, - C28759BD2C6D1A0F0032283D /* VMController.swift */, - C28759BB2C6D19D40032283D /* AppDelegate.swift */, - 8F87D52226C34111000EADA4 /* Assets.xcassets */, - 8F87D52426C34111000EADA4 /* MainMenu.xib */, - 8FB90BE826D422FD00988F51 /* Info.plist */, - ); - path = Host; - sourceTree = ""; - }; - 8F87D52C26C3418F000EADA4 /* Common */ = { - isa = PBXGroup; - children = ( - C266EA7E2C5D2AD800DC57E3 /* Config.swift */, - 8F87D53D26C34259000EADA4 /* TailMacConfigHelper.swift */, - C28759BF2C6D1E980032283D /* Notifications.swift */, - ); - path = Common; - sourceTree = ""; - }; - 8F87D53226C341AC000EADA4 /* TailMac */ = { - isa = PBXGroup; - children = ( - 8F87D53326C341AC000EADA4 /* TailMac.swift */, - C28759A62C6BB7F90032283D /* RestoreImage.swift */, - C28759A32C6BB68D0032283D /* VMInstaller.swift */, - ); - path = TailMac; - sourceTree = ""; - }; - 8F87D54526C3427C000EADA4 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 8F87D54626C3427C000EADA4 /* Virtualization.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - 8FDABC17270D0F9100D7FC60 /* Swift */ = { - isa = PBXGroup; - children = ( - 8F87D52C26C3418F000EADA4 /* Common */, - 8F87D51F26C34111000EADA4 /* Host */, - 8F87D53226C341AC000EADA4 /* TailMac */, - ); - path = Swift; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 8F87D51C26C34111000EADA4 /* host */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8F87D52926C34111000EADA4 /* Build configuration list for PBXNativeTarget "host" */; - buildPhases = ( - 8F87D51926C34111000EADA4 /* Sources */, - 8F87D51A26C34111000EADA4 /* Frameworks */, - 8F87D51B26C34111000EADA4 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = host; - packageProductDependencies = ( - C28759AD2C6D0FC10032283D /* ArgumentParser */, - ); - productName = macOSVirtualMachineSampleApp; - productReference = 8F87D51D26C34111000EADA4 /* Host.app */; - productType = "com.apple.product-type.application"; - }; - 8F87D53026C341AC000EADA4 /* tailmac */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8F87D53526C341AC000EADA4 /* Build configuration list for PBXNativeTarget "tailmac" */; - buildPhases = ( - 8F87D52D26C341AC000EADA4 /* Sources */, - 8F87D52E26C341AC000EADA4 /* Frameworks */, - 8F87D52F26C341AC000EADA4 /* CopyFiles */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = tailmac; - packageProductDependencies = ( - C28759AB2C6C00840032283D /* ArgumentParser */, - ); - productName = InstallationTool; - productReference = 8F87D53126C341AC000EADA4 /* TailMac */; - productType = "com.apple.product-type.tool"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 8F87D51526C34111000EADA4 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - DefaultBuildSystemTypeForWorkspace = Latest; - LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = Apple; - TargetAttributes = { - 8F87D51C26C34111000EADA4 = { - CreatedOnToolsVersion = 13.0; - }; - 8F87D53026C341AC000EADA4 = { - CreatedOnToolsVersion = 13.0; - }; - }; - }; - buildConfigurationList = 8F87D51826C34111000EADA4 /* Build configuration list for PBXProject "TailMac" */; - compatibilityVersion = "Xcode 13.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 8F87D51426C34111000EADA4; - packageReferences = ( - C28759AA2C6BFF0F0032283D /* XCRemoteSwiftPackageReference "swift-argument-parser" */, - ); - productRefGroup = 8F87D51E26C34111000EADA4 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 8F87D51C26C34111000EADA4 /* host */, - 8F87D53026C341AC000EADA4 /* tailmac */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 8F87D51B26C34111000EADA4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 8F87D52326C34111000EADA4 /* Assets.xcassets in Resources */, - 8F87D52626C34111000EADA4 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 8F87D51926C34111000EADA4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 8F87D52126C34111000EADA4 /* HostCli.swift in Sources */, - C28759C02C6D1E980032283D /* Notifications.swift in Sources */, - C266EA7F2C5D2AD800DC57E3 /* Config.swift in Sources */, - C28759BC2C6D19D40032283D /* AppDelegate.swift in Sources */, - C28759BE2C6D1A0F0032283D /* VMController.swift in Sources */, - 8F87D54026C34259000EADA4 /* TailMacConfigHelper.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8F87D52D26C341AC000EADA4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 8F87D54426C34269000EADA4 /* TailMacConfigHelper.swift in Sources */, - C28759C12C6D1E980032283D /* Notifications.swift in Sources */, - C28759A72C6BB7F90032283D /* RestoreImage.swift in Sources */, - C266EA802C5D2AE700DC57E3 /* Config.swift in Sources */, - C28759A42C6BB68D0032283D /* VMInstaller.swift in Sources */, - 8F87D53426C341AC000EADA4 /* TailMac.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 8F87D52426C34111000EADA4 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 8F87D52526C34111000EADA4 /* Base */, - ); - name = MainMenu.xib; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 8F87D52726C34111000EADA4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 8F87D52826C34111000EADA4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 8F87D52A26C34111000EADA4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Host.entitlements; - CODE_SIGN_IDENTITY = "Mac Developer"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = W5364U7YZB; - ENABLE_APP_SANDBOX = NO; - ENABLE_USER_SELECTED_FILES = readwrite; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Swift/Host/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Allow for using audio input devices."; - INFOPLIST_KEY_NSPrincipalClass = NSApplication; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tailscale.vnetMacHost; - PRODUCT_NAME = Host; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 8F87D52B26C34111000EADA4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Host.entitlements; - CODE_SIGN_IDENTITY = "Mac Developer"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = W5364U7YZB; - ENABLE_APP_SANDBOX = NO; - ENABLE_USER_SELECTED_FILES = readwrite; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Swift/Host/Info.plist; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Allow for using audio input devices."; - INFOPLIST_KEY_NSPrincipalClass = NSApplication; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tailscale.vnetMacHost; - PRODUCT_NAME = Host; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 8F87D53626C341AC000EADA4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - CODE_SIGN_ENTITLEMENTS = TailMac.entitlements; - CODE_SIGN_IDENTITY = "Mac Developer"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = W5364U7YZB; - ENABLE_USER_SELECTED_FILES = readwrite; - MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tailscale.vnetMacHostSetupTool; - PRODUCT_NAME = TailMac; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 8F87D53726C341AC000EADA4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - CODE_SIGN_ENTITLEMENTS = TailMac.entitlements; - CODE_SIGN_IDENTITY = "Mac Developer"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = W5364U7YZB; - ENABLE_USER_SELECTED_FILES = readwrite; - MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tailscale.vnetMacHostSetupTool; - PRODUCT_NAME = TailMac; - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 8F87D51826C34111000EADA4 /* Build configuration list for PBXProject "TailMac" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8F87D52726C34111000EADA4 /* Debug */, - 8F87D52826C34111000EADA4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8F87D52926C34111000EADA4 /* Build configuration list for PBXNativeTarget "host" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8F87D52A26C34111000EADA4 /* Debug */, - 8F87D52B26C34111000EADA4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8F87D53526C341AC000EADA4 /* Build configuration list for PBXNativeTarget "tailmac" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8F87D53626C341AC000EADA4 /* Debug */, - 8F87D53726C341AC000EADA4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - C28759AA2C6BFF0F0032283D /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-argument-parser.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.5.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - C28759AB2C6C00840032283D /* ArgumentParser */ = { - isa = XCSwiftPackageProductDependency; - package = C28759AA2C6BFF0F0032283D /* XCRemoteSwiftPackageReference "swift-argument-parser" */; - productName = ArgumentParser; - }; - C28759AD2C6D0FC10032283D /* ArgumentParser */ = { - isa = XCSwiftPackageProductDependency; - package = C28759AA2C6BFF0F0032283D /* XCRemoteSwiftPackageReference "swift-argument-parser" */; - productName = ArgumentParser; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 8F87D51526C34111000EADA4 /* Project object */; -} diff --git a/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index 3ddf867a10ac3..0000000000000 --- a/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - BuildSystemType - Latest - - diff --git a/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index d3fbce1982aef..0000000000000 --- a/tstest/tailmac/TailMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "59ba1edda695b389d6c9ac1809891cd779e4024f505b0ce1a9d5202b6762e38a", - "pins" : [ - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "41982a3656a71c768319979febd796c6fd111d5c", - "version" : "1.5.0" - } - } - ], - "version" : 3 -} diff --git a/tstest/tailmac/TailMac.xcodeproj/xcshareddata/xcschemes/host.xcscheme b/tstest/tailmac/TailMac.xcodeproj/xcshareddata/xcschemes/host.xcscheme deleted file mode 100644 index 060f48e0d6865..0000000000000 --- a/tstest/tailmac/TailMac.xcodeproj/xcshareddata/xcschemes/host.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tstest/tailmac/TailMac.xcodeproj/xcshareddata/xcschemes/tailmac.xcscheme b/tstest/tailmac/TailMac.xcodeproj/xcshareddata/xcschemes/tailmac.xcscheme deleted file mode 100644 index 80cdd413eddf0..0000000000000 --- a/tstest/tailmac/TailMac.xcodeproj/xcshareddata/xcschemes/tailmac.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tstest/tailmac/TailMac.xcodeproj/xcuserdata/jnobels.xcuserdatad/xcschemes/xcschememanagement.plist b/tstest/tailmac/TailMac.xcodeproj/xcuserdata/jnobels.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 543f1f77a0b13..0000000000000 --- a/tstest/tailmac/TailMac.xcodeproj/xcuserdata/jnobels.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,37 +0,0 @@ - - - - - SchemeUserState - - VMRunner.xcscheme_^#shared#^_ - - orderHint - 2 - - host.xcscheme_^#shared#^_ - - orderHint - 1 - - tailmac.xcscheme_^#shared#^_ - - orderHint - 0 - - - SuppressBuildableAutocreation - - 8FDABC39270D1DC600D7FC60 - - primary - - - 8FDABC58270D1FFE00D7FC60 - - primary - - - - - diff --git a/tstest/test-wishlist.md b/tstest/test-wishlist.md deleted file mode 100644 index eb4601b929650..0000000000000 --- a/tstest/test-wishlist.md +++ /dev/null @@ -1,20 +0,0 @@ -# Testing wishlist - -This is a list of tests we'd like to add one day, as our e2e/natlab/VM -testing infrastructure matures. - -We're going to start collecting ideas as we develop PRs (updating this -wishlist in the same PR that adds something that could be better -tested) and then use this list to inform the order we build out our -future testing machinery. - -For each item, try to include a `#nnn` or `tailscale/corp#nnn` -reference to an issue or PR about the feature. - -# The list - -- Link-local multicast, and mDNS/LLMNR specifically, when an exit node is used, - both with and without the "Allow local network access" option enabled. - When the option is disabled, we should still permit it for internal interfaces, - such as Hyper-V/WSL2 on Windows. - diff --git a/tstest/tools/tools.go b/tstest/tools/tools.go deleted file mode 100644 index 4d810483b78b5..0000000000000 --- a/tstest/tools/tools.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build tools - -// This file exists just so `go mod tidy` won't remove -// tool modules from our go.mod. -package tools - -import ( - _ "github.com/elastic/crd-ref-docs" - _ "github.com/tailscale/mkctr" - _ "honnef.co/go/tools/cmd/staticcheck" - _ "sigs.k8s.io/controller-tools/cmd/controller-gen" -) diff --git a/tstest/tstest.go b/tstest/tstest.go deleted file mode 100644 index 2d0d1351e293a..0000000000000 --- a/tstest/tstest.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package tstest provides utilities for use in unit tests. -package tstest - -import ( - "context" - "os" - "strconv" - "strings" - "sync/atomic" - "testing" - "time" - - "tailscale.com/envknob" - "tailscale.com/logtail/backoff" - "tailscale.com/types/logger" - "tailscale.com/util/cibuild" -) - -// Replace replaces the value of target with val. -// The old value is restored when the test ends. -func Replace[T any](t testing.TB, target *T, val T) { - t.Helper() - if target == nil { - t.Fatalf("Replace: nil pointer") - panic("unreachable") // pacify staticcheck - } - old := *target - t.Cleanup(func() { - *target = old - }) - - *target = val - return -} - -// WaitFor retries try for up to maxWait. -// It returns nil once try returns nil the first time. -// If maxWait passes without success, it returns try's last error. -func WaitFor(maxWait time.Duration, try func() error) error { - bo := backoff.NewBackoff("wait-for", logger.Discard, maxWait/4) - deadline := time.Now().Add(maxWait) - var err error - for time.Now().Before(deadline) { - err = try() - if err == nil { - break - } - bo.BackOff(context.Background(), err) - } - return err -} - -var testNum atomic.Int32 - -// Shard skips t if it's not running if the TS_TEST_SHARD test shard is set to -// "n/m" and this test execution number in the process mod m is not equal to n-1. -// That is, to run with 4 shards, set TS_TEST_SHARD=1/4, ..., TS_TEST_SHARD=4/4 -// for the four jobs. -func Shard(t testing.TB) { - e := os.Getenv("TS_TEST_SHARD") - a, b, ok := strings.Cut(e, "/") - if !ok { - return - } - wantShard, _ := strconv.ParseInt(a, 10, 32) - shards, _ := strconv.ParseInt(b, 10, 32) - if wantShard == 0 || shards == 0 { - return - } - - shard := ((testNum.Add(1) - 1) % int32(shards)) + 1 - if shard != int32(wantShard) { - t.Skipf("skipping shard %d/%d (process has TS_TEST_SHARD=%q)", shard, shards, e) - } -} - -// SkipOnUnshardedCI skips t if we're in CI and the TS_TEST_SHARD -// environment variable isn't set. -func SkipOnUnshardedCI(t testing.TB) { - if cibuild.On() && os.Getenv("TS_TEST_SHARD") == "" { - t.Skip("skipping on CI without TS_TEST_SHARD") - } -} - -var serializeParallel = envknob.RegisterBool("TS_SERIAL_TESTS") - -// Parallel calls t.Parallel, unless TS_SERIAL_TESTS is set true. -func Parallel(t *testing.T) { - if !serializeParallel() { - t.Parallel() - } -} diff --git a/tstest/tstest_test.go b/tstest/tstest_test.go deleted file mode 100644 index e988d5d5624b6..0000000000000 --- a/tstest/tstest_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstest - -import "testing" - -func TestReplace(t *testing.T) { - before := "before" - done := false - t.Run("replace", func(t *testing.T) { - Replace(t, &before, "after") - if before != "after" { - t.Errorf("before = %q; want %q", before, "after") - } - done = true - }) - if !done { - t.Fatal("subtest didn't run") - } - if before != "before" { - t.Errorf("before = %q; want %q", before, "before") - } -} diff --git a/tstime/jitter_test.go b/tstime/jitter_test.go deleted file mode 100644 index 579287bda0bc2..0000000000000 --- a/tstime/jitter_test.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstime - -import ( - "testing" - "time" -) - -func TestRandomDurationBetween(t *testing.T) { - if got := RandomDurationBetween(1, 1); got != 1 { - t.Errorf("between 1 and 1 = %v; want 1", int64(got)) - } - const min = 1 * time.Second - const max = 10 * time.Second - for range 500 { - if got := RandomDurationBetween(min, max); got < min || got >= max { - t.Fatalf("%v (%d) out of range", got, got) - } - } -} diff --git a/tstime/mono/mono_test.go b/tstime/mono/mono_test.go deleted file mode 100644 index 67a8614baf2ef..0000000000000 --- a/tstime/mono/mono_test.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package mono - -import ( - "encoding/json" - "testing" - "time" -) - -func TestNow(t *testing.T) { - start := Now() - time.Sleep(100 * time.Millisecond) - if elapsed := Since(start); elapsed < 100*time.Millisecond { - t.Errorf("short sleep: %v elapsed, want min %v", elapsed, 100*time.Millisecond) - } -} - -func TestUnmarshalZero(t *testing.T) { - var tt time.Time - buf, err := json.Marshal(tt) - if err != nil { - t.Fatal(err) - } - var m Time - err = json.Unmarshal(buf, &m) - if err != nil { - t.Fatal(err) - } - if !m.IsZero() { - t.Errorf("expected unmarshal of zero time to be 0, got %d (~=%v)", m, m) - } -} - -func TestJSONRoundtrip(t *testing.T) { - want := Now() - b, err := want.MarshalJSON() - if err != nil { - t.Errorf("MarshalJSON error: %v", err) - } - var got Time - if err := got.UnmarshalJSON(b); err != nil { - t.Errorf("UnmarshalJSON error: %v", err) - } - if got != want { - t.Errorf("got %v, want %v", got, want) - } -} - -func BenchmarkMonoNow(b *testing.B) { - b.ReportAllocs() - for range b.N { - Now() - } -} - -func BenchmarkTimeNow(b *testing.B) { - b.ReportAllocs() - for range b.N { - time.Now() - } -} diff --git a/tstime/rate/rate.go b/tstime/rate/rate.go index f0473862a2890..2fe8e54f54079 100644 --- a/tstime/rate/rate.go +++ b/tstime/rate/rate.go @@ -14,7 +14,7 @@ import ( "sync" "time" - "tailscale.com/tstime/mono" + "github.com/sagernet/tailscale/tstime/mono" ) // Limit defines the maximum frequency of some events. diff --git a/tstime/rate/rate_test.go b/tstime/rate/rate_test.go deleted file mode 100644 index dc3f9e84bb851..0000000000000 --- a/tstime/rate/rate_test.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// This is a modified, simplified version of code from golang.org/x/time/rate. - -// Copyright 2015 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package rate - -import ( - "math" - "sync" - "sync/atomic" - "testing" - "time" - - "tailscale.com/tstime/mono" -) - -func closeEnough(a, b Limit) bool { - return (math.Abs(float64(a)/float64(b)) - 1.0) < 1e-9 -} - -func TestEvery(t *testing.T) { - cases := []struct { - interval time.Duration - lim Limit - }{ - {1 * time.Nanosecond, Limit(1e9)}, - {1 * time.Microsecond, Limit(1e6)}, - {1 * time.Millisecond, Limit(1e3)}, - {10 * time.Millisecond, Limit(100)}, - {100 * time.Millisecond, Limit(10)}, - {1 * time.Second, Limit(1)}, - {2 * time.Second, Limit(0.5)}, - {time.Duration(2.5 * float64(time.Second)), Limit(0.4)}, - {4 * time.Second, Limit(0.25)}, - {10 * time.Second, Limit(0.1)}, - {time.Duration(math.MaxInt64), Limit(1e9 / float64(math.MaxInt64))}, - } - for _, tc := range cases { - lim := Every(tc.interval) - if !closeEnough(lim, tc.lim) { - t.Errorf("Every(%v) = %v want %v", tc.interval, lim, tc.lim) - } - } -} - -const ( - d = 100 * time.Millisecond -) - -var ( - t0 = mono.Now() - t1 = t0.Add(time.Duration(1) * d) - t2 = t0.Add(time.Duration(2) * d) -) - -type allow struct { - t mono.Time - ok bool -} - -func run(t *testing.T, lim *Limiter, allows []allow) { - t.Helper() - for i, allow := range allows { - ok := lim.allow(allow.t) - if ok != allow.ok { - t.Errorf("step %d: lim.AllowN(%v) = %v want %v", - i, allow.t, ok, allow.ok) - } - } -} - -func TestLimiterBurst1(t *testing.T) { - run(t, NewLimiter(10, 1), []allow{ - {t0, true}, - {t0, false}, - {t0, false}, - {t1, true}, - {t1, false}, - {t1, false}, - {t2, true}, - {t2, false}, - }) -} - -func TestLimiterJumpBackwards(t *testing.T) { - run(t, NewLimiter(10, 3), []allow{ - {t1, true}, // start at t1 - {t0, true}, // jump back to t0, two tokens remain - {t0, true}, - {t0, false}, - {t0, false}, - {t1, true}, // got a token - {t1, false}, - {t1, false}, - {t2, true}, // got another token - {t2, false}, - {t2, false}, - }) -} - -// Ensure that tokensFromDuration doesn't produce -// rounding errors by truncating nanoseconds. -// See golang.org/issues/34861. -func TestLimiter_noTruncationErrors(t *testing.T) { - if !NewLimiter(0.7692307692307693, 1).Allow() { - t.Fatal("expected true") - } -} - -func TestSimultaneousRequests(t *testing.T) { - const ( - limit = 1 - burst = 5 - numRequests = 15 - ) - var ( - wg sync.WaitGroup - numOK = uint32(0) - ) - - // Very slow replenishing bucket. - lim := NewLimiter(limit, burst) - - // Tries to take a token, atomically updates the counter and decreases the wait - // group counter. - f := func() { - defer wg.Done() - if ok := lim.Allow(); ok { - atomic.AddUint32(&numOK, 1) - } - } - - wg.Add(numRequests) - for range numRequests { - go f() - } - wg.Wait() - if numOK != burst { - t.Errorf("numOK = %d, want %d", numOK, burst) - } -} - -func BenchmarkAllowN(b *testing.B) { - lim := NewLimiter(Every(1*time.Second), 1) - now := mono.Now() - b.ReportAllocs() - b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - lim.allow(now) - } - }) -} diff --git a/tstime/rate/value.go b/tstime/rate/value.go index 610f06bbd7991..ea3945b88cd08 100644 --- a/tstime/rate/value.go +++ b/tstime/rate/value.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "tailscale.com/tstime/mono" + "github.com/sagernet/tailscale/tstime/mono" ) // Value measures the rate at which events occur, diff --git a/tstime/rate/value_test.go b/tstime/rate/value_test.go deleted file mode 100644 index a26442650cf94..0000000000000 --- a/tstime/rate/value_test.go +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package rate - -import ( - "flag" - "math" - "reflect" - "testing" - "time" - - qt "github.com/frankban/quicktest" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/tstime/mono" - "tailscale.com/util/must" -) - -const ( - min = mono.Time(time.Minute) - sec = mono.Time(time.Second) - msec = mono.Time(time.Millisecond) - usec = mono.Time(time.Microsecond) - nsec = mono.Time(time.Nanosecond) - - val = 1.0e6 -) - -var longNumericalStabilityTest = flag.Bool("long-numerical-stability-test", false, "") - -func TestValue(t *testing.T) { - // When performing many small calculations, the accuracy of the - // result can drift due to accumulated errors in the calculation. - // Verify that the result is correct even with many small updates. - // See https://en.wikipedia.org/wiki/Numerical_stability. - t.Run("NumericalStability", func(t *testing.T) { - step := usec - if *longNumericalStabilityTest { - step = nsec - } - numStep := int(sec / step) - - c := qt.New(t) - var v Value - var now mono.Time - for range numStep { - v.addNow(now, float64(step)) - now += step - } - c.Assert(v.rateNow(now), qt.CmpEquals(cmpopts.EquateApprox(1e-6, 0)), 1e9/2) - }) - - halfLives := []struct { - name string - period time.Duration - }{ - {"½s", time.Second / 2}, - {"1s", time.Second}, - {"2s", 2 * time.Second}, - } - for _, halfLife := range halfLives { - t.Run(halfLife.name+"/SpikeDecay", func(t *testing.T) { - testValueSpikeDecay(t, halfLife.period, false) - }) - t.Run(halfLife.name+"/SpikeDecayAddZero", func(t *testing.T) { - testValueSpikeDecay(t, halfLife.period, true) - }) - t.Run(halfLife.name+"/HighThenLow", func(t *testing.T) { - testValueHighThenLow(t, halfLife.period) - }) - t.Run(halfLife.name+"/LowFrequency", func(t *testing.T) { - testLowFrequency(t, halfLife.period) - }) - } -} - -// testValueSpikeDecay starts with a target rate and ensure that it -// exponentially decays according to the half-life formula. -func testValueSpikeDecay(t *testing.T, halfLife time.Duration, addZero bool) { - c := qt.New(t) - v := Value{HalfLife: halfLife} - v.addNow(0, val*v.normalizedIntegral()) - - var now mono.Time - var prevRate float64 - step := 100 * msec - wantHalfRate := float64(val) - for now < 10*sec { - // Adding zero for every time-step will repeatedly trigger the - // computation to decay the value, which may cause the result - // to become more numerically unstable. - if addZero { - v.addNow(now, 0) - } - currRate := v.rateNow(now) - t.Logf("%0.1fs:\t%0.3f", time.Duration(now).Seconds(), currRate) - - // At every multiple of a half-life period, - // the current rate should be half the value of what - // it was at the last half-life period. - if time.Duration(now)%halfLife == 0 { - c.Assert(currRate, qt.CmpEquals(cmpopts.EquateApprox(1e-12, 0)), wantHalfRate) - wantHalfRate = currRate / 2 - } - - // Without any newly added events, - // the rate should be decaying over time. - if now > 0 && prevRate < currRate { - t.Errorf("%v: rate is not decaying: %0.1f < %0.1f", time.Duration(now), prevRate, currRate) - } - if currRate < 0 { - t.Errorf("%v: rate too low: %0.1f < %0.1f", time.Duration(now), currRate, 0.0) - } - - prevRate = currRate - now += step - } -} - -// testValueHighThenLow targets a steady-state rate that is high, -// then switches to a target steady-state rate that is low. -func testValueHighThenLow(t *testing.T, halfLife time.Duration) { - c := qt.New(t) - v := Value{HalfLife: halfLife} - - var now mono.Time - var prevRate float64 - var wantRate float64 - const step = 10 * msec - const stepsPerSecond = int(sec / step) - - // Target a higher steady-state rate. - wantRate = 2 * val - wantHalfRate := float64(0.0) - eventsPerStep := wantRate / float64(stepsPerSecond) - for now < 10*sec { - currRate := v.rateNow(now) - v.addNow(now, eventsPerStep) - t.Logf("%0.1fs:\t%0.3f", time.Duration(now).Seconds(), currRate) - - // At every multiple of a half-life period, - // the current rate should be half-way more towards - // the target rate relative to before. - if time.Duration(now)%halfLife == 0 { - c.Assert(currRate, qt.CmpEquals(cmpopts.EquateApprox(0.1, 0)), wantHalfRate) - wantHalfRate += (wantRate - currRate) / 2 - } - - // Rate should approach wantRate from below, - // but never exceed it. - if now > 0 && prevRate > currRate { - t.Errorf("%v: rate is not growing: %0.1f > %0.1f", time.Duration(now), prevRate, currRate) - } - if currRate > 1.01*wantRate { - t.Errorf("%v: rate too high: %0.1f > %0.1f", time.Duration(now), currRate, wantRate) - } - - prevRate = currRate - now += step - } - c.Assert(prevRate, qt.CmpEquals(cmpopts.EquateApprox(0.05, 0)), wantRate) - - // Target a lower steady-state rate. - wantRate = val / 3 - wantHalfRate = prevRate - eventsPerStep = wantRate / float64(stepsPerSecond) - for now < 20*sec { - currRate := v.rateNow(now) - v.addNow(now, eventsPerStep) - t.Logf("%0.1fs:\t%0.3f", time.Duration(now).Seconds(), currRate) - - // At every multiple of a half-life period, - // the current rate should be half-way more towards - // the target rate relative to before. - if time.Duration(now)%halfLife == 0 { - c.Assert(currRate, qt.CmpEquals(cmpopts.EquateApprox(0.1, 0)), wantHalfRate) - wantHalfRate += (wantRate - currRate) / 2 - } - - // Rate should approach wantRate from above, - // but never exceed it. - if now > 10*sec && prevRate < currRate { - t.Errorf("%v: rate is not decaying: %0.1f < %0.1f", time.Duration(now), prevRate, currRate) - } - if currRate < 0.99*wantRate { - t.Errorf("%v: rate too low: %0.1f < %0.1f", time.Duration(now), currRate, wantRate) - } - - prevRate = currRate - now += step - } - c.Assert(prevRate, qt.CmpEquals(cmpopts.EquateApprox(0.15, 0)), wantRate) -} - -// testLowFrequency fires an event at a frequency much slower than -// the specified half-life period. While the average rate over time -// should be accurate, the standard deviation gets worse. -func testLowFrequency(t *testing.T, halfLife time.Duration) { - v := Value{HalfLife: halfLife} - - var now mono.Time - var rates []float64 - for now < 20*min { - if now%(10*sec) == 0 { - v.addNow(now, 1) // 1 event every 10 seconds - } - now += 50 * msec - rates = append(rates, v.rateNow(now)) - now += 50 * msec - } - - mean, stddev := stats(rates) - c := qt.New(t) - c.Assert(mean, qt.CmpEquals(cmpopts.EquateApprox(0.001, 0)), 0.1) - t.Logf("mean:%v stddev:%v", mean, stddev) -} - -func stats(fs []float64) (mean, stddev float64) { - for _, rate := range fs { - mean += rate - } - mean /= float64(len(fs)) - for _, rate := range fs { - stddev += (rate - mean) * (rate - mean) - } - stddev = math.Sqrt(stddev / float64(len(fs))) - return mean, stddev -} - -// BenchmarkValue benchmarks the cost of Value.Add, -// which is called often and makes extensive use of floating-point math. -func BenchmarkValue(b *testing.B) { - b.ReportAllocs() - v := Value{HalfLife: time.Second} - for range b.N { - v.Add(1) - } -} - -func TestValueMarshal(t *testing.T) { - now := mono.Now() - tests := []struct { - val *Value - str string - }{ - {val: &Value{}, str: `{}`}, - {val: &Value{HalfLife: 5 * time.Minute}, str: `{"halfLife":"` + (5 * time.Minute).String() + `"}`}, - {val: &Value{value: 12345, updated: now}, str: `{"value":12345,"updated":` + string(must.Get(now.MarshalJSON())) + `}`}, - } - for _, tt := range tests { - str := string(must.Get(tt.val.MarshalJSON())) - if str != tt.str { - t.Errorf("string mismatch: got %v, want %v", str, tt.str) - } - var val Value - must.Do(val.UnmarshalJSON([]byte(str))) - if !reflect.DeepEqual(&val, tt.val) { - t.Errorf("value mismatch: %+v, want %+v", &val, tt.val) - } - } -} diff --git a/tstime/tstime_test.go b/tstime/tstime_test.go deleted file mode 100644 index 3ffeaf0fff1b8..0000000000000 --- a/tstime/tstime_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tstime - -import ( - "testing" - "time" -) - -func TestParseDuration(t *testing.T) { - tests := []struct { - in string - want time.Duration - }{ - {"1h", time.Hour}, - {"1d", 24 * time.Hour}, - {"365d", 365 * 24 * time.Hour}, - {"12345d", 12345 * 24 * time.Hour}, - {"67890d", 67890 * 24 * time.Hour}, - {"100d", 100 * 24 * time.Hour}, - {"1d1d", 48 * time.Hour}, - {"1h1d", 25 * time.Hour}, - {"1d1h", 25 * time.Hour}, - {"1w", 7 * 24 * time.Hour}, - {"1w1d1h", 8*24*time.Hour + time.Hour}, - {"1w1d1h", 8*24*time.Hour + time.Hour}, - {"1y", 0}, - {"", 0}, - } - for _, tt := range tests { - if got, _ := ParseDuration(tt.in); got != tt.want { - t.Errorf("ParseDuration(%q) = %d; want %d", tt.in, got, tt.want) - } - } -} diff --git a/tsweb/debug.go b/tsweb/debug.go index 6db3f25cf06d5..da44b79ed69d5 100644 --- a/tsweb/debug.go +++ b/tsweb/debug.go @@ -14,9 +14,9 @@ import ( "os" "runtime" - "tailscale.com/tsweb/promvarz" - "tailscale.com/tsweb/varz" - "tailscale.com/version" + "github.com/sagernet/tailscale/tsweb/promvarz" + "github.com/sagernet/tailscale/tsweb/varz" + "github.com/sagernet/tailscale/version" ) // DebugHandler is an http.Handler that serves a debugging "homepage", diff --git a/tsweb/debug_test.go b/tsweb/debug_test.go deleted file mode 100644 index 2a68ab6fb27b9..0000000000000 --- a/tsweb/debug_test.go +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsweb - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "runtime" - "strings" - "testing" -) - -func TestDebugger(t *testing.T) { - mux := http.NewServeMux() - - dbg1 := Debugger(mux) - if dbg1 == nil { - t.Fatal("didn't get a debugger from mux") - } - - dbg2 := Debugger(mux) - if dbg2 != dbg1 { - t.Fatal("Debugger returned different debuggers for the same mux") - } - - t.Run("cpu_pprof", func(t *testing.T) { - if testing.Short() { - t.Skip("skipping second long test") - } - switch runtime.GOOS { - case "linux", "darwin": - default: - t.Skipf("skipping test on %v", runtime.GOOS) - } - req := httptest.NewRequest("GET", "/debug/pprof/profile?seconds=1", nil) - req.RemoteAddr = "100.101.102.103:1234" - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - res := rec.Result() - if res.StatusCode != 200 { - t.Errorf("unexpected %v", res.Status) - } - }) -} - -func get(m http.Handler, path, srcIP string) (int, string) { - req := httptest.NewRequest("GET", path, nil) - req.RemoteAddr = srcIP + ":1234" - rec := httptest.NewRecorder() - m.ServeHTTP(rec, req) - return rec.Result().StatusCode, rec.Body.String() -} - -const ( - tsIP = "100.100.100.100" - pubIP = "8.8.8.8" -) - -func TestDebuggerKV(t *testing.T) { - mux := http.NewServeMux() - dbg := Debugger(mux) - dbg.KV("Donuts", 42) - dbg.KV("Secret code", "hunter2") - val := "red" - dbg.KVFunc("Condition", func() any { return val }) - - code, _ := get(mux, "/debug/", pubIP) - if code != 403 { - t.Fatalf("debug access wasn't denied, got %v", code) - } - - code, body := get(mux, "/debug/", tsIP) - if code != 200 { - t.Fatalf("debug access failed, got %v", code) - } - for _, want := range []string{"Donuts", "42", "Secret code", "hunter2", "Condition", "red"} { - if !strings.Contains(body, want) { - t.Errorf("want %q in output, not found", want) - } - } - - val = "green" - code, body = get(mux, "/debug/", tsIP) - if code != 200 { - t.Fatalf("debug access failed, got %v", code) - } - for _, want := range []string{"Condition", "green"} { - if !strings.Contains(body, want) { - t.Errorf("want %q in output, not found", want) - } - } -} - -func TestDebuggerURL(t *testing.T) { - mux := http.NewServeMux() - dbg := Debugger(mux) - dbg.URL("https://www.tailscale.com", "Homepage") - - code, body := get(mux, "/debug/", tsIP) - if code != 200 { - t.Fatalf("debug access failed, got %v", code) - } - for _, want := range []string{"https://www.tailscale.com", "Homepage"} { - if !strings.Contains(body, want) { - t.Errorf("want %q in output, not found", want) - } - } -} - -func TestDebuggerSection(t *testing.T) { - mux := http.NewServeMux() - dbg := Debugger(mux) - dbg.Section(func(w io.Writer, r *http.Request) { - fmt.Fprintf(w, "Test output %v", r.RemoteAddr) - }) - - code, body := get(mux, "/debug/", tsIP) - if code != 200 { - t.Fatalf("debug access failed, got %v", code) - } - want := `Test output 100.100.100.100:1234` - if !strings.Contains(body, want) { - t.Errorf("want %q in output, not found", want) - } -} - -func TestDebuggerHandle(t *testing.T) { - mux := http.NewServeMux() - dbg := Debugger(mux) - dbg.Handle("check", "Consistency check", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Test output %v", r.RemoteAddr) - })) - - code, body := get(mux, "/debug/", tsIP) - if code != 200 { - t.Fatalf("debug access failed, got %v", code) - } - for _, want := range []string{"/debug/check", "Consistency check"} { - if !strings.Contains(body, want) { - t.Errorf("want %q in output, not found", want) - } - } - - code, _ = get(mux, "/debug/check", pubIP) - if code != 403 { - t.Fatal("/debug/check should be protected, but isn't") - } - - code, body = get(mux, "/debug/check", tsIP) - if code != 200 { - t.Fatal("/debug/check denied debug access") - } - want := "Test output " + tsIP - if !strings.Contains(body, want) { - t.Errorf("want %q in output, not found", want) - } -} - -func ExampleDebugHandler_Handle() { - mux := http.NewServeMux() - dbg := Debugger(mux) - // Registers /debug/flushcache with the given handler, and adds a - // link to /debug/ with the description "Flush caches". - dbg.Handle("flushcache", "Flush caches", http.HandlerFunc(http.NotFound)) -} - -func ExampleDebugHandler_KV() { - mux := http.NewServeMux() - dbg := Debugger(mux) - // Adds two list items to /debug/, showing that the condition is - // red and there are 42 donuts. - dbg.KV("Condition", "red") - dbg.KV("Donuts", 42) -} - -func ExampleDebugHandler_KVFunc() { - mux := http.NewServeMux() - dbg := Debugger(mux) - // Adds an count of page renders to /debug/. Note this example - // isn't concurrency-safe. - views := 0 - dbg.KVFunc("Debug pageviews", func() any { - views = views + 1 - return views - }) - dbg.KV("Donuts", 42) -} - -func ExampleDebugHandler_URL() { - mux := http.NewServeMux() - dbg := Debugger(mux) - // Links to the Tailscale website from /debug/. - dbg.URL("https://www.tailscale.com", "Homepage") -} - -func ExampleDebugHandler_Section() { - mux := http.NewServeMux() - dbg := Debugger(mux) - // Adds a section to /debug/ that dumps the HTTP request of the - // visitor. - dbg.Section(func(w io.Writer, r *http.Request) { - io.WriteString(w, "

Dump of your HTTP request

") - fmt.Fprintf(w, "%#v", r) - }) -} diff --git a/tsweb/promvarz/promvarz.go b/tsweb/promvarz/promvarz.go index d0e1e52baeadb..82a9487240b44 100644 --- a/tsweb/promvarz/promvarz.go +++ b/tsweb/promvarz/promvarz.go @@ -11,7 +11,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/expfmt" - "tailscale.com/tsweb/varz" + "github.com/sagernet/tailscale/tsweb/varz" ) // Handler returns Prometheus metrics exported by our expvar converter diff --git a/tsweb/promvarz/promvarz_test.go b/tsweb/promvarz/promvarz_test.go deleted file mode 100644 index a3f4e66f11a42..0000000000000 --- a/tsweb/promvarz/promvarz_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause -package promvarz - -import ( - "expvar" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/prometheus/client_golang/prometheus/testutil" -) - -var ( - testVar1 = expvar.NewInt("gauge_promvarz_test_expvar") - testVar2 = promauto.NewGauge(prometheus.GaugeOpts{Name: "promvarz_test_native"}) -) - -func TestHandler(t *testing.T) { - testVar1.Set(42) - testVar2.Set(4242) - - svr := httptest.NewServer(http.HandlerFunc(Handler)) - defer svr.Close() - - want := ` - # TYPE promvarz_test_expvar gauge - promvarz_test_expvar 42 - # TYPE promvarz_test_native gauge - promvarz_test_native 4242 - ` - if err := testutil.ScrapeAndCompare(svr.URL, strings.NewReader(want), "promvarz_test_expvar", "promvarz_test_native"); err != nil { - t.Error(err) - } -} diff --git a/tsweb/request_id.go b/tsweb/request_id.go index 46e52385240ca..eda13d8c5e9e1 100644 --- a/tsweb/request_id.go +++ b/tsweb/request_id.go @@ -8,8 +8,8 @@ import ( "net/http" "time" - "tailscale.com/util/ctxkey" - "tailscale.com/util/rands" + "github.com/sagernet/tailscale/util/ctxkey" + "github.com/sagernet/tailscale/util/rands" ) // RequestID is an opaque identifier for a HTTP request, used to correlate diff --git a/tsweb/tsweb.go b/tsweb/tsweb.go index 9ddb3fad5d710..ce8e2debd3054 100644 --- a/tsweb/tsweb.go +++ b/tsweb/tsweb.go @@ -27,14 +27,14 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/tsweb/varz" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/ctxkey" + "github.com/sagernet/tailscale/util/vizerror" "go4.org/mem" - "tailscale.com/envknob" - "tailscale.com/metrics" - "tailscale.com/net/tsaddr" - "tailscale.com/tsweb/varz" - "tailscale.com/types/logger" - "tailscale.com/util/ctxkey" - "tailscale.com/util/vizerror" ) // DevMode controls whether extra output in shown, for when the binary is being run in dev mode. diff --git a/tsweb/tsweb_test.go b/tsweb/tsweb_test.go deleted file mode 100644 index d4c9721e97215..0000000000000 --- a/tsweb/tsweb_test.go +++ /dev/null @@ -1,1367 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tsweb - -import ( - "bufio" - "context" - "errors" - "expvar" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/http/httputil" - "net/textproto" - "net/url" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/metrics" - "tailscale.com/tstest" - "tailscale.com/util/httpm" - "tailscale.com/util/must" - "tailscale.com/util/vizerror" -) - -type noopHijacker struct { - *httptest.ResponseRecorder - hijacked bool -} - -func (h *noopHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { - // Hijack "successfully" but don't bother returning a conn. - h.hijacked = true - return nil, nil, nil -} - -type handlerFunc func(http.ResponseWriter, *http.Request) error - -func (f handlerFunc) ServeHTTPReturn(w http.ResponseWriter, r *http.Request) error { - return f(w, r) -} - -func TestStdHandler(t *testing.T) { - const exampleRequestID = "example-request-id" - var ( - handlerCode = func(code int) ReturnHandler { - return handlerFunc(func(w http.ResponseWriter, r *http.Request) error { - w.WriteHeader(code) - return nil - }) - } - handlerErr = func(code int, err error) ReturnHandler { - return handlerFunc(func(w http.ResponseWriter, r *http.Request) error { - if code != 0 { - w.WriteHeader(code) - } - return err - }) - } - - req = func(ctx context.Context, url string) *http.Request { - return httptest.NewRequest("GET", url, nil).WithContext(ctx) - } - - testErr = errors.New("test error") - bgCtx = context.Background() - // canceledCtx, cancel = context.WithCancel(bgCtx) - startTime = time.Unix(1687870000, 1234) - ) - // cancel() - - tests := []struct { - name string - rh ReturnHandler - r *http.Request - errHandler ErrorHandlerFunc - wantCode int - wantLog AccessLogRecord - wantBody string - }{ - { - name: "handler returns 200", - rh: handlerCode(200), - r: req(bgCtx, "http://example.com/"), - wantCode: 200, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - TLS: false, - Host: "example.com", - Method: "GET", - Code: 200, - RequestURI: "/", - }, - }, - - { - name: "handler returns 200 with request ID", - rh: handlerCode(200), - r: req(bgCtx, "http://example.com/"), - wantCode: 200, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - TLS: false, - Host: "example.com", - Method: "GET", - Code: 200, - RequestURI: "/", - }, - }, - - { - name: "handler returns 404", - rh: handlerCode(404), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 404, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Code: 404, - }, - }, - - { - name: "handler returns 404 with request ID", - rh: handlerCode(404), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 404, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Code: 404, - }, - }, - - { - name: "handler returns 404 via HTTPError", - rh: handlerErr(0, Error(404, "not found", testErr)), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 404, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "not found: " + testErr.Error(), - Code: 404, - }, - wantBody: "not found\n", - }, - - { - name: "handler returns 404 via HTTPError with request ID", - rh: handlerErr(0, Error(404, "not found", testErr)), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"), - wantCode: 404, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "not found: " + testErr.Error(), - Code: 404, - RequestID: exampleRequestID, - }, - wantBody: "not found\n" + exampleRequestID + "\n", - }, - - { - name: "handler returns 404 with nil child error", - rh: handlerErr(0, Error(404, "not found", nil)), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 404, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "not found", - Code: 404, - }, - wantBody: "not found\n", - }, - - { - name: "handler returns 404 with request ID and nil child error", - rh: handlerErr(0, Error(404, "not found", nil)), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"), - wantCode: 404, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "not found", - Code: 404, - RequestID: exampleRequestID, - }, - wantBody: "not found\n" + exampleRequestID + "\n", - }, - - { - name: "handler returns user-visible error", - rh: handlerErr(0, vizerror.New("visible error")), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 500, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "visible error", - Code: 500, - }, - wantBody: "visible error\n", - }, - - { - name: "handler returns user-visible error with request ID", - rh: handlerErr(0, vizerror.New("visible error")), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"), - wantCode: 500, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "visible error", - Code: 500, - RequestID: exampleRequestID, - }, - wantBody: "visible error\n" + exampleRequestID + "\n", - }, - - { - name: "handler returns user-visible error wrapped by private error", - rh: handlerErr(0, fmt.Errorf("private internal error: %w", vizerror.New("visible error"))), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 500, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "visible error", - Code: 500, - }, - wantBody: "visible error\n", - }, - - { - name: "handler returns JSON-formatted HTTPError", - rh: ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - h := Error(http.StatusBadRequest, `{"isjson": true}`, errors.New("uh")) - h.Header = http.Header{"Content-Type": {"application/json"}} - return h - }), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"), - wantCode: 400, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: `{"isjson": true}: uh`, - Code: 400, - RequestID: exampleRequestID, - }, - wantBody: `{"isjson": true}`, - }, - - { - name: "handler returns user-visible error wrapped by private error with request ID", - rh: handlerErr(0, fmt.Errorf("private internal error: %w", vizerror.New("visible error"))), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"), - wantCode: 500, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "visible error", - Code: 500, - RequestID: exampleRequestID, - }, - wantBody: "visible error\n" + exampleRequestID + "\n", - }, - - { - name: "handler returns generic error", - rh: handlerErr(0, testErr), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 500, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: testErr.Error(), - Code: 500, - }, - wantBody: "Internal Server Error\n", - }, - - { - name: "handler returns generic error with request ID", - rh: handlerErr(0, testErr), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"), - wantCode: 500, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: testErr.Error(), - Code: 500, - RequestID: exampleRequestID, - }, - wantBody: "Internal Server Error\n" + exampleRequestID + "\n", - }, - - { - name: "handler returns error after writing response", - rh: handlerErr(200, testErr), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 200, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: testErr.Error(), - Code: 200, - }, - }, - - { - name: "handler returns error after writing response with request ID", - rh: handlerErr(200, testErr), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/foo"), - wantCode: 200, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: testErr.Error(), - Code: 200, - RequestID: exampleRequestID, - }, - }, - - { - name: "handler returns HTTPError after writing response", - rh: handlerErr(200, Error(404, "not found", testErr)), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 200, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Err: "not found: " + testErr.Error(), - Code: 200, - }, - }, - - { - name: "handler does nothing", - rh: handlerFunc(func(http.ResponseWriter, *http.Request) error { return nil }), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 200, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Code: 200, - }, - }, - - { - name: "handler hijacks conn", - rh: handlerFunc(func(w http.ResponseWriter, r *http.Request) error { - _, _, err := w.(http.Hijacker).Hijack() - if err != nil { - t.Errorf("couldn't hijack: %v", err) - } - return err - }), - r: req(bgCtx, "http://example.com/foo"), - wantCode: 200, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/foo", - Code: 101, - }, - }, - - { - name: "error handler gets run", - rh: handlerErr(0, Error(404, "not found", nil)), // status code changed in errHandler - r: req(bgCtx, "http://example.com/"), - wantCode: 200, - errHandler: func(w http.ResponseWriter, r *http.Request, e HTTPError) { - http.Error(w, e.Msg, 200) - }, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - TLS: false, - Host: "example.com", - Method: "GET", - Code: 200, - Err: "not found", - RequestURI: "/", - }, - wantBody: "not found\n", - }, - - { - name: "error handler gets run with request ID", - rh: handlerErr(0, Error(404, "not found", nil)), // status code changed in errHandler - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/"), - wantCode: 200, - errHandler: func(w http.ResponseWriter, r *http.Request, e HTTPError) { - requestID := RequestIDFromContext(r.Context()) - http.Error(w, fmt.Sprintf("%s with request ID %s", e.Msg, requestID), 200) - }, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - TLS: false, - Host: "example.com", - Method: "GET", - Code: 200, - Err: "not found", - RequestURI: "/", - RequestID: exampleRequestID, - }, - wantBody: "not found with request ID " + exampleRequestID + "\n", - }, - - { - name: "inner_cancelled", - rh: handlerErr(0, context.Canceled), // return canceled error, but the request was not cancelled - r: req(bgCtx, "http://example.com/"), - wantCode: 500, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - TLS: false, - Host: "example.com", - Method: "GET", - Code: 500, - Err: "context canceled", - RequestURI: "/", - }, - wantBody: "Internal Server Error\n", - }, - - { - name: "nested", - rh: ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - // Here we completely handle the web response with an - // independent StdHandler that is unaware of the outer - // StdHandler and its logger. - StdHandler(ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - return Error(501, "Not Implemented", errors.New("uhoh")) - }), HandlerOptions{ - OnError: func(w http.ResponseWriter, r *http.Request, h HTTPError) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(h.Code) - fmt.Fprintf(w, `{"error": %q}`, h.Msg) - }, - }).ServeHTTP(w, r) - return nil - }), - r: req(RequestIDKey.WithValue(bgCtx, exampleRequestID), "http://example.com/"), - wantCode: 501, - wantLog: AccessLogRecord{ - Time: startTime, - Seconds: 1.0, - Proto: "HTTP/1.1", - TLS: false, - Host: "example.com", - Method: "GET", - Code: 501, - Err: "Not Implemented: uhoh", - RequestURI: "/", - RequestID: exampleRequestID, - }, - wantBody: `{"error": "Not Implemented"}`, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - clock := tstest.NewClock(tstest.ClockOpts{ - Start: startTime, - Step: time.Second, - }) - - // Callbacks to track the emitted AccessLogRecords. - var ( - logs []AccessLogRecord - starts []AccessLogRecord - comps []AccessLogRecord - ) - logf := func(fmt string, args ...any) { - if fmt == "%s" { - logs = append(logs, args[0].(AccessLogRecord)) - } - t.Logf(fmt, args...) - } - oncomp := func(r *http.Request, msg AccessLogRecord) { - comps = append(comps, msg) - } - onstart := func(r *http.Request, msg AccessLogRecord) { - starts = append(starts, msg) - } - - bucket := func(r *http.Request) string { return r.URL.RequestURI() } - - // Build the request handler. - opts := HandlerOptions{ - Now: clock.Now, - - OnError: test.errHandler, - Logf: logf, - OnStart: onstart, - OnCompletion: oncomp, - - StatusCodeCounters: &expvar.Map{}, - StatusCodeCountersFull: &expvar.Map{}, - BucketedStats: &BucketedStatsOptions{ - Bucket: bucket, - Started: &metrics.LabelMap{}, - Finished: &metrics.LabelMap{}, - }, - } - h := StdHandler(test.rh, opts) - - // Pre-create the BucketedStats.{Started,Finished} metric for the - // test request's bucket so that even non-200 status codes get - // recorded immediately. logHandler tries to avoid counting unknown - // paths, so here we're marking them known. - opts.BucketedStats.Started.Get(bucket(test.r)) - opts.BucketedStats.Finished.Get(bucket(test.r)) - - // Perform the request. - rec := noopHijacker{httptest.NewRecorder(), false} - h.ServeHTTP(&rec, test.r) - - // Validate the client received the expected response. - res := rec.Result() - if res.StatusCode != test.wantCode { - t.Errorf("HTTP code = %v, want %v", res.StatusCode, test.wantCode) - } - if diff := cmp.Diff(rec.Body.String(), test.wantBody); diff != "" { - t.Errorf("handler wrote incorrect body (-got +want):\n%s", diff) - } - - // Fields we want to check for in tests but not repeat on every case. - test.wantLog.RemoteAddr = "192.0.2.1:1234" // Hard-coded by httptest.NewRequest. - test.wantLog.Bytes = len(test.wantBody) - - // Validate the AccessLogRecords written to logf and sent back to - // the OnCompletion handler. - checkOutput := func(src string, msgs []AccessLogRecord, opts ...cmp.Option) { - t.Helper() - if len(msgs) != 1 { - t.Errorf("%s: expected 1 msg, got: %#v", src, msgs) - } else if diff := cmp.Diff(msgs[0], test.wantLog, opts...); diff != "" { - t.Errorf("%s: wrong access log (-got +want):\n%s", src, diff) - } - } - checkOutput("hander wrote logs", logs) - checkOutput("start msgs", starts, cmpopts.IgnoreFields(AccessLogRecord{}, "Time", "Seconds", "Code", "Err", "Bytes")) - checkOutput("completion msgs", comps) - - // Validate the code counters. - if got, want := opts.StatusCodeCounters.String(), fmt.Sprintf(`{"%dxx": 1}`, test.wantLog.Code/100); got != want { - t.Errorf("StatusCodeCounters: got %s, want %s", got, want) - } - if got, want := opts.StatusCodeCountersFull.String(), fmt.Sprintf(`{"%d": 1}`, test.wantLog.Code); got != want { - t.Errorf("StatusCodeCountersFull: got %s, want %s", got, want) - } - - // Validate the bucketed counters. - if got, want := opts.BucketedStats.Started.String(), fmt.Sprintf("{%q: 1}", bucket(test.r)); got != want { - t.Errorf("BucketedStats.Started: got %q, want %q", got, want) - } - if got, want := opts.BucketedStats.Finished.String(), fmt.Sprintf("{%q: 1}", bucket(test.r)); got != want { - t.Errorf("BucketedStats.Finished: got %s, want %s", got, want) - } - }) - } -} - -func TestStdHandler_Panic(t *testing.T) { - var r AccessLogRecord - h := StdHandler( - ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - panicElsewhere() - return nil - }), - HandlerOptions{ - Logf: t.Logf, - OnCompletion: func(_ *http.Request, alr AccessLogRecord) { - r = alr - }, - }, - ) - - // Run our panicking handler in a http.Server which catches and rethrows - // any panics. - recovered := make(chan any, 1) - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - recovered <- recover() - }() - h.ServeHTTP(w, r) - })) - t.Cleanup(s.Close) - - // Send a request to our server. - res, err := http.Get(s.URL) - if err != nil { - t.Fatal(err) - } - if rec := <-recovered; rec != nil { - t.Fatalf("expected no panic but saw: %v", rec) - } - - // Check that the log message contained the stack trace in the error. - var logerr bool - if p := "panic: panicked elsewhere\n\ngoroutine "; !strings.HasPrefix(r.Err, p) { - t.Errorf("got Err prefix %q, want %q", r.Err[:min(len(r.Err), len(p))], p) - logerr = true - } - if s := "\ntailscale.com/tsweb.panicElsewhere("; !strings.Contains(r.Err, s) { - t.Errorf("want Err substr %q, not found", s) - logerr = true - } - if logerr { - t.Logf("logger got error: (quoted) %q\n\n(verbatim)\n%s", r.Err, r.Err) - } - - // Check that the server sent an error response. - if res.StatusCode != 500 { - t.Errorf("got status code %d, want %d", res.StatusCode, 500) - } - body, err := io.ReadAll(res.Body) - if err != nil { - t.Errorf("error reading body: %s", err) - } else if want := "Internal Server Error\n"; string(body) != want { - t.Errorf("got body %q, want %q", body, want) - } - res.Body.Close() -} - -func TestStdHandler_Canceled(t *testing.T) { - now := time.Now() - - r := make(chan AccessLogRecord) - var e *HTTPError - handlerOpen := make(chan struct{}) - h := StdHandler( - ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - close(handlerOpen) - ctx := r.Context() - <-ctx.Done() - w.WriteHeader(200) // Ignored. - return ctx.Err() - }), - HandlerOptions{ - Logf: t.Logf, - Now: func() time.Time { return now }, - OnError: func(w http.ResponseWriter, r *http.Request, h HTTPError) { - e = &h - }, - OnCompletion: func(_ *http.Request, alr AccessLogRecord) { - r <- alr - }, - }, - ) - s := httptest.NewServer(h) - t.Cleanup(s.Close) - - // Create a context which gets canceled after the handler starts processing - // the request. - ctx, cancelReq := context.WithCancel(context.Background()) - go func() { - <-handlerOpen - cancelReq() - }() - - // Send a request to our server. - req, err := http.NewRequestWithContext(ctx, httpm.GET, s.URL, nil) - if err != nil { - t.Fatalf("making request: %s", err) - } - res, err := http.DefaultClient.Do(req) - if !errors.Is(err, context.Canceled) { - t.Errorf("got error %v, want context.Canceled", err) - } - if res != nil { - t.Errorf("got response %#v, want nil", res) - } - - // Check that we got the expected log record. - got := <-r - got.Seconds = 0 - got.RemoteAddr = "" - got.Host = "" - got.UserAgent = "" - want := AccessLogRecord{ - Time: now, - Code: 499, - Method: "GET", - Err: "context canceled", - Proto: "HTTP/1.1", - RequestURI: "/", - } - if d := cmp.Diff(want, got); d != "" { - t.Errorf("AccessLogRecord wrong (-want +got)\n%s", d) - } - - // Check that we rendered no response to the client after - // logHandler.OnCompletion has been called. - if e != nil { - t.Errorf("got OnError callback with %#v, want no callback", e) - } -} - -func TestStdHandler_CanceledAfterHeader(t *testing.T) { - now := time.Now() - - r := make(chan AccessLogRecord) - var e *HTTPError - handlerOpen := make(chan struct{}) - h := StdHandler( - ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - w.WriteHeader(http.StatusNoContent) - close(handlerOpen) - ctx := r.Context() - <-ctx.Done() - return ctx.Err() - }), - HandlerOptions{ - Logf: t.Logf, - Now: func() time.Time { return now }, - OnError: func(w http.ResponseWriter, r *http.Request, h HTTPError) { - e = &h - }, - OnCompletion: func(_ *http.Request, alr AccessLogRecord) { - r <- alr - }, - }, - ) - s := httptest.NewServer(h) - t.Cleanup(s.Close) - - // Create a context which gets canceled after the handler starts processing - // the request. - ctx, cancelReq := context.WithCancel(context.Background()) - go func() { - <-handlerOpen - cancelReq() - }() - - // Send a request to our server. - req, err := http.NewRequestWithContext(ctx, httpm.GET, s.URL, nil) - if err != nil { - t.Fatalf("making request: %s", err) - } - res, err := http.DefaultClient.Do(req) - if !errors.Is(err, context.Canceled) { - t.Errorf("got error %v, want context.Canceled", err) - } - if res != nil { - t.Errorf("got response %#v, want nil", res) - } - - // Check that we got the expected log record. - got := <-r - got.Seconds = 0 - got.RemoteAddr = "" - got.Host = "" - got.UserAgent = "" - want := AccessLogRecord{ - Time: now, - Code: 499, - Method: "GET", - Err: "context canceled (original code 204)", - Proto: "HTTP/1.1", - RequestURI: "/", - } - if d := cmp.Diff(want, got); d != "" { - t.Errorf("AccessLogRecord wrong (-want +got)\n%s", d) - } - - // Check that we rendered no response to the client after - // logHandler.OnCompletion has been called. - if e != nil { - t.Errorf("got OnError callback with %#v, want no callback", e) - } -} - -func TestStdHandler_ConnectionClosedDuringBody(t *testing.T) { - now := time.Now() - - // Start a HTTP server that writes back zeros until the request is abandoned. - // We next put a reverse-proxy in front of this server. - rs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - zeroes := make([]byte, 1024) - for r.Context().Err() == nil { - w.Write(zeroes) - } - })) - defer rs.Close() - - r := make(chan AccessLogRecord) - var e *HTTPError - responseStarted := make(chan struct{}) - requestCanceled := make(chan struct{}) - - // Create another server which proxies our zeroes server. - // The [httputil.ReverseProxy] will panic with [http.ErrAbortHandler] when - // it fails to copy the response to the client. - h := StdHandler( - ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - (&httputil.ReverseProxy{ - Director: func(r *http.Request) { - r.URL = must.Get(url.Parse(rs.URL)) - }, - }).ServeHTTP(w, r) - return nil - }), - HandlerOptions{ - Logf: t.Logf, - Now: func() time.Time { return now }, - OnError: func(w http.ResponseWriter, r *http.Request, h HTTPError) { - e = &h - }, - OnCompletion: func(_ *http.Request, alr AccessLogRecord) { - r <- alr - }, - }, - ) - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(responseStarted) - <-requestCanceled - h.ServeHTTP(w, r.WithContext(context.WithoutCancel(r.Context()))) - })) - t.Cleanup(s.Close) - - // Create a context which gets canceled after the handler starts processing - // the request. - ctx, cancelReq := context.WithCancel(context.Background()) - go func() { - <-responseStarted - cancelReq() - }() - - // Send a request to our server. - req, err := http.NewRequestWithContext(ctx, httpm.GET, s.URL, nil) - if err != nil { - t.Fatalf("making request: %s", err) - } - res, err := http.DefaultClient.Do(req) - close(requestCanceled) - if !errors.Is(err, context.Canceled) { - t.Errorf("got error %v, want context.Canceled", err) - } - if res != nil { - t.Errorf("got response %#v, want nil", res) - } - - // Check that we got the expected log record. - got := <-r - got.Seconds = 0 - got.RemoteAddr = "" - got.Host = "" - got.UserAgent = "" - want := AccessLogRecord{ - Time: now, - Code: 499, - Method: "GET", - Err: "net/http: abort Handler (original code 200)", - Proto: "HTTP/1.1", - RequestURI: "/", - } - if d := cmp.Diff(want, got, cmpopts.IgnoreFields(AccessLogRecord{}, "Bytes")); d != "" { - t.Errorf("AccessLogRecord wrong (-want +got)\n%s", d) - } - - // Check that we rendered no response to the client after - // logHandler.OnCompletion has been called. - if e != nil { - t.Errorf("got OnError callback with %#v, want no callback", e) - } -} - -func TestStdHandler_OnErrorPanic(t *testing.T) { - var r AccessLogRecord - h := StdHandler( - ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - // This response is supposed to be written by OnError, but it panics - // so nothing is written. - return Error(401, "lacking auth", nil) - }), - HandlerOptions{ - Logf: t.Logf, - OnError: func(w http.ResponseWriter, r *http.Request, h HTTPError) { - panicElsewhere() - }, - OnCompletion: func(_ *http.Request, alr AccessLogRecord) { - r = alr - }, - }, - ) - - // Run our panicking handler in a http.Server which catches and rethrows - // any panics. - recovered := make(chan any, 1) - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - recovered <- recover() - }() - h.ServeHTTP(w, r) - })) - t.Cleanup(s.Close) - - // Send a request to our server. - res, err := http.Get(s.URL) - if err != nil { - t.Fatal(err) - } - if rec := <-recovered; rec != nil { - t.Fatalf("expected no panic but saw: %v", rec) - } - - // Check that the log message contained the stack trace in the error. - var logerr bool - if p := "lacking auth\n\nthen panic: panicked elsewhere\n\ngoroutine "; !strings.HasPrefix(r.Err, p) { - t.Errorf("got Err prefix %q, want %q", r.Err[:min(len(r.Err), len(p))], p) - logerr = true - } - if s := "\ntailscale.com/tsweb.panicElsewhere("; !strings.Contains(r.Err, s) { - t.Errorf("want Err substr %q, not found", s) - logerr = true - } - if logerr { - t.Logf("logger got error: (quoted) %q\n\n(verbatim)\n%s", r.Err, r.Err) - } - - // Check that the server sent a bare 500 response. - if res.StatusCode != 500 { - t.Errorf("got status code %d, want %d", res.StatusCode, 500) - } - body, err := io.ReadAll(res.Body) - if err != nil { - t.Errorf("error reading body: %s", err) - } else if want := ""; string(body) != want { - t.Errorf("got body %q, want %q", body, want) - } - res.Body.Close() -} - -func TestLogHandler_QuietLogging(t *testing.T) { - now := time.Now() - var logs []string - logf := func(format string, args ...any) { - logs = append(logs, fmt.Sprintf(format, args...)) - } - - var done bool - onComp := func(r *http.Request, alr AccessLogRecord) { - if done { - t.Fatal("expected only one OnCompletion call") - } - done = true - - want := AccessLogRecord{ - Time: now, - RemoteAddr: "192.0.2.1:1234", - Proto: "HTTP/1.1", - Host: "example.com", - Method: "GET", - RequestURI: "/", - Code: 200, - } - if diff := cmp.Diff(want, alr); diff != "" { - t.Fatalf("unexpected OnCompletion AccessLogRecord (-want +got):\n%s", diff) - } - } - - LogHandler( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - w.WriteHeader(201) // loggingResponseWriter will write a warning. - }), - LogOptions{ - Logf: logf, - OnCompletion: onComp, - QuietLogging: true, - Now: func() time.Time { return now }, - }, - ).ServeHTTP( - httptest.NewRecorder(), - httptest.NewRequest("GET", "/", nil), - ) - - if !done { - t.Fatal("OnCompletion call didn't happen") - } - - wantLogs := []string{ - "[unexpected] HTTP handler set statusCode twice (200 and 201)", - } - if diff := cmp.Diff(wantLogs, logs); diff != "" { - t.Fatalf("logs (-want +got):\n%s", diff) - } -} - -func TestErrorHandler_Panic(t *testing.T) { - // errorHandler should panic when not wrapped in logHandler. - defer func() { - rec := recover() - if rec == nil { - t.Fatal("expected errorHandler to panic when not wrapped in logHandler") - } - if want := any("uhoh"); rec != want { - t.Fatalf("got panic %#v, want %#v", rec, want) - } - }() - ErrorHandler( - ReturnHandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - panic("uhoh") - }), - ErrorOptions{}, - ).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) -} - -func panicElsewhere() { - panic("panicked elsewhere") -} - -func BenchmarkLogNot200(b *testing.B) { - b.ReportAllocs() - rh := handlerFunc(func(w http.ResponseWriter, r *http.Request) error { - // Implicit 200 OK. - return nil - }) - h := StdHandler(rh, HandlerOptions{QuietLoggingIfSuccessful: true}) - req := httptest.NewRequest("GET", "/", nil) - rw := new(httptest.ResponseRecorder) - for range b.N { - *rw = httptest.ResponseRecorder{} - h.ServeHTTP(rw, req) - } -} - -func BenchmarkLog(b *testing.B) { - b.ReportAllocs() - rh := handlerFunc(func(w http.ResponseWriter, r *http.Request) error { - // Implicit 200 OK. - return nil - }) - h := StdHandler(rh, HandlerOptions{}) - req := httptest.NewRequest("GET", "/", nil) - rw := new(httptest.ResponseRecorder) - for range b.N { - *rw = httptest.ResponseRecorder{} - h.ServeHTTP(rw, req) - } -} - -func TestHTTPError_Unwrap(t *testing.T) { - wrappedErr := fmt.Errorf("wrapped") - err := Error(404, "not found", wrappedErr) - if got := errors.Unwrap(err); got != wrappedErr { - t.Errorf("HTTPError.Unwrap() = %v, want %v", got, wrappedErr) - } -} - -func TestAcceptsEncoding(t *testing.T) { - tests := []struct { - in, enc string - want bool - }{ - {"", "gzip", false}, - {"gzip", "gzip", true}, - {"foo,gzip", "gzip", true}, - {"foo, gzip", "gzip", true}, - {"foo, gzip ", "gzip", true}, - {"gzip, foo ", "gzip", true}, - {"gzip, foo ", "br", false}, - {"gzip, foo ", "fo", false}, - {"gzip;q=1.2, foo ", "gzip", true}, - {" gzip;q=1.2, foo ", "gzip", true}, - } - for i, tt := range tests { - h := make(http.Header) - if tt.in != "" { - h.Set("Accept-Encoding", tt.in) - } - got := AcceptsEncoding(&http.Request{Header: h}, tt.enc) - if got != tt.want { - t.Errorf("%d. got %v; want %v", i, got, tt.want) - } - } -} - -func TestPort80Handler(t *testing.T) { - tests := []struct { - name string - h *Port80Handler - req string - wantLoc string - }{ - { - name: "no_fqdn", - h: &Port80Handler{}, - req: "GET / HTTP/1.1\r\nHost: foo.com\r\n\r\n", - wantLoc: "https://foo.com/", - }, - { - name: "fqdn_and_path", - h: &Port80Handler{FQDN: "bar.com"}, - req: "GET /path HTTP/1.1\r\nHost: foo.com\r\n\r\n", - wantLoc: "https://bar.com/path", - }, - { - name: "path_and_query_string", - h: &Port80Handler{FQDN: "baz.com"}, - req: "GET /path?a=b HTTP/1.1\r\nHost: foo.com\r\n\r\n", - wantLoc: "https://baz.com/path?a=b", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r, _ := http.ReadRequest(bufio.NewReader(strings.NewReader(tt.req))) - rec := httptest.NewRecorder() - tt.h.ServeHTTP(rec, r) - got := rec.Result() - if got, want := got.StatusCode, 302; got != want { - t.Errorf("got status code %v; want %v", got, want) - } - if got, want := got.Header.Get("Location"), "https://foo.com/"; got != tt.wantLoc { - t.Errorf("Location = %q; want %q", got, want) - } - }) - } -} - -func TestCleanRedirectURL(t *testing.T) { - tailscaleHost := []string{"tailscale.com"} - tailscaleAndOtherHost := []string{"microsoft.com", "tailscale.com"} - localHost := []string{"127.0.0.1", "localhost"} - myServer := []string{"myserver"} - cases := []struct { - url string - hosts []string - want string - wantErr bool - }{ - {"http://tailscale.com/foo", tailscaleHost, "http://tailscale.com/foo", false}, - {"http://tailscale.com/foo", tailscaleAndOtherHost, "http://tailscale.com/foo", false}, - {"http://microsoft.com/foo", tailscaleAndOtherHost, "http://microsoft.com/foo", false}, - {"https://tailscale.com/foo", tailscaleHost, "https://tailscale.com/foo", false}, - {"/foo", tailscaleHost, "/foo", false}, - {"//tailscale.com/foo", tailscaleHost, "//tailscale.com/foo", false}, - {"/a/foobar", tailscaleHost, "/a/foobar", false}, - {"http://127.0.0.1/a/foobar", localHost, "http://127.0.0.1/a/foobar", false}, - {"http://127.0.0.1:123/a/foobar", localHost, "http://127.0.0.1:123/a/foobar", false}, - {"http://127.0.0.1:31544/a/foobar", localHost, "http://127.0.0.1:31544/a/foobar", false}, - {"http://localhost/a/foobar", localHost, "http://localhost/a/foobar", false}, - {"http://localhost:123/a/foobar", localHost, "http://localhost:123/a/foobar", false}, - {"http://localhost:31544/a/foobar", localHost, "http://localhost:31544/a/foobar", false}, - {"http://myserver/a/foobar", myServer, "http://myserver/a/foobar", false}, - {"http://myserver:123/a/foobar", myServer, "http://myserver:123/a/foobar", false}, - {"http://myserver:31544/a/foobar", myServer, "http://myserver:31544/a/foobar", false}, - {"http://evil.com/foo", tailscaleHost, "", true}, - {"//evil.com", tailscaleHost, "", true}, - {"\\\\evil.com", tailscaleHost, "", true}, - {"javascript:alert(123)", tailscaleHost, "", true}, - {"file:///", tailscaleHost, "", true}, - {"file:////SERVER/directory/goats.txt", tailscaleHost, "", true}, - {"https://google.com", tailscaleHost, "", true}, - {"", tailscaleHost, "", false}, - {"\"\"", tailscaleHost, "", true}, - {"https://tailscale.com@goats.com:8443", tailscaleHost, "", true}, - {"https://tailscale.com:8443@goats.com:8443", tailscaleHost, "", true}, - {"HttP://tailscale.com", tailscaleHost, "http://tailscale.com", false}, - {"http://TaIlScAlE.CoM/spongebob", tailscaleHost, "http://TaIlScAlE.CoM/spongebob", false}, - {"ftp://tailscale.com", tailscaleHost, "", true}, - {"https:/evil.com", tailscaleHost, "", true}, // regression test for tailscale/corp#892 - {"%2Fa%2F44869c061701", tailscaleHost, "/a/44869c061701", false}, // regression test for tailscale/corp#13288 - {"https%3A%2Ftailscale.com", tailscaleHost, "", true}, // escaped colon-single-slash malformed URL - {"", nil, "", false}, - } - - for _, tc := range cases { - gotURL, err := CleanRedirectURL(tc.url, tc.hosts) - if err != nil { - if !tc.wantErr { - t.Errorf("CleanRedirectURL(%q, %v) got error: %v", tc.url, tc.hosts, err) - } - } else { - if tc.wantErr { - t.Errorf("CleanRedirectURL(%q, %v) got %q, want an error", tc.url, tc.hosts, gotURL) - } - if got := gotURL.String(); got != tc.want { - t.Errorf("CleanRedirectURL(%q, %v) = %q, want %q", tc.url, tc.hosts, got, tc.want) - } - } - } -} - -func TestBucket(t *testing.T) { - tcs := []struct { - path string - want string - }{ - {"/map", "/map"}, - {"/key?v=63", "/key"}, - {"/map/a87e865a9d1c7", "/map/…"}, - {"/machine/37fc1acb57f256b69b0d76749d814d91c68b241057c6b127fee3df37e4af111e", "/machine/…"}, - {"/machine/37fc1acb57f256b69b0d76749d814d91c68b241057c6b127fee3df37e4af111e/map", "/machine/…/map"}, - {"/api/v2/tailnet/jeremiah@squish.com/devices", "/api/v2/tailnet/…/devices"}, - {"/machine/ssh/wait/5227109621243650/to/7111899293970143/a/a9e4e04cc01b", "/machine/ssh/wait/…/to/…/a/…"}, - {"/a/831a4bf39856?refreshed=true", "/a/…"}, - {"/c2n/nxaaa1CNTRL", "/c2n/…"}, - {"/api/v2/tailnet/blueberries.com/keys/kxaDK21CNTRL", "/api/v2/tailnet/…/keys/…"}, - {"/api/v2/tailnet/bloop@passkey/devices", "/api/v2/tailnet/…/devices"}, - } - - for _, tc := range tcs { - t.Run(tc.path, func(t *testing.T) { - o := BucketedStatsOptions{} - bucket := (&o).bucketForRequest(&http.Request{ - URL: must.Get(url.Parse(tc.path)), - }) - - if bucket != tc.want { - t.Errorf("bucket for %q was %q, want %q", tc.path, bucket, tc.want) - } - }) - } -} - -func TestGenerateRequestID(t *testing.T) { - t0 := time.Now() - got := GenerateRequestID() - t.Logf("Got: %q", got) - if !strings.HasPrefix(string(got), "REQ-2") { - t.Errorf("expect REQ-2 prefix; got %q", got) - } - const wantLen = len("REQ-2024112022140896f8ead3d3f3be27") - if len(got) != wantLen { - t.Fatalf("len = %d; want %d", len(got), wantLen) - } - d := got[len("REQ-"):][:14] - timeBack, err := time.Parse("20060102150405", string(d)) - if err != nil { - t.Fatalf("parsing time back: %v", err) - } - elapsed := timeBack.Sub(t0) - if elapsed > 3*time.Second { // allow for slow github actions runners :) - t.Fatalf("time back was %v; want within 3s", elapsed) - } -} - -func ExampleMiddlewareStack() { - // setHeader returns a middleware that sets header k = vs. - setHeader := func(k string, vs ...string) Middleware { - k = textproto.CanonicalMIMEHeaderKey(k) - return func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header()[k] = vs - h.ServeHTTP(w, r) - }) - } - } - - // h is a http.Handler which prints the A, B & C response headers, wrapped - // in a few middleware which set those headers. - var h http.Handler = MiddlewareStack( - setHeader("A", "mw1"), - MiddlewareStack( - setHeader("A", "mw2.1"), - setHeader("B", "mw2.2"), - setHeader("C", "mw2.3"), - setHeader("C", "mw2.4"), - ), - setHeader("B", "mw3"), - )(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Println("A", w.Header().Get("A")) - fmt.Println("B", w.Header().Get("B")) - fmt.Println("C", w.Header().Get("C")) - })) - - // Invoke the handler. - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("", "/", nil)) - // Output: - // A mw2.1 - // B mw3 - // C mw2.4 -} diff --git a/tsweb/varz/varz.go b/tsweb/varz/varz.go index 952ebc23134c2..49915dd637a49 100644 --- a/tsweb/varz/varz.go +++ b/tsweb/varz/varz.go @@ -19,8 +19,8 @@ import ( "unicode" "unicode/utf8" - "tailscale.com/metrics" - "tailscale.com/version" + "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/version" ) // StaticStringVar returns a new expvar.Var that always returns s. diff --git a/tsweb/varz/varz_test.go b/tsweb/varz/varz_test.go deleted file mode 100644 index 7e094b0e72608..0000000000000 --- a/tsweb/varz/varz_test.go +++ /dev/null @@ -1,420 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package varz - -import ( - "expvar" - "net/http/httptest" - "reflect" - "strings" - "testing" - - "tailscale.com/metrics" - "tailscale.com/tstest" - "tailscale.com/version" -) - -func TestVarzHandler(t *testing.T) { - t.Run("globals_log", func(t *testing.T) { - rec := httptest.NewRecorder() - Handler(rec, httptest.NewRequest("GET", "/", nil)) - t.Logf("Got: %s", rec.Body.Bytes()) - }) - - half := new(expvar.Float) - half.Set(0.5) - - type L2 struct { - Foo string `prom:"foo"` - Bar string `prom:"bar"` - } - - tests := []struct { - name string - k string // key name - v expvar.Var - want string - }{ - { - "int", - "foo", - new(expvar.Int), - "# TYPE foo counter\nfoo 0\n", - }, - { - "dash_in_metric_name", - "counter_foo-bar", - new(expvar.Int), - "# TYPE foo_bar counter\nfoo_bar 0\n", - }, - { - "slash_in_metric_name", - "counter_foo/bar", - new(expvar.Int), - "# TYPE foo_bar counter\nfoo_bar 0\n", - }, - { - "metric_name_start_digit", - "0abc", - new(expvar.Int), - "# TYPE _0abc counter\n_0abc 0\n", - }, - { - "metric_name_have_bogus_bytes", - "abc\x10defügh", - new(expvar.Int), - "# TYPE abcdefgh counter\nabcdefgh 0\n", - }, - { - "int_with_type_counter", - "counter_foo", - new(expvar.Int), - "# TYPE foo counter\nfoo 0\n", - }, - { - "int_with_type_gauge", - "gauge_foo", - new(expvar.Int), - "# TYPE foo gauge\nfoo 0\n", - }, - { - // For a float = 0.0, Prometheus client_golang outputs "0" - "float_zero", - "foo", - new(expvar.Float), - "# TYPE foo gauge\nfoo 0\n", - }, - { - "float_point_5", - "foo", - half, - "# TYPE foo gauge\nfoo 0.5\n", - }, - { - "float_with_type_counter", - "counter_foo", - half, - "# TYPE foo counter\nfoo 0.5\n", - }, - { - "float_with_type_gauge", - "gauge_foo", - half, - "# TYPE foo gauge\nfoo 0.5\n", - }, - { - "metrics_set", - "s", - &metrics.Set{ - Map: *(func() *expvar.Map { - m := new(expvar.Map) - m.Init() - m.Add("foo", 1) - m.Add("bar", 2) - return m - })(), - }, - "# TYPE s_bar counter\ns_bar 2\n# TYPE s_foo counter\ns_foo 1\n", - }, - { - "metrics_set_TODO_gauge_type", - "gauge_s", // TODO(bradfitz): arguably a bug; should pass down type - &metrics.Set{ - Map: *(func() *expvar.Map { - m := new(expvar.Map) - m.Init() - m.Add("foo", 1) - m.Add("bar", 2) - return m - })(), - }, - "# TYPE s_bar counter\ns_bar 2\n# TYPE s_foo counter\ns_foo 1\n", - }, - { - "expvar_map_untyped", - "api_status_code", - func() *expvar.Map { - m := new(expvar.Map) - m.Init() - m.Add("2xx", 100) - m.Add("5xx", 2) - return m - }(), - "api_status_code_2xx 100\napi_status_code_5xx 2\n", - }, - { - "func_float64", - "counter_x", - expvar.Func(func() any { return float64(1.2) }), - "# TYPE x counter\nx 1.2\n", - }, - { - "func_float64_gauge", - "gauge_y", - expvar.Func(func() any { return float64(1.2) }), - "# TYPE y gauge\ny 1.2\n", - }, - { - "func_float64_untyped", - "z", - expvar.Func(func() any { return float64(1.2) }), - "z 1.2\n", - }, - { - "metrics_label_map", - "counter_m", - &metrics.LabelMap{ - Label: "label", - Map: *(func() *expvar.Map { - m := new(expvar.Map) - m.Init() - m.Add("foo", 1) - m.Add("bar", 2) - return m - })(), - }, - "# TYPE m counter\nm{label=\"bar\"} 2\nm{label=\"foo\"} 1\n", - }, - { - "metrics_label_map_untyped", - "control_save_config", - (func() *metrics.LabelMap { - m := &metrics.LabelMap{Label: "reason"} - m.Add("new", 1) - m.Add("updated", 1) - m.Add("fun", 1) - return m - })(), - "control_save_config{reason=\"fun\"} 1\ncontrol_save_config{reason=\"new\"} 1\ncontrol_save_config{reason=\"updated\"} 1\n", - }, - { - "metrics_label_map_unlabeled", - "foo", - (func() *metrics.LabelMap { - m := &metrics.LabelMap{Label: ""} - m.Add("a", 1) - return m - })(), - "foo{label=\"a\"} 1\n", - }, - { - "metrics_multilabel_map", - "foo", - (func() *metrics.MultiLabelMap[L2] { - m := new(metrics.MultiLabelMap[L2]) - m.Add(L2{"a", "b"}, 1) - m.Add(L2{"c", "d"}, 2) - return m - })(), - "foo{foo=\"a\",bar=\"b\"} 1\n" + - "foo{foo=\"c\",bar=\"d\"} 2\n", - }, - { - "expvar_label_map", - "counter_labelmap_keyname_m", - func() *expvar.Map { - m := new(expvar.Map) - m.Init() - m.Add("foo", 1) - m.Add("bar", 2) - return m - }(), - "# TYPE m counter\nm{keyname=\"bar\"} 2\nm{keyname=\"foo\"} 1\n", - }, - { - "struct_reflect", - "foo", - someExpVarWithJSONAndPromTypes(), - strings.TrimSpace(` -# TYPE foo_AUint16 counter -foo_AUint16 65535 -# TYPE foo_AnInt8 counter -foo_AnInt8 127 -# TYPE foo_curTemp gauge -foo_curTemp 20.6 -# TYPE foo_curX gauge -foo_curX 3 -# TYPE foo_nestptr_bar counter -foo_nestptr_bar 20 -# TYPE foo_nestptr_foo gauge -foo_nestptr_foo 10 -# TYPE foo_nestvalue_bar counter -foo_nestvalue_bar 2 -# TYPE foo_nestvalue_foo gauge -foo_nestvalue_foo 1 -# TYPE foo_totalY counter -foo_totalY 4 -`) + "\n", - }, - { - "struct_reflect_nil_root", - "foo", - expvarAdapter{(*SomeStats)(nil)}, - "", - }, - { - "func_returning_int", - "num_goroutines", - expvar.Func(func() any { return 123 }), - "num_goroutines 123\n", - }, - { - "string_version_var", - "foo_version", - expvar.Func(func() any { return "1.2.3-foo15" }), - "foo_version{version=\"1.2.3-foo15\"} 1\n", - }, - { - "field_ordering", - "foo", - someExpVarWithFieldNamesSorting(), - strings.TrimSpace(` -# TYPE foo_bar_a gauge -foo_bar_a 1 -# TYPE foo_bar_b counter -foo_bar_b 1 -# TYPE foo_foo_a gauge -foo_foo_a 1 -# TYPE foo_foo_b counter -foo_foo_b 1 -`) + "\n", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tstest.Replace(t, &expvarDo, func(f func(expvar.KeyValue)) { - f(expvar.KeyValue{Key: tt.k, Value: tt.v}) - }) - rec := httptest.NewRecorder() - Handler(rec, httptest.NewRequest("GET", "/", nil)) - if got := rec.Body.Bytes(); string(got) != tt.want { - t.Errorf("mismatch\n got: %q\n%s\nwant: %q\n%s\n", got, got, tt.want, tt.want) - } - }) - } -} - -type SomeNested struct { - FooG int64 `json:"foo" metrictype:"gauge"` - BarC int64 `json:"bar" metrictype:"counter"` - Omit int `json:"-" metrictype:"counter"` -} - -type SomeStats struct { - Nested SomeNested `json:"nestvalue"` - NestedPtr *SomeNested `json:"nestptr"` - NestedNilPtr *SomeNested `json:"nestnilptr"` - CurX int `json:"curX" metrictype:"gauge"` - NoMetricType int `json:"noMetric" metrictype:""` - TotalY int64 `json:"totalY,omitempty" metrictype:"counter"` - CurTemp float64 `json:"curTemp" metrictype:"gauge"` - AnInt8 int8 `metrictype:"counter"` - AUint16 uint16 `metrictype:"counter"` -} - -// someExpVarWithJSONAndPromTypes returns an expvar.Var that -// implements PrometheusMetricsReflectRooter for TestVarzHandler. -func someExpVarWithJSONAndPromTypes() expvar.Var { - st := &SomeStats{ - Nested: SomeNested{ - FooG: 1, - BarC: 2, - Omit: 3, - }, - NestedPtr: &SomeNested{ - FooG: 10, - BarC: 20, - }, - CurX: 3, - TotalY: 4, - CurTemp: 20.6, - AnInt8: 127, - AUint16: 65535, - } - return expvarAdapter{st} -} - -type expvarAdapter struct { - st *SomeStats -} - -func (expvarAdapter) String() string { return "{}" } // expvar JSON; unused in test - -func (a expvarAdapter) PrometheusMetricsReflectRoot() any { - return a.st -} - -// SomeTestOfFieldNamesSorting demonstrates field -// names that are not in sorted in declaration order, to verify -// that we sort based on field name -type SomeTestOfFieldNamesSorting struct { - FooAG int64 `json:"foo_a" metrictype:"gauge"` - BarAG int64 `json:"bar_a" metrictype:"gauge"` - FooBC int64 `json:"foo_b" metrictype:"counter"` - BarBC int64 `json:"bar_b" metrictype:"counter"` -} - -// someExpVarWithFieldNamesSorting returns an expvar.Var that -// implements PrometheusMetricsReflectRooter for TestVarzHandler. -func someExpVarWithFieldNamesSorting() expvar.Var { - st := &SomeTestOfFieldNamesSorting{ - FooAG: 1, - BarAG: 1, - FooBC: 1, - BarBC: 1, - } - return expvarAdapter2{st} -} - -type expvarAdapter2 struct { - st *SomeTestOfFieldNamesSorting -} - -func (expvarAdapter2) String() string { return "{}" } // expvar JSON; unused in test - -func (a expvarAdapter2) PrometheusMetricsReflectRoot() any { - return a.st -} - -func TestSortedStructAllocs(t *testing.T) { - f := reflect.ValueOf(struct { - Foo int - Bar int - Baz int - }{}) - n := testing.AllocsPerRun(1000, func() { - foreachExportedStructField(f, func(fieldOrJSONName, metricType string, rv reflect.Value) { - // Nothing. - }) - }) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestVarzHandlerSorting(t *testing.T) { - tstest.Replace(t, &expvarDo, func(f func(expvar.KeyValue)) { - f(expvar.KeyValue{Key: "counter_zz", Value: new(expvar.Int)}) - f(expvar.KeyValue{Key: "gauge_aa", Value: new(expvar.Int)}) - }) - rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/", nil) - Handler(rec, req) - got := rec.Body.Bytes() - const want = "# TYPE aa gauge\naa 0\n# TYPE zz counter\nzz 0\n" - if string(got) != want { - t.Errorf("got %q; want %q", got, want) - } - rec = new(httptest.ResponseRecorder) // without a body - - // Lock in the current number of allocs, to prevent it from growing. - if !version.IsRace() { - allocs := int(testing.AllocsPerRun(1000, func() { - Handler(rec, req) - })) - if max := 13; allocs > max { - t.Errorf("allocs = %v; want max %v", allocs, max) - } - } -} diff --git a/types/appctype/appconnector.go b/types/appctype/appconnector.go index f4ced65a41b14..81dcf84d7d16f 100644 --- a/types/appctype/appconnector.go +++ b/types/appctype/appconnector.go @@ -8,7 +8,7 @@ package appctype import ( "net/netip" - "tailscale.com/tailcfg" + "github.com/sagernet/tailscale/tailcfg" ) // ConfigID is an opaque identifier for a configuration. diff --git a/types/appctype/appconnector_test.go b/types/appctype/appconnector_test.go deleted file mode 100644 index 390d1776a3280..0000000000000 --- a/types/appctype/appconnector_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package appctype - -import ( - "encoding/json" - "net/netip" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "tailscale.com/tailcfg" - "tailscale.com/util/must" -) - -var golden = `{ - "dnat": { - "opaqueid1": { - "addrs": ["100.64.0.1", "fd7a:115c:a1e0::1"], - "to": ["example.org"], - "ip": ["*"] - } - }, - "sniProxy": { - "opaqueid2": { - "addrs": ["::"], - "ip": ["tcp:443"], - "allowedDomains": ["*"] - } - }, - "advertiseRoutes": true -}` - -func TestGolden(t *testing.T) { - wantDNAT := map[ConfigID]DNATConfig{"opaqueid1": { - Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}, - To: []string{"example.org"}, - IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}}, - }} - - wantSNI := map[ConfigID]SNIProxyConfig{"opaqueid2": { - Addrs: []netip.Addr{netip.MustParseAddr("::")}, - IP: []tailcfg.ProtoPortRange{{Proto: 6, Ports: tailcfg.PortRange{First: 443, Last: 443}}}, - AllowedDomains: []string{"*"}, - }} - - var config AppConnectorConfig - if err := json.NewDecoder(strings.NewReader(golden)).Decode(&config); err != nil { - t.Fatalf("failed to decode golden config: %v", err) - } - - if !config.AdvertiseRoutes { - t.Fatalf("expected AdvertiseRoutes to be true, got false") - } - - assertEqual(t, "DNAT", config.DNAT, wantDNAT) - assertEqual(t, "SNI", config.SNIProxy, wantSNI) -} - -func TestRoundTrip(t *testing.T) { - var config AppConnectorConfig - must.Do(json.NewDecoder(strings.NewReader(golden)).Decode(&config)) - b := must.Get(json.Marshal(config)) - var config2 AppConnectorConfig - must.Do(json.Unmarshal(b, &config2)) - assertEqual(t, "DNAT", config.DNAT, config2.DNAT) -} - -func assertEqual(t *testing.T, name string, a, b any) { - var addrComparer = cmp.Comparer(func(a, b netip.Addr) bool { - return a.Compare(b) == 0 - }) - t.Helper() - if diff := cmp.Diff(a, b, addrComparer); diff != "" { - t.Fatalf("mismatch (-want +got):\n%s", diff) - } -} diff --git a/types/bools/compare_test.go b/types/bools/compare_test.go deleted file mode 100644 index 280294621e719..0000000000000 --- a/types/bools/compare_test.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package bools - -import "testing" - -func TestCompare(t *testing.T) { - if got := Compare(false, false); got != 0 { - t.Errorf("Compare(false, false) = %v, want 0", got) - } - if got := Compare(false, true); got != -1 { - t.Errorf("Compare(false, true) = %v, want -1", got) - } - if got := Compare(true, false); got != +1 { - t.Errorf("Compare(true, false) = %v, want +1", got) - } - if got := Compare(true, true); got != 0 { - t.Errorf("Compare(true, true) = %v, want 0", got) - } -} diff --git a/types/dnstype/dnstype_test.go b/types/dnstype/dnstype_test.go deleted file mode 100644 index e3a941a2040fc..0000000000000 --- a/types/dnstype/dnstype_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dnstype - -import ( - "net/netip" - "reflect" - "slices" - "sort" - "testing" -) - -func TestResolverEqual(t *testing.T) { - var fieldNames []string - for _, field := range reflect.VisibleFields(reflect.TypeFor[Resolver]()) { - fieldNames = append(fieldNames, field.Name) - } - sort.Strings(fieldNames) - if !slices.Equal(fieldNames, []string{"Addr", "BootstrapResolution"}) { - t.Errorf("Resolver fields changed; update test") - } - - tests := []struct { - name string - a, b *Resolver - want bool - }{ - { - name: "nil", - a: nil, - b: nil, - want: true, - }, - { - name: "nil vs non-nil", - a: nil, - b: &Resolver{}, - want: false, - }, - { - name: "non-nil vs nil", - a: &Resolver{}, - b: nil, - want: false, - }, - { - name: "equal", - a: &Resolver{Addr: "dns.example.com"}, - b: &Resolver{Addr: "dns.example.com"}, - want: true, - }, - { - name: "not equal addrs", - a: &Resolver{Addr: "dns.example.com"}, - b: &Resolver{Addr: "dns2.example.com"}, - want: false, - }, - { - name: "not equal bootstrap", - a: &Resolver{ - Addr: "dns.example.com", - BootstrapResolution: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, - }, - b: &Resolver{ - Addr: "dns.example.com", - BootstrapResolution: []netip.Addr{netip.MustParseAddr("8.8.4.4")}, - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.a.Equal(tt.b) - if got != tt.want { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } -} diff --git a/types/dnstype/dnstype_view.go b/types/dnstype/dnstype_view.go index c0e2b28ffb9b4..aafd139adc0db 100644 --- a/types/dnstype/dnstype_view.go +++ b/types/dnstype/dnstype_view.go @@ -10,7 +10,7 @@ import ( "errors" "net/netip" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/types/views" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=Resolver diff --git a/types/ipproto/ipproto.go b/types/ipproto/ipproto.go index b5333eb56ace0..cd2d5c038bcaa 100644 --- a/types/ipproto/ipproto.go +++ b/types/ipproto/ipproto.go @@ -8,8 +8,8 @@ import ( "fmt" "strconv" - "tailscale.com/util/nocasemaps" - "tailscale.com/util/vizerror" + "github.com/sagernet/tailscale/util/nocasemaps" + "github.com/sagernet/tailscale/util/vizerror" ) // Version describes the IP address version. diff --git a/types/ipproto/ipproto_test.go b/types/ipproto/ipproto_test.go deleted file mode 100644 index 102b79cffae5b..0000000000000 --- a/types/ipproto/ipproto_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ipproto - -import ( - "encoding" - "encoding/json" - "fmt" - "testing" - - "tailscale.com/util/must" -) - -// Ensure that the Proto type implements encoding.TextMarshaler and -// encoding.TextUnmarshaler. -var ( - _ encoding.TextMarshaler = (*Proto)(nil) - _ encoding.TextUnmarshaler = (*Proto)(nil) -) - -func TestHistoricalStringNames(t *testing.T) { - // A subset of supported protocols were described with their lowercase String() representations and must remain supported. - var historical = map[string]Proto{ - "icmpv4": ICMPv4, - "igmp": IGMP, - "tcp": TCP, - "udp": UDP, - "dccp": DCCP, - "gre": GRE, - "sctp": SCTP, - } - - for name, proto := range historical { - var p Proto - must.Do(p.UnmarshalText([]byte(name))) - if got, want := p, proto; got != want { - t.Errorf("Proto.UnmarshalText(%q) = %v, want %v", name, got, want) - } - } -} - -func TestAcceptedNamesContainsPreferredNames(t *testing.T) { - for proto, name := range preferredNames { - if _, ok := acceptedNames[name]; !ok { - t.Errorf("preferredNames[%q] = %v, but acceptedNames does not contain it", name, proto) - } - } -} - -func TestProtoTextEncodingRoundTrip(t *testing.T) { - for i := range 256 { - text := must.Get(Proto(i).MarshalText()) - var p Proto - must.Do(p.UnmarshalText(text)) - - if got, want := p, Proto(i); got != want { - t.Errorf("Proto(%d) round-trip got %v, want %v", i, got, want) - } - } -} - -func TestProtoUnmarshalText(t *testing.T) { - var p Proto = 1 - err := p.UnmarshalText([]byte(nil)) - if err != nil || p != 0 { - t.Fatalf("empty input, got err=%v, p=%v, want nil, 0", err, p) - } - - for i := range 256 { - var p Proto - must.Do(p.UnmarshalText([]byte(fmt.Sprintf("%d", i)))) - if got, want := p, Proto(i); got != want { - t.Errorf("Proto(%d) = %v, want %v", i, got, want) - } - } - - for name, wantProto := range acceptedNames { - var p Proto - must.Do(p.UnmarshalText([]byte(name))) - if got, want := p, wantProto; got != want { - t.Errorf("Proto(%q) = %v, want %v", name, got, want) - } - } - - for wantProto, name := range preferredNames { - var p Proto - must.Do(p.UnmarshalText([]byte(name))) - if got, want := p, wantProto; got != want { - t.Errorf("Proto(%q) = %v, want %v", name, got, want) - } - } -} - -func TestProtoMarshalText(t *testing.T) { - for i := range 256 { - text := must.Get(Proto(i).MarshalText()) - - if wantName, ok := preferredNames[Proto(i)]; ok { - if got, want := string(text), wantName; got != want { - t.Errorf("Proto(%d).MarshalText() = %q, want preferred name %q", i, got, want) - } - continue - } - - if got, want := string(text), fmt.Sprintf("%d", i); got != want { - t.Errorf("Proto(%d).MarshalText() = %q, want %q", i, got, want) - } - } -} - -func TestProtoMarshalJSON(t *testing.T) { - for i := range 256 { - j := must.Get(Proto(i).MarshalJSON()) - if got, want := string(j), fmt.Sprintf(`%d`, i); got != want { - t.Errorf("Proto(%d).MarshalJSON() = %q, want %q", i, got, want) - } - } -} - -func TestProtoUnmarshalJSON(t *testing.T) { - var p Proto - - for i := range 256 { - j := []byte(fmt.Sprintf(`%d`, i)) - must.Do(json.Unmarshal(j, &p)) - if got, want := p, Proto(i); got != want { - t.Errorf("Proto(%d) = %v, want %v", i, got, want) - } - } - - for name, wantProto := range acceptedNames { - must.Do(json.Unmarshal([]byte(fmt.Sprintf(`"%s"`, name)), &p)) - if got, want := p, wantProto; got != want { - t.Errorf("Proto(%q) = %v, want %v", name, got, want) - } - } -} diff --git a/types/key/chal.go b/types/key/chal.go index 742ac5479e4a1..ed40c4065a8b7 100644 --- a/types/key/chal.go +++ b/types/key/chal.go @@ -6,8 +6,8 @@ package key import ( "errors" + "github.com/sagernet/tailscale/types/structs" "go4.org/mem" - "tailscale.com/types/structs" ) const ( diff --git a/types/key/control_test.go b/types/key/control_test.go deleted file mode 100644 index a98a586f3ba5a..0000000000000 --- a/types/key/control_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package key - -import ( - "encoding/json" - "testing" -) - -func TestControlKey(t *testing.T) { - serialized := `{"PrivateKey":[36,132,249,6,73,141,249,49,9,96,49,60,240,217,253,57,3,69,248,64,178,62,121,73,121,88,115,218,130,145,68,254]}` - want := ControlPrivate{ - MachinePrivate{ - k: [32]byte{36, 132, 249, 6, 73, 141, 249, 49, 9, 96, 49, 60, 240, 217, 253, 57, 3, 69, 248, 64, 178, 62, 121, 73, 121, 88, 115, 218, 130, 145, 68, 254}, - }, - } - - var got struct { - PrivateKey ControlPrivate - } - if err := json.Unmarshal([]byte(serialized), &got); err != nil { - t.Fatalf("decoding serialized ControlPrivate: %v", err) - } - - if !got.PrivateKey.mkey.Equal(want.mkey) { - t.Fatalf("Serialized ControlPrivate didn't deserialize as expected, got %v want %v", got.PrivateKey, want) - } - - bs, err := json.Marshal(got) - if err != nil { - t.Fatalf("json reserialization of ControlPrivate failed: %v", err) - } - - if got, want := string(bs), serialized; got != want { - t.Fatalf("ControlPrivate didn't round-trip, got %q want %q", got, want) - } -} diff --git a/types/key/disco.go b/types/key/disco.go index 1013ce5bf89af..5680cd02a391c 100644 --- a/types/key/disco.go +++ b/types/key/disco.go @@ -8,10 +8,10 @@ import ( "crypto/subtle" "fmt" + "github.com/sagernet/tailscale/types/structs" "go4.org/mem" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/nacl/box" - "tailscale.com/types/structs" ) const ( diff --git a/types/key/disco_test.go b/types/key/disco_test.go deleted file mode 100644 index c62c13cbf8970..0000000000000 --- a/types/key/disco_test.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package key - -import ( - "bytes" - "encoding/json" - "testing" -) - -func TestDiscoKey(t *testing.T) { - k := NewDisco() - if k.IsZero() { - t.Fatal("DiscoPrivate should not be zero") - } - - p := k.Public() - if p.IsZero() { - t.Fatal("DiscoPublic should not be zero") - } - - bs, err := p.MarshalText() - if err != nil { - t.Fatal(err) - } - if !bytes.HasPrefix(bs, []byte("discokey:")) { - t.Fatalf("serialization of public discokey %s has wrong prefix", p) - } - - z := DiscoPublic{} - if !z.IsZero() { - t.Fatal("IsZero(DiscoPublic{}) is false") - } - if s := z.ShortString(); s != "" { - t.Fatalf("DiscoPublic{}.ShortString() is %q, want \"\"", s) - } -} - -func TestDiscoSerialization(t *testing.T) { - serialized := `{ - "Pub":"discokey:50d20b455ecf12bc453f83c2cfdb2a24925d06cf2598dcaa54e91af82ce9f765" - }` - - pub := DiscoPublic{ - k: [32]uint8{ - 0x50, 0xd2, 0xb, 0x45, 0x5e, 0xcf, 0x12, 0xbc, 0x45, 0x3f, 0x83, - 0xc2, 0xcf, 0xdb, 0x2a, 0x24, 0x92, 0x5d, 0x6, 0xcf, 0x25, 0x98, - 0xdc, 0xaa, 0x54, 0xe9, 0x1a, 0xf8, 0x2c, 0xe9, 0xf7, 0x65, - }, - } - - type key struct { - Pub DiscoPublic - } - - var a key - if err := json.Unmarshal([]byte(serialized), &a); err != nil { - t.Fatal(err) - } - if a.Pub != pub { - t.Errorf("wrong deserialization of public key, got %#v want %#v", a.Pub, pub) - } - - bs, err := json.MarshalIndent(a, "", " ") - if err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - json.Indent(&b, []byte(serialized), "", " ") - if got, want := string(bs), b.String(); got != want { - t.Error("json serialization doesn't roundtrip") - } -} - -func TestDiscoShared(t *testing.T) { - k1, k2 := NewDisco(), NewDisco() - s1, s2 := k1.Shared(k2.Public()), k2.Shared(k1.Public()) - if !s1.Equal(s2) { - t.Error("k1.Shared(k2) != k2.Shared(k1)") - } -} diff --git a/types/key/machine.go b/types/key/machine.go index a05f3cc1f5735..074eff7df2d65 100644 --- a/types/key/machine.go +++ b/types/key/machine.go @@ -8,10 +8,10 @@ import ( "crypto/subtle" "encoding/hex" + "github.com/sagernet/tailscale/types/structs" "go4.org/mem" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/nacl/box" - "tailscale.com/types/structs" ) const ( diff --git a/types/key/machine_test.go b/types/key/machine_test.go deleted file mode 100644 index 157df9e4356b1..0000000000000 --- a/types/key/machine_test.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package key - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestMachineKey(t *testing.T) { - k := NewMachine() - if k.IsZero() { - t.Fatal("MachinePrivate should not be zero") - } - - p := k.Public() - if p.IsZero() { - t.Fatal("MachinePublic should not be zero") - } - - bs, err := p.MarshalText() - if err != nil { - t.Fatal(err) - } - if full, got := string(bs), ":"+p.UntypedHexString(); !strings.HasSuffix(full, got) { - t.Fatalf("MachinePublic.UntypedHexString is not a suffix of the typed serialization, got %q want suffix of %q", got, full) - } - - z := MachinePublic{} - if !z.IsZero() { - t.Fatal("IsZero(MachinePublic{}) is false") - } - if s := z.ShortString(); s != "" { - t.Fatalf("MachinePublic{}.ShortString() is %q, want \"\"", s) - } -} - -func TestMachineSerialization(t *testing.T) { - serialized := `{ - "Priv": "privkey:40ab1b58e9076c7a4d9d07291f5edf9d1aa017eb949624ba683317f48a640369", - "Pub":"mkey:50d20b455ecf12bc453f83c2cfdb2a24925d06cf2598dcaa54e91af82ce9f765" - }` - - // Carefully check that the expected serialized data decodes and - // reencodes to the expected keys. These types are serialized to - // disk all over the place and need to be stable. - priv := MachinePrivate{ - k: [32]uint8{ - 0x40, 0xab, 0x1b, 0x58, 0xe9, 0x7, 0x6c, 0x7a, 0x4d, 0x9d, 0x7, - 0x29, 0x1f, 0x5e, 0xdf, 0x9d, 0x1a, 0xa0, 0x17, 0xeb, 0x94, - 0x96, 0x24, 0xba, 0x68, 0x33, 0x17, 0xf4, 0x8a, 0x64, 0x3, 0x69, - }, - } - pub := MachinePublic{ - k: [32]uint8{ - 0x50, 0xd2, 0xb, 0x45, 0x5e, 0xcf, 0x12, 0xbc, 0x45, 0x3f, 0x83, - 0xc2, 0xcf, 0xdb, 0x2a, 0x24, 0x92, 0x5d, 0x6, 0xcf, 0x25, 0x98, - 0xdc, 0xaa, 0x54, 0xe9, 0x1a, 0xf8, 0x2c, 0xe9, 0xf7, 0x65, - }, - } - - type keypair struct { - Priv MachinePrivate - Pub MachinePublic - } - - var a keypair - if err := json.Unmarshal([]byte(serialized), &a); err != nil { - t.Fatal(err) - } - if !a.Priv.Equal(priv) { - t.Errorf("wrong deserialization of private key, got %#v want %#v", a.Priv, priv) - } - if a.Pub != pub { - t.Errorf("wrong deserialization of public key, got %#v want %#v", a.Pub, pub) - } - - bs, err := json.MarshalIndent(a, "", " ") - if err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - json.Indent(&b, []byte(serialized), "", " ") - if got, want := string(bs), b.String(); got != want { - t.Error("json serialization doesn't roundtrip") - } -} - -func TestSealViaSharedKey(t *testing.T) { - // encrypt a message from a to b - a := NewMachine() - b := NewMachine() - apub, bpub := a.Public(), b.Public() - - shared := a.SharedKey(bpub) - - const clear = "the eagle flies at midnight" - enc := shared.Seal([]byte(clear)) - - back, ok := b.OpenFrom(apub, enc) - if !ok { - t.Fatal("failed to decrypt") - } - if string(back) != clear { - t.Errorf("OpenFrom got %q; want cleartext %q", back, clear) - } - - backShared, ok := shared.Open(enc) - if !ok { - t.Fatal("failed to decrypt from shared key") - } - if string(backShared) != clear { - t.Errorf("Open got %q; want cleartext %q", back, clear) - } -} diff --git a/types/key/nl.go b/types/key/nl.go index 50caed98c2d0b..9cc0ab729c0b6 100644 --- a/types/key/nl.go +++ b/types/key/nl.go @@ -7,9 +7,9 @@ import ( "crypto/ed25519" "crypto/subtle" + "github.com/sagernet/tailscale/types/structs" + "github.com/sagernet/tailscale/types/tkatype" "go4.org/mem" - "tailscale.com/types/structs" - "tailscale.com/types/tkatype" ) const ( diff --git a/types/key/nl_test.go b/types/key/nl_test.go deleted file mode 100644 index 75b7765a19ea1..0000000000000 --- a/types/key/nl_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package key - -import ( - "bytes" - "testing" -) - -func TestNLPrivate(t *testing.T) { - p := NewNLPrivate() - - encoded, err := p.MarshalText() - if err != nil { - t.Fatal(err) - } - var decoded NLPrivate - if err := decoded.UnmarshalText(encoded); err != nil { - t.Fatal(err) - } - if !bytes.Equal(decoded.k[:], p.k[:]) { - t.Error("decoded and generated NLPrivate bytes differ") - } - - // Test NLPublic - pub := p.Public() - encoded, err = pub.MarshalText() - if err != nil { - t.Fatal(err) - } - var decodedPub NLPublic - if err := decodedPub.UnmarshalText(encoded); err != nil { - t.Fatal(err) - } - if !bytes.Equal(decodedPub.k[:], pub.k[:]) { - t.Error("decoded and generated NLPublic bytes differ") - } - - // Test decoding with CLI prefix: 'nlpub:' => 'tlpub:' - decodedPub = NLPublic{} - if err := decodedPub.UnmarshalText([]byte(pub.CLIString())); err != nil { - t.Fatal(err) - } - if !bytes.Equal(decodedPub.k[:], pub.k[:]) { - t.Error("decoded and generated NLPublic bytes differ (CLI prefix)") - } -} diff --git a/types/key/node.go b/types/key/node.go index 11ee1fa3cfd41..ac77e4ce3332e 100644 --- a/types/key/node.go +++ b/types/key/node.go @@ -11,10 +11,10 @@ import ( "errors" "fmt" + "github.com/sagernet/tailscale/types/structs" "go4.org/mem" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/nacl/box" - "tailscale.com/types/structs" ) const ( diff --git a/types/key/node_test.go b/types/key/node_test.go deleted file mode 100644 index 80a2dadf90f5f..0000000000000 --- a/types/key/node_test.go +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package key - -import ( - "bufio" - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestNodeKey(t *testing.T) { - k := NewNode() - if k.IsZero() { - t.Fatal("NodePrivate should not be zero") - } - - p := k.Public() - if p.IsZero() { - t.Fatal("NodePublic should not be zero") - } - - bs, err := p.MarshalText() - if err != nil { - t.Fatal(err) - } - if full, got := string(bs), ":"+p.UntypedHexString(); !strings.HasSuffix(full, got) { - t.Fatalf("NodePublic.UntypedHexString is not a suffix of the typed serialization, got %q want suffix of %q", got, full) - } - bs, err = p.MarshalBinary() - if err != nil { - t.Fatal(err) - } - if got, want := bs, append([]byte(nodePublicBinaryPrefix), p.k[:]...); !bytes.Equal(got, want) { - t.Fatalf("Binary-encoded NodePublic = %x, want %x", got, want) - } - var decoded NodePublic - if err := decoded.UnmarshalBinary(bs); err != nil { - t.Fatalf("NodePublic.UnmarshalBinary(%x) failed: %v", bs, err) - } - if decoded != p { - t.Errorf("unmarshaled and original NodePublic differ:\noriginal = %v\ndecoded = %v", p, decoded) - } - - z := NodePublic{} - if !z.IsZero() { - t.Fatal("IsZero(NodePublic{}) is false") - } - if s := z.ShortString(); s != "" { - t.Fatalf("NodePublic{}.ShortString() is %q, want \"\"", s) - } -} - -func TestNodeSerialization(t *testing.T) { - serialized := `{ - "Priv": "privkey:40ab1b58e9076c7a4d9d07291f5edf9d1aa017eb949624ba683317f48a640369", - "Pub":"nodekey:50d20b455ecf12bc453f83c2cfdb2a24925d06cf2598dcaa54e91af82ce9f765" - }` - - // Carefully check that the expected serialized data decodes and - // re-encodes to the expected keys. These types are serialized to - // disk all over the place and need to be stable. - priv := NodePrivate{ - k: [32]uint8{ - 0x40, 0xab, 0x1b, 0x58, 0xe9, 0x7, 0x6c, 0x7a, 0x4d, 0x9d, 0x7, - 0x29, 0x1f, 0x5e, 0xdf, 0x9d, 0x1a, 0xa0, 0x17, 0xeb, 0x94, - 0x96, 0x24, 0xba, 0x68, 0x33, 0x17, 0xf4, 0x8a, 0x64, 0x3, 0x69, - }, - } - pub := NodePublic{ - k: [32]uint8{ - 0x50, 0xd2, 0xb, 0x45, 0x5e, 0xcf, 0x12, 0xbc, 0x45, 0x3f, 0x83, - 0xc2, 0xcf, 0xdb, 0x2a, 0x24, 0x92, 0x5d, 0x6, 0xcf, 0x25, 0x98, - 0xdc, 0xaa, 0x54, 0xe9, 0x1a, 0xf8, 0x2c, 0xe9, 0xf7, 0x65, - }, - } - - type keypair struct { - Priv NodePrivate - Pub NodePublic - } - - var a keypair - if err := json.Unmarshal([]byte(serialized), &a); err != nil { - t.Fatal(err) - } - if !a.Priv.Equal(priv) { - t.Errorf("wrong deserialization of private key, got %#v want %#v", a.Priv, priv) - } - if a.Pub != pub { - t.Errorf("wrong deserialization of public key, got %#v want %#v", a.Pub, pub) - } - - bs, err := json.MarshalIndent(a, "", " ") - if err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - json.Indent(&b, []byte(serialized), "", " ") - if got, want := string(bs), b.String(); got != want { - t.Error("json serialization doesn't roundtrip") - } -} - -func TestNodeReadRawWithoutAllocating(t *testing.T) { - buf := make([]byte, 32) - for i := range buf { - buf[i] = 0x42 - } - r := bytes.NewReader(buf) - br := bufio.NewReader(r) - got := testing.AllocsPerRun(1000, func() { - r.Reset(buf) - br.Reset(r) - var k NodePublic - if err := k.ReadRawWithoutAllocating(br); err != nil { - t.Fatalf("ReadRawWithoutAllocating: %v", err) - } - }) - if want := 0.0; got != want { - t.Fatalf("ReadRawWithoutAllocating got %f allocs, want %f", got, want) - } -} - -func TestNodeWriteRawWithoutAllocating(t *testing.T) { - buf := make([]byte, 0, 32) - w := bytes.NewBuffer(buf) - bw := bufio.NewWriter(w) - got := testing.AllocsPerRun(1000, func() { - w.Reset() - bw.Reset(w) - var k NodePublic - if err := k.WriteRawWithoutAllocating(bw); err != nil { - t.Fatalf("WriteRawWithoutAllocating: %v", err) - } - }) - if want := 0.0; got != want { - t.Fatalf("WriteRawWithoutAllocating got %f allocs, want %f", got, want) - } -} - -func TestChallenge(t *testing.T) { - priv := NewChallenge() - pub := priv.Public() - txt, err := pub.MarshalText() - if err != nil { - t.Fatal(err) - } - var back ChallengePublic - if err := back.UnmarshalText(txt); err != nil { - t.Fatal(err) - } - if back != pub { - t.Errorf("didn't round trip: %v != %v", back, pub) - } -} - -// Test that NodePublic.Shard is uniformly distributed. -func TestShard(t *testing.T) { - const N = 1_000 - var shardCount [256]int - for range N { - shardCount[NewNode().Public().Shard()]++ - } - e := float64(N) / 256 // expected - var x2 float64 // chi-squared - for _, c := range shardCount { - r := float64(c) - e // residual - x2 += r * r / e - } - t.Logf("x^2 = %v", x2) - if x2 > 512 { // really want x^2 =~ (256 - 1), but leave slop - t.Errorf("too much variation in shard distribution") - for i, c := range shardCount { - rj := float64(c) - e - t.Logf("shard[%v] = %v (off by %v)", i, c, rj) - } - } -} diff --git a/types/key/util_test.go b/types/key/util_test.go deleted file mode 100644 index 4d6f8242280ad..0000000000000 --- a/types/key/util_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package key - -import ( - "bytes" - "testing" -) - -func TestRand(t *testing.T) { - var bs [32]byte - rand(bs[:]) - if bs == [32]byte{} { - t.Fatal("rand didn't provide randomness") - } - var bs2 [32]byte - rand(bs2[:]) - if bytes.Equal(bs[:], bs2[:]) { - t.Fatal("rand returned the same data twice") - } -} - -func TestClamp25519Private(t *testing.T) { - for range 100 { - var k [32]byte - rand(k[:]) - clamp25519Private(k[:]) - if k[0]&0b111 != 0 { - t.Fatalf("Bogus clamping in first byte: %#08b", k[0]) - return - } - if k[31]>>6 != 1 { - t.Fatalf("Bogus clamping in last byte: %#08b", k[0]) - } - } -} diff --git a/types/lazy/deferred.go b/types/lazy/deferred.go index 964553cef6524..ec32331d5814f 100644 --- a/types/lazy/deferred.go +++ b/types/lazy/deferred.go @@ -7,7 +7,7 @@ import ( "sync" "sync/atomic" - "tailscale.com/types/ptr" + "github.com/sagernet/tailscale/types/ptr" ) // DeferredInit allows one or more funcs to be deferred diff --git a/types/lazy/deferred_test.go b/types/lazy/deferred_test.go deleted file mode 100644 index 9de16c67a6067..0000000000000 --- a/types/lazy/deferred_test.go +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package lazy - -import ( - "errors" - "fmt" - "sync" - "sync/atomic" - "testing" -) - -func ExampleDeferredInit() { - // DeferredInit allows both registration and invocation of the - // deferred funcs. It should remain internal to the code that "owns" it. - var di DeferredInit - // Deferred funcs will not be executed until [DeferredInit.Do] is called. - deferred := di.Defer(func() error { - fmt.Println("Internal init") - return nil - }) - // [DeferredInit.Defer] reports whether the function was successfully deferred. - // A func can only fail to defer if [DeferredInit.Do] has already been called. - if deferred { - fmt.Printf("Internal init has been deferred\n\n") - } - - // If necessary, the value returned by [DeferredInit.Funcs] - // can be shared with external code to facilitate deferring - // funcs without allowing it to call [DeferredInit.Do]. - df := di.Funcs() - // If a certain init step must be completed for the program - // to function correctly, and failure to defer it indicates - // a coding error, use [DeferredFuncs.MustDefer] instead of - // [DeferredFuncs.Defer]. It panics if Do() has already been called. - df.MustDefer(func() error { - fmt.Println("External init - 1") - return nil - }) - // A deferred func may return an error to indicate a failed init. - // If a deferred func returns an error, execution stops - // and the error is propagated to the caller. - df.Defer(func() error { - fmt.Println("External init - 2") - return errors.New("bang!") - }) - // The deferred function below won't be executed. - df.Defer(func() error { - fmt.Println("Unreachable") - return nil - }) - - // When [DeferredInit]'s owner needs initialization to be completed, - // it can call [DeferredInit.Do]. When called for the first time, - // it invokes the deferred funcs. - err := di.Do() - if err != nil { - fmt.Printf("Deferred init failed: %v\n", err) - } - // [DeferredInit.Do] is safe for concurrent use and can be called - // multiple times by the same or different goroutines. - // However, the deferred functions are never invoked more than once. - // If the deferred init fails on the first attempt, all subsequent - // [DeferredInit.Do] calls will return the same error. - if err = di.Do(); err != nil { - fmt.Printf("Deferred init failed: %v\n\n", err) - } - - // Additionally, all subsequent attempts to defer a function will fail - // after [DeferredInit.Do] has been called. - deferred = di.Defer(func() error { - fmt.Println("Unreachable") - return nil - }) - if !deferred { - fmt.Println("Cannot defer a func once init has been completed") - } - - // Output: - // Internal init has been deferred - // - // Internal init - // External init - 1 - // External init - 2 - // Deferred init failed: bang! - // Deferred init failed: bang! - // - // Cannot defer a func once init has been completed -} - -func TestDeferredInit(t *testing.T) { - tests := []struct { - name string - numFuncs int - }{ - { - name: "no-funcs", - numFuncs: 0, - }, - { - name: "one-func", - numFuncs: 1, - }, - { - name: "many-funcs", - numFuncs: 1000, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var di DeferredInit - - calls := make([]atomic.Bool, tt.numFuncs) // whether N-th func has been called - checkCalls := func() { - t.Helper() - for i := range calls { - if !calls[i].Load() { - t.Errorf("Func #%d has never been called", i) - } - } - } - - // Defer funcs concurrently across multiple goroutines. - var wg sync.WaitGroup - wg.Add(tt.numFuncs) - for i := range tt.numFuncs { - go func() { - f := func() error { - if calls[i].Swap(true) { - t.Errorf("Func #%d has already been called", i) - } - return nil - } - if !di.Defer(f) { - t.Errorf("Func #%d cannot be deferred", i) - return - } - wg.Done() - }() - } - // Wait for all funcs to be deferred. - wg.Wait() - - // Call [DeferredInit.Do] concurrently. - const N = 10000 - for range N { - wg.Add(1) - go func() { - gotErr := di.Do() - checkError(t, gotErr, nil, false) - checkCalls() - wg.Done() - }() - } - wg.Wait() - }) - } -} - -func TestDeferredErr(t *testing.T) { - tests := []struct { - name string - funcs []func() error - wantErr error - }{ - { - name: "no-funcs", - wantErr: nil, - }, - { - name: "no-error", - funcs: []func() error{func() error { return nil }}, - wantErr: nil, - }, - { - name: "error", - funcs: []func() error{ - func() error { return nil }, - func() error { return errors.New("bang!") }, - func() error { return errors.New("unreachable") }, - }, - wantErr: errors.New("bang!"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var di DeferredInit - for _, f := range tt.funcs { - di.MustDefer(f) - } - - var wg sync.WaitGroup - N := 10000 - for range N { - wg.Add(1) - go func() { - gotErr := di.Do() - checkError(t, gotErr, tt.wantErr, false) - wg.Done() - }() - } - wg.Wait() - }) - } -} - -func TestDeferAfterDo(t *testing.T) { - var di DeferredInit - var deferred, called atomic.Int32 - - deferOnce := func() bool { - ok := di.Defer(func() error { - called.Add(1) - return nil - }) - if ok { - deferred.Add(1) - } - return ok - } - - // Deferring a func before calling [DeferredInit.Do] should always succeed. - if !deferOnce() { - t.Fatal("Failed to defer a func") - } - - // Defer up to N funcs concurrently while [DeferredInit.Do] is being called by the main goroutine. - // Since we'll likely attempt to defer some funcs after [DeferredInit.Do] has been called, - // we expect these late defers to fail, and the funcs will not be deferred or executed. - // However, the number of the deferred and called funcs should always be equal when [DeferredInit.Do] exits. - const N = 10000 - var wg sync.WaitGroup - for range N { - wg.Add(1) - go func() { - deferOnce() - wg.Done() - }() - } - - if err := di.Do(); err != nil { - t.Fatalf("DeferredInit.Do() failed: %v", err) - } - wantDeferred, wantCalled := deferred.Load(), called.Load() - - if deferOnce() { - t.Error("An init func was deferred after DeferredInit.Do() returned") - } - - // Wait for the goroutines deferring init funcs to exit. - // No funcs should be deferred after DeferredInit.Do() has returned, - // so the deferred and called counters should remain unchanged. - wg.Wait() - if gotDeferred := deferred.Load(); gotDeferred != wantDeferred { - t.Errorf("An init func was deferred after DeferredInit.Do() returned. Got %d, want %d", gotDeferred, wantDeferred) - } - if gotCalled := called.Load(); gotCalled != wantCalled { - t.Errorf("An init func was called after DeferredInit.Do() returned. Got %d, want %d", gotCalled, wantCalled) - } - if deferred, called := deferred.Load(), called.Load(); deferred != called { - t.Errorf("Deferred: %d; Called: %d", deferred, called) - } -} - -func checkError(tb testing.TB, got, want error, fatal bool) { - tb.Helper() - f := tb.Errorf - if fatal { - f = tb.Fatalf - } - if (want == nil && got != nil) || - (want != nil && got == nil) || - (want != nil && got != nil && want.Error() != got.Error()) { - f("gotErr: %v; wantErr: %v", got, want) - } -} diff --git a/types/lazy/lazy.go b/types/lazy/lazy.go index 43325512d9cb0..80ce9b514610e 100644 --- a/types/lazy/lazy.go +++ b/types/lazy/lazy.go @@ -8,7 +8,7 @@ import ( "sync" "sync/atomic" - "tailscale.com/types/ptr" + "github.com/sagernet/tailscale/types/ptr" ) // nilErrPtr is a sentinel *error value for SyncValue.err to signal diff --git a/types/lazy/sync_test.go b/types/lazy/sync_test.go deleted file mode 100644 index 5578eee0cfed9..0000000000000 --- a/types/lazy/sync_test.go +++ /dev/null @@ -1,399 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package lazy - -import ( - "errors" - "fmt" - "sync" - "testing" - - "tailscale.com/types/opt" -) - -func TestSyncValue(t *testing.T) { - var lt SyncValue[int] - n := int(testing.AllocsPerRun(1000, func() { - got := lt.Get(fortyTwo) - if got != 42 { - t.Fatalf("got %v; want 42", got) - } - if p, ok := lt.Peek(); !ok { - t.Fatalf("Peek failed") - } else if p != 42 { - t.Fatalf("Peek got %v; want 42", p) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestSyncValueErr(t *testing.T) { - var lt SyncValue[int] - n := int(testing.AllocsPerRun(1000, func() { - got, err := lt.GetErr(func() (int, error) { - return 42, nil - }) - if got != 42 || err != nil { - t.Fatalf("got %v, %v; want 42, nil", got, err) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } - - var lterr SyncValue[int] - wantErr := errors.New("test error") - n = int(testing.AllocsPerRun(1000, func() { - got, err := lterr.GetErr(func() (int, error) { - return 0, wantErr - }) - if got != 0 || err != wantErr { - t.Fatalf("got %v, %v; want 0, %v", got, err, wantErr) - } - - if p, ok := lt.Peek(); !ok { - t.Fatalf("Peek failed") - } else if got != 0 { - t.Fatalf("Peek got %v; want 0", p) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestSyncValueSet(t *testing.T) { - var lt SyncValue[int] - if !lt.Set(42) { - t.Fatalf("Set failed") - } - if lt.Set(43) { - t.Fatalf("Set succeeded after first Set") - } - if p, ok := lt.Peek(); !ok { - t.Fatalf("Peek failed") - } else if p != 42 { - t.Fatalf("Peek got %v; want 42", p) - } - n := int(testing.AllocsPerRun(1000, func() { - got := lt.Get(fortyTwo) - if got != 42 { - t.Fatalf("got %v; want 42", got) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestSyncValueMustSet(t *testing.T) { - var lt SyncValue[int] - lt.MustSet(42) - defer func() { - if e := recover(); e == nil { - t.Errorf("unexpected success; want panic") - } - }() - lt.MustSet(43) -} - -func TestSyncValueErrPeek(t *testing.T) { - var sv SyncValue[int] - sv.GetErr(func() (int, error) { - return 123, errors.New("boom") - }) - p, ok := sv.Peek() - if ok { - t.Error("unexpected Peek success") - } - if p != 0 { - t.Fatalf("Peek got %v; want 0", p) - } - p, err, ok := sv.PeekErr() - if !ok { - t.Errorf("PeekErr ok=false; want true on error") - } - if got, want := fmt.Sprint(err), "boom"; got != want { - t.Errorf("PeekErr error=%v; want %v", got, want) - } - if p != 123 { - t.Fatalf("PeekErr got %v; want 123", p) - } -} - -func TestSyncValueConcurrent(t *testing.T) { - var ( - lt SyncValue[int] - wg sync.WaitGroup - start = make(chan struct{}) - routines = 10000 - ) - wg.Add(routines) - for range routines { - go func() { - defer wg.Done() - // Every goroutine waits for the go signal, so that more of them - // have a chance to race on the initial Get than with sequential - // goroutine starts. - <-start - got := lt.Get(fortyTwo) - if got != 42 { - t.Errorf("got %v; want 42", got) - } - }() - } - close(start) - wg.Wait() -} - -func TestSyncValueSetForTest(t *testing.T) { - testErr := errors.New("boom") - tests := []struct { - name string - initValue opt.Value[int] - initErr opt.Value[error] - setForTestValue int - setForTestErr error - getValue int - getErr opt.Value[error] - wantValue int - wantErr error - routines int - }{ - { - name: "GetOk", - setForTestValue: 42, - getValue: 8, - wantValue: 42, - }, - { - name: "GetOk/WithInit", - initValue: opt.ValueOf(4), - setForTestValue: 42, - getValue: 8, - wantValue: 42, - }, - { - name: "GetOk/WithInitErr", - initValue: opt.ValueOf(4), - initErr: opt.ValueOf(errors.New("blast")), - setForTestValue: 42, - getValue: 8, - wantValue: 42, - }, - { - name: "GetErr", - setForTestValue: 42, - setForTestErr: testErr, - getValue: 8, - getErr: opt.ValueOf(errors.New("ka-boom")), - wantValue: 42, - wantErr: testErr, - }, - { - name: "GetErr/NilError", - setForTestValue: 42, - setForTestErr: nil, - getValue: 8, - getErr: opt.ValueOf(errors.New("ka-boom")), - wantValue: 42, - wantErr: nil, - }, - { - name: "GetErr/WithInitErr", - initValue: opt.ValueOf(4), - initErr: opt.ValueOf(errors.New("blast")), - setForTestValue: 42, - setForTestErr: testErr, - getValue: 8, - getErr: opt.ValueOf(errors.New("ka-boom")), - wantValue: 42, - wantErr: testErr, - }, - { - name: "Concurrent/GetOk", - setForTestValue: 42, - getValue: 8, - wantValue: 42, - routines: 10000, - }, - { - name: "Concurrent/GetOk/WithInitErr", - initValue: opt.ValueOf(4), - initErr: opt.ValueOf(errors.New("blast")), - setForTestValue: 42, - getValue: 8, - wantValue: 42, - routines: 10000, - }, - { - name: "Concurrent/GetErr", - setForTestValue: 42, - setForTestErr: testErr, - getValue: 8, - getErr: opt.ValueOf(errors.New("ka-boom")), - wantValue: 42, - wantErr: testErr, - routines: 10000, - }, - { - name: "Concurrent/GetErr/WithInitErr", - initValue: opt.ValueOf(4), - initErr: opt.ValueOf(errors.New("blast")), - setForTestValue: 42, - setForTestErr: testErr, - getValue: 8, - getErr: opt.ValueOf(errors.New("ka-boom")), - wantValue: 42, - wantErr: testErr, - routines: 10000, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var v SyncValue[int] - - // Initialize the sync value with the specified value and/or error, - // if required by the test. - if initValue, ok := tt.initValue.GetOk(); ok { - var wantInitErr, gotInitErr error - var wantInitValue, gotInitValue int - wantInitValue = initValue - if initErr, ok := tt.initErr.GetOk(); ok { - wantInitErr = initErr - gotInitValue, gotInitErr = v.GetErr(func() (int, error) { return initValue, initErr }) - } else { - gotInitValue = v.Get(func() int { return initValue }) - } - - if gotInitErr != wantInitErr { - t.Fatalf("InitErr: got %v; want %v", gotInitErr, wantInitErr) - } - if gotInitValue != wantInitValue { - t.Fatalf("InitValue: got %v; want %v", gotInitValue, wantInitValue) - } - - // Verify that SetForTest reverted the error and the value during the test cleanup. - t.Cleanup(func() { - wantCleanupValue, wantCleanupErr := wantInitValue, wantInitErr - gotCleanupValue, gotCleanupErr, ok := v.PeekErr() - if !ok { - t.Fatal("SyncValue is not set after cleanup") - } - if gotCleanupErr != wantCleanupErr { - t.Fatalf("CleanupErr: got %v; want %v", gotCleanupErr, wantCleanupErr) - } - if gotCleanupValue != wantCleanupValue { - t.Fatalf("CleanupValue: got %v; want %v", gotCleanupValue, wantCleanupValue) - } - }) - } else { - // Verify that if v wasn't set prior to SetForTest, it's - // reverted to a valid unset state during the test cleanup. - t.Cleanup(func() { - if _, _, ok := v.PeekErr(); ok { - t.Fatal("SyncValue is set after cleanup") - } - wantCleanupValue, wantCleanupErr := 42, errors.New("ka-boom") - gotCleanupValue, gotCleanupErr := v.GetErr(func() (int, error) { return wantCleanupValue, wantCleanupErr }) - if gotCleanupErr != wantCleanupErr { - t.Fatalf("CleanupErr: got %v; want %v", gotCleanupErr, wantCleanupErr) - } - if gotCleanupValue != wantCleanupValue { - t.Fatalf("CleanupValue: got %v; want %v", gotCleanupValue, wantCleanupValue) - } - }) - } - - // Set the test value and/or error. - v.SetForTest(t, tt.setForTestValue, tt.setForTestErr) - - // Verify that the value and/or error have been set. - // This will run on either the current goroutine - // or concurrently depending on the tt.routines value. - checkSyncValue := func() { - var gotValue int - var gotErr error - if getErr, ok := tt.getErr.GetOk(); ok { - gotValue, gotErr = v.GetErr(func() (int, error) { return tt.getValue, getErr }) - } else { - gotValue = v.Get(func() int { return tt.getValue }) - } - - if gotErr != tt.wantErr { - t.Errorf("Err: got %v; want %v", gotErr, tt.wantErr) - } - if gotValue != tt.wantValue { - t.Errorf("Value: got %v; want %v", gotValue, tt.wantValue) - } - } - - switch tt.routines { - case 0: - checkSyncValue() - default: - var wg sync.WaitGroup - wg.Add(tt.routines) - start := make(chan struct{}) - for range tt.routines { - go func() { - defer wg.Done() - // Every goroutine waits for the go signal, so that more of them - // have a chance to race on the initial Get than with sequential - // goroutine starts. - <-start - checkSyncValue() - }() - } - close(start) - wg.Wait() - } - }) - } -} - -func TestSyncFunc(t *testing.T) { - f := SyncFunc(fortyTwo) - - n := int(testing.AllocsPerRun(1000, func() { - got := f() - if got != 42 { - t.Fatalf("got %v; want 42", got) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestSyncFuncErr(t *testing.T) { - f := SyncFuncErr(func() (int, error) { - return 42, nil - }) - n := int(testing.AllocsPerRun(1000, func() { - got, err := f() - if got != 42 || err != nil { - t.Fatalf("got %v, %v; want 42, nil", got, err) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } - - wantErr := errors.New("test error") - f = SyncFuncErr(func() (int, error) { - return 0, wantErr - }) - n = int(testing.AllocsPerRun(1000, func() { - got, err := f() - if got != 0 || err != wantErr { - t.Fatalf("got %v, %v; want 0, %v", got, err, wantErr) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} diff --git a/types/lazy/unsync_test.go b/types/lazy/unsync_test.go deleted file mode 100644 index f0d2494d12b6e..0000000000000 --- a/types/lazy/unsync_test.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package lazy - -import ( - "errors" - "testing" -) - -func fortyTwo() int { return 42 } - -func TestGValue(t *testing.T) { - var lt GValue[int] - n := int(testing.AllocsPerRun(1000, func() { - got := lt.Get(fortyTwo) - if got != 42 { - t.Fatalf("got %v; want 42", got) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestGValueErr(t *testing.T) { - var lt GValue[int] - n := int(testing.AllocsPerRun(1000, func() { - got, err := lt.GetErr(func() (int, error) { - return 42, nil - }) - if got != 42 || err != nil { - t.Fatalf("got %v, %v; want 42, nil", got, err) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } - - var lterr GValue[int] - wantErr := errors.New("test error") - n = int(testing.AllocsPerRun(1000, func() { - got, err := lterr.GetErr(func() (int, error) { - return 0, wantErr - }) - if got != 0 || err != wantErr { - t.Fatalf("got %v, %v; want 0, %v", got, err, wantErr) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestGValueSet(t *testing.T) { - var lt GValue[int] - if !lt.Set(42) { - t.Fatalf("Set failed") - } - if lt.Set(43) { - t.Fatalf("Set succeeded after first Set") - } - n := int(testing.AllocsPerRun(1000, func() { - got := lt.Get(fortyTwo) - if got != 42 { - t.Fatalf("got %v; want 42", got) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestGValueMustSet(t *testing.T) { - var lt GValue[int] - lt.MustSet(42) - defer func() { - if e := recover(); e == nil { - t.Errorf("unexpected success; want panic") - } - }() - lt.MustSet(43) -} - -func TestGValueRecursivePanic(t *testing.T) { - defer func() { - if e := recover(); e != nil { - t.Logf("got panic, as expected") - } else { - t.Errorf("unexpected success; want panic") - } - }() - v := GValue[int]{} - v.Get(func() int { - return v.Get(func() int { return 42 }) - }) -} - -func TestGFunc(t *testing.T) { - f := GFunc(fortyTwo) - - n := int(testing.AllocsPerRun(1000, func() { - got := f() - if got != 42 { - t.Fatalf("got %v; want 42", got) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} - -func TestGFuncErr(t *testing.T) { - f := GFuncErr(func() (int, error) { - return 42, nil - }) - n := int(testing.AllocsPerRun(1000, func() { - got, err := f() - if got != 42 || err != nil { - t.Fatalf("got %v, %v; want 42, nil", got, err) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } - - wantErr := errors.New("test error") - f = GFuncErr(func() (int, error) { - return 0, wantErr - }) - n = int(testing.AllocsPerRun(1000, func() { - got, err := f() - if got != 0 || err != wantErr { - t.Fatalf("got %v, %v; want 0, %v", got, err, wantErr) - } - })) - if n != 0 { - t.Errorf("allocs = %v; want 0", n) - } -} diff --git a/types/logger/logger.go b/types/logger/logger.go index 11596b357cabb..2ae285cf16d2c 100644 --- a/types/logger/logger.go +++ b/types/logger/logger.go @@ -10,6 +10,7 @@ import ( "bufio" "bytes" "container/list" + "context" "encoding/json" "fmt" "io" @@ -18,11 +19,9 @@ import ( "sync" "time" - "context" - + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/util/ctxkey" "go4.org/mem" - "tailscale.com/envknob" - "tailscale.com/util/ctxkey" ) // Logf is the basic Tailscale logger type: a printf-like func. diff --git a/types/logger/logger_test.go b/types/logger/logger_test.go deleted file mode 100644 index 52c1d3900e1c5..0000000000000 --- a/types/logger/logger_test.go +++ /dev/null @@ -1,280 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package logger - -import ( - "bufio" - "bytes" - "context" - "fmt" - "log" - "sync" - "testing" - "time" - - qt "github.com/frankban/quicktest" - "tailscale.com/tailcfg" - "tailscale.com/version" -) - -func TestFuncWriter(t *testing.T) { - w := FuncWriter(t.Logf) - lg := log.New(w, "prefix: ", 0) - lg.Printf("plumbed through") -} - -func TestStdLogger(t *testing.T) { - lg := StdLogger(t.Logf) - lg.Printf("plumbed through") -} - -func logTester(want []string, t *testing.T, i *int) Logf { - return func(format string, args ...any) { - got := fmt.Sprintf(format, args...) - if *i >= len(want) { - t.Fatalf("Logging continued past end of expected input: %s", got) - } - if got != want[*i] { - t.Fatalf("\nwanted: %s\n got: %s", want[*i], got) - } - t.Log(got) - *i++ - } -} - -func TestRateLimiter(t *testing.T) { - want := []string{ - "boring string with constant formatting (constant)", - "templated format string no. 0", - "boring string with constant formatting (constant)", - "[RATELIMIT] format(\"boring string with constant formatting %s\")", - "templated format string no. 1", - "[RATELIMIT] format(\"templated format string no. %d\")", - "Make sure this string makes it through the rest (that are blocked) 4", - "4 shouldn't get filtered.", - "hello 1", - "hello 2", - "[RATELIMIT] format(\"hello %v\")", - "[RATELIMIT] format(\"hello %v\") (2 dropped)", - "hello 5", - "hello 6", - "[RATELIMIT] format(\"hello %v\")", - "hello 7", - } - - var now time.Time - nowf := func() time.Time { return now } - - testsRun := 0 - lgtest := logTester(want, t, &testsRun) - lg := RateLimitedFnWithClock(lgtest, 1*time.Minute, 2, 50, nowf) - var prefixed Logf - for i := range 10 { - lg("boring string with constant formatting %s", "(constant)") - lg("templated format string no. %d", i) - if i == 4 { - lg("Make sure this string makes it through the rest (that are blocked) %d", i) - prefixed = WithPrefix(lg, string(rune('0'+i))) - prefixed(" shouldn't get filtered.") - } - } - - lg("hello %v", 1) - lg("hello %v", 2) // printed, but rate limit starts - lg("hello %v", 3) // rate limited (not printed) - now = now.Add(1 * time.Minute) - lg("hello %v", 4) // still limited (not printed) - now = now.Add(1 * time.Minute) - lg("hello %v", 5) // restriction lifted; prints drop count + message - - lg("hello %v", 6) // printed, but rate limit starts - now = now.Add(2 * time.Minute) - lg("hello %v", 7) // restriction lifted; no drop count needed - - if testsRun < len(want) { - t.Fatalf("Tests after %s weren't logged.", want[testsRun]) - } - -} - -func testTimer(d time.Duration) func() time.Time { - timeNow := time.Now() - return func() time.Time { - timeNow = timeNow.Add(d) - return timeNow - } -} - -func TestLogOnChange(t *testing.T) { - want := []string{ - "1 2 3 4 5 6", - "1 2 3 4 5 6", - "1 2 3 4 5 7", - "1 2 3 4 5", - "1 2 3 4 5 6 7", - } - - timeNow := testTimer(1 * time.Second) - - testsRun := 0 - lgtest := logTester(want, t, &testsRun) - lg := LogOnChange(lgtest, 5*time.Second, timeNow) - - for range 10 { - lg("%s", "1 2 3 4 5 6") - } - lg("1 2 3 4 5 7") - lg("1 2 3 4 5") - lg("1 2 3 4 5") - lg("1 2 3 4 5 6 7") - - if testsRun < len(want) { - t.Fatalf("'Wanted' lines including and after [%s] weren't logged.", want[testsRun]) - } -} - -func TestArgWriter(t *testing.T) { - got := new(bytes.Buffer) - fmt.Fprintf(got, "Greeting: %v", ArgWriter(func(bw *bufio.Writer) { - bw.WriteString("Hello, ") - bw.WriteString("world") - bw.WriteByte('!') - })) - const want = "Greeting: Hello, world!" - if got.String() != want { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestSynchronization(t *testing.T) { - timeNow := testTimer(1 * time.Second) - tests := []struct { - name string - logf Logf - }{ - {"RateLimitedFn", RateLimitedFn(t.Logf, 1*time.Minute, 2, 50)}, - {"LogOnChange", LogOnChange(t.Logf, 5*time.Second, timeNow)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var wg sync.WaitGroup - wg.Add(2) - - f := func() { - tt.logf("1 2 3 4 5") - wg.Done() - } - - go f() - go f() - - wg.Wait() - }) - } -} - -// test that RateLimitedFn is safe for reentrancy without deadlocking -func TestRateLimitedFnReentrancy(t *testing.T) { - rlogf := RateLimitedFn(t.Logf, time.Nanosecond, 10, 10) - rlogf("Hello.") - rlogf("Hello, %v", ArgWriter(func(bw *bufio.Writer) { - bw.WriteString("world") - })) - rlogf("Hello, %v", ArgWriter(func(bw *bufio.Writer) { - bw.WriteString("bye") - rlogf("boom") // this used to deadlock - })) -} - -func TestContext(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - // Test that FromContext returns log.Printf when the context has no custom log function. - defer log.SetOutput(log.Writer()) - defer log.SetFlags(log.Flags()) - var buf bytes.Buffer - log.SetOutput(&buf) - log.SetFlags(0) - logf := FromContext(ctx) - logf("a") - c.Assert(buf.String(), qt.Equals, "a\n") - - // Test that FromContext and Ctx work together. - var called bool - markCalled := func(string, ...any) { - called = true - } - ctx = Ctx(ctx, markCalled) - logf = FromContext(ctx) - logf("a") - c.Assert(called, qt.IsTrue) -} - -func TestJSON(t *testing.T) { - var buf bytes.Buffer - var logf Logf = func(f string, a ...any) { fmt.Fprintf(&buf, f, a...) } - logf.JSON(1, "foo", &tailcfg.Hostinfo{}) - want := "[v\x00JSON]1" + `{"foo":{}}` - if got := buf.String(); got != want { - t.Errorf("mismatch\n got: %q\nwant: %q\n", got, want) - } -} - -func TestAsJSON(t *testing.T) { - got := fmt.Sprintf("got %v", AsJSON(struct { - Foo string - Bar int - }{"hi", 123})) - const want = `got {"Foo":"hi","Bar":123}` - if got != want { - t.Errorf("got %#q; want %#q", got, want) - } - - got = fmt.Sprintf("got %v", AsJSON(func() {})) - const wantErr = `got %!JSON-ERROR:json: unsupported type: func()` - if got != wantErr { - t.Errorf("for marshal error, got %#q; want %#q", got, wantErr) - } - - if version.IsRace() { - // skip the rest of the test in race mode; - // race mode causes more allocs which we don't care about. - return - } - - var buf bytes.Buffer - n := int(testing.AllocsPerRun(1000, func() { - buf.Reset() - fmt.Fprintf(&buf, "got %v", AsJSON("hi")) - })) - if n > 2 { - // the JSON AsMarshal itself + boxing - // the asJSONResult into an interface (which needs - // to happen at some point to get to fmt, so might - // as well return an interface from AsJSON)) - t.Errorf("allocs = %v; want max 2", n) - } -} - -func TestHTTPServerLogFilter(t *testing.T) { - var buf bytes.Buffer - logf := func(format string, args ...any) { - t.Logf("[logf] "+format, args...) - fmt.Fprintf(&buf, format, args...) - } - - lf := HTTPServerLogFilter{logf} - quietLogger := log.New(lf, "", 0) - - quietLogger.Printf("foo bar") - quietLogger.Printf("http: TLS handshake error from %s:%d: EOF", "1.2.3.4", 9999) - quietLogger.Printf("baz") - - const want = "foo bar\nbaz\n" - if s := buf.String(); s != want { - t.Errorf("got buf=%q, want %q", s, want) - } -} diff --git a/types/logid/id_test.go b/types/logid/id_test.go deleted file mode 100644 index c93d1f1c1adc0..0000000000000 --- a/types/logid/id_test.go +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package logid - -import ( - "math" - "testing" - - "tailscale.com/tstest" - "tailscale.com/util/must" -) - -func TestIDs(t *testing.T) { - id1, err := NewPrivateID() - if err != nil { - t.Fatal(err) - } - pub1 := id1.Public() - - id2, err := NewPrivateID() - if err != nil { - t.Fatal(err) - } - pub2 := id2.Public() - - if id1 == id2 { - t.Fatalf("subsequent private IDs match: %v", id1) - } - if pub1 == pub2 { - t.Fatalf("subsequent public IDs match: %v", id1) - } - if id1.String() == id2.String() { - t.Fatalf("id1.String()=%v equals id2.String()", id1.String()) - } - if pub1.String() == pub2.String() { - t.Fatalf("pub1.String()=%v equals pub2.String()", pub1.String()) - } - - id1txt, err := id1.MarshalText() - if err != nil { - t.Fatal(err) - } - var id3 PrivateID - if err := id3.UnmarshalText(id1txt); err != nil { - t.Fatal(err) - } - if id1 != id3 { - t.Fatalf("id1 %v: marshal and unmarshal gives different key: %v", id1, id3) - } - if want, got := id1.Public(), id3.Public(); want != got { - t.Fatalf("id1.Public()=%v does not match id3.Public()=%v", want, got) - } - if id1.String() != id3.String() { - t.Fatalf("id1.String()=%v does not match id3.String()=%v", id1.String(), id3.String()) - } - if id3, err := ParsePublicID(id1.Public().String()); err != nil { - t.Errorf("ParsePublicID: %v", err) - } else if id1.Public() != id3 { - t.Errorf("ParsePublicID mismatch") - } - - id4, err := ParsePrivateID(id1.String()) - if err != nil { - t.Fatalf("failed to ParsePrivateID(%q): %v", id1.String(), err) - } - if id1 != id4 { - t.Fatalf("ParsePrivateID returned different id") - } - - hexString := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - hexBytes := []byte(hexString) - if err := tstest.MinAllocsPerRun(t, 0, func() { - ParsePrivateID(hexString) - new(PrivateID).UnmarshalText(hexBytes) - ParsePublicID(hexString) - new(PublicID).UnmarshalText(hexBytes) - }); err != nil { - t.Fatal(err) - } -} - -func TestAdd(t *testing.T) { - tests := []struct { - in string - add int64 - want string - }{{ - in: "0000000000000000000000000000000000000000000000000000000000000000", - add: 0, - want: "0000000000000000000000000000000000000000000000000000000000000000", - }, { - in: "0000000000000000000000000000000000000000000000000000000000000000", - add: 1, - want: "0000000000000000000000000000000000000000000000000000000000000001", - }, { - in: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - add: 1, - want: "0000000000000000000000000000000000000000000000000000000000000000", - }, { - in: "0000000000000000000000000000000000000000000000000000000000000000", - add: -1, - want: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - }, { - in: "0000000000000000000000000000000000000000000000000000000000000000", - add: math.MinInt64, - want: "ffffffffffffffffffffffffffffffffffffffffffffffff8000000000000000", - }, { - in: "000000000000000000000000000000000000000000000000ffffffffffffffff", - add: math.MinInt64, - want: "0000000000000000000000000000000000000000000000007fffffffffffffff", - }, { - in: "0000000000000000000000000000000000000000000000000000000000000000", - add: math.MaxInt64, - want: "0000000000000000000000000000000000000000000000007fffffffffffffff", - }, { - in: "0000000000000000000000000000000000000000000000007fffffffffffffff", - add: math.MaxInt64, - want: "000000000000000000000000000000000000000000000000fffffffffffffffe", - }, { - in: "000000000000000000000000000000000000000000000000ffffffffffffffff", - add: 1, - want: "0000000000000000000000000000000000000000000000010000000000000000", - }, { - in: "00000000000000000000000000000000fffffffffffffffffffffffffffffffe", - add: 3, - want: "0000000000000000000000000000000100000000000000000000000000000001", - }, { - in: "0000000000000000fffffffffffffffffffffffffffffffffffffffffffffffd", - add: 5, - want: "0000000000000001000000000000000000000000000000000000000000000002", - }, { - in: "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc", - add: 7, - want: "0000000000000000000000000000000000000000000000000000000000000003", - }, { - in: "ffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000", - add: -1, - want: "fffffffffffffffffffffffffffffffffffffffffffffffeffffffffffffffff", - }, { - in: "ffffffffffffffffffffffffffffffff00000000000000000000000000000001", - add: -3, - want: "fffffffffffffffffffffffffffffffefffffffffffffffffffffffffffffffe", - }, { - in: "ffffffffffffffff000000000000000000000000000000000000000000000002", - add: -5, - want: "fffffffffffffffefffffffffffffffffffffffffffffffffffffffffffffffd", - }, { - in: "0000000000000000000000000000000000000000000000000000000000000003", - add: -7, - want: "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc", - }} - for _, tt := range tests { - in := must.Get(ParsePublicID(tt.in)) - want := must.Get(ParsePublicID(tt.want)) - got := in.Add(tt.add) - if got != want { - t.Errorf("%s.Add(%d):\n\tgot %s\n\twant %s", in, tt.add, got, want) - } - if tt.add != math.MinInt64 { - got = got.Add(-tt.add) - if got != in { - t.Errorf("%s.Add(%d):\n\tgot %s\n\twant %s", want, -tt.add, got, in) - } - } - } -} diff --git a/types/netlogtype/netlogtype.go b/types/netlogtype/netlogtype.go index f2fa2bda92366..5aafca4076fad 100644 --- a/types/netlogtype/netlogtype.go +++ b/types/netlogtype/netlogtype.go @@ -8,8 +8,8 @@ import ( "net/netip" "time" - "tailscale.com/tailcfg" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/ipproto" ) // TODO(joetsai): Remove "omitempty" if "omitzero" is ever supported in both diff --git a/types/netlogtype/netlogtype_test.go b/types/netlogtype/netlogtype_test.go deleted file mode 100644 index 7f29090c5f757..0000000000000 --- a/types/netlogtype/netlogtype_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netlogtype - -import ( - "encoding/json" - "math" - "net/netip" - "testing" - - "github.com/fxamacker/cbor/v2" - "github.com/google/go-cmp/cmp" - "tailscale.com/util/must" -) - -func TestMaxSize(t *testing.T) { - maxAddr := netip.AddrFrom16([16]byte{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}) - maxAddrPort := netip.AddrPortFrom(maxAddr, math.MaxUint16) - cc := ConnectionCounts{ - // NOTE: These composite literals are deliberately unkeyed so that - // added fields result in a build failure here. - // Newly added fields should result in an update to both - // MaxConnectionCountsJSONSize and MaxConnectionCountsCBORSize. - Connection{math.MaxUint8, maxAddrPort, maxAddrPort}, - Counts{math.MaxUint64, math.MaxUint64, math.MaxUint64, math.MaxUint64}, - } - - outJSON := must.Get(json.Marshal(cc)) - if string(outJSON) != maxJSONConnCounts { - t.Errorf("JSON mismatch (-got +want):\n%s", cmp.Diff(string(outJSON), maxJSONConnCounts)) - } - - outCBOR := must.Get(cbor.Marshal(cc)) - maxCBORConnCountsAlt := "\xa7" + maxCBORConnCounts[1:len(maxCBORConnCounts)-1] // may use a definite encoding of map - if string(outCBOR) != maxCBORConnCounts && string(outCBOR) != maxCBORConnCountsAlt { - t.Errorf("CBOR mismatch (-got +want):\n%s", cmp.Diff(string(outCBOR), maxCBORConnCounts)) - } -} diff --git a/types/netmap/netmap.go b/types/netmap/netmap.go index 94e872a5593ea..0579ffca4dcf7 100644 --- a/types/netmap/netmap.go +++ b/types/netmap/netmap.go @@ -13,12 +13,12 @@ import ( "strings" "time" - "tailscale.com/tailcfg" - "tailscale.com/tka" - "tailscale.com/types/key" - "tailscale.com/types/views" - "tailscale.com/util/set" - "tailscale.com/wgengine/filter/filtertype" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tka" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/wgengine/filter/filtertype" ) // NetworkMap is the current state of the world. diff --git a/types/netmap/netmap_test.go b/types/netmap/netmap_test.go deleted file mode 100644 index e7e2d19575c44..0000000000000 --- a/types/netmap/netmap_test.go +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmap - -import ( - "encoding/hex" - "net/netip" - "testing" - - "go4.org/mem" - "tailscale.com/net/netaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -func testNodeKey(b byte) (ret key.NodePublic) { - var bs [key.NodePublicRawLen]byte - for i := range bs { - bs[i] = b - } - return key.NodePublicFromRaw32(mem.B(bs[:])) -} - -func testDiscoKey(hexPrefix string) (ret key.DiscoPublic) { - b, err := hex.DecodeString(hexPrefix) - if err != nil { - panic(err) - } - // this function is used with short hexes, so zero-extend the raw - // value. - var bs [32]byte - copy(bs[:], b) - return key.DiscoPublicFromRaw32(mem.B(bs[:])) -} - -func nodeViews(v []*tailcfg.Node) []tailcfg.NodeView { - nv := make([]tailcfg.NodeView, len(v)) - for i, n := range v { - nv[i] = n.View() - } - return nv -} - -func eps(s ...string) []netip.AddrPort { - var eps []netip.AddrPort - for _, ep := range s { - eps = append(eps, netip.MustParseAddrPort(ep)) - } - return eps -} - -func TestNetworkMapConcise(t *testing.T) { - for _, tt := range []struct { - name string - nm *NetworkMap - want string - }{ - { - name: "basic", - nm: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - { - Key: testNodeKey(3), - DERP: "127.3.3.40:4", - Endpoints: eps("10.2.0.100:12", "10.1.0.100:12345"), - }, - }), - }, - want: "netmap: self: [AQEBA] auth=machine-unknown u=? []\n [AgICA] D2 : 192.168.0.100:12 192.168.0.100:12354\n [AwMDA] D4 : 10.2.0.100:12 10.1.0.100:12345\n", - }, - } { - t.Run(tt.name, func(t *testing.T) { - var got string - n := int(testing.AllocsPerRun(1000, func() { - got = tt.nm.Concise() - })) - t.Logf("Allocs = %d", n) - if got != tt.want { - t.Errorf("Wrong output\n Got: %q\nWant: %q\n## Got (unescaped):\n%s\n## Want (unescaped):\n%s\n", got, tt.want, got, tt.want) - } - }) - } -} - -func TestConciseDiffFrom(t *testing.T) { - for _, tt := range []struct { - name string - a, b *NetworkMap - want string - }{ - { - name: "no_change", - a: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - b: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - want: "", - }, - { - name: "header_change", - a: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - b: &NetworkMap{ - NodeKey: testNodeKey(2), - Peers: nodeViews([]*tailcfg.Node{ - { - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - want: "-netmap: self: [AQEBA] auth=machine-unknown u=? []\n+netmap: self: [AgICA] auth=machine-unknown u=? []\n", - }, - { - name: "peer_add", - a: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - b: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Key: testNodeKey(1), - DERP: "127.3.3.40:1", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - { - ID: 3, - Key: testNodeKey(3), - DERP: "127.3.3.40:3", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - want: "+ [AQEBA] D1 : 192.168.0.100:12 192.168.0.100:12354\n+ [AwMDA] D3 : 192.168.0.100:12 192.168.0.100:12354\n", - }, - { - name: "peer_remove", - a: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Key: testNodeKey(1), - DERP: "127.3.3.40:1", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - { - ID: 3, - Key: testNodeKey(3), - DERP: "127.3.3.40:3", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - b: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "192.168.0.100:12354"), - }, - }), - }, - want: "- [AQEBA] D1 : 192.168.0.100:12 192.168.0.100:12354\n- [AwMDA] D3 : 192.168.0.100:12 192.168.0.100:12354\n", - }, - { - name: "peer_port_change", - a: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "1.1.1.1:1"), - }, - }), - }, - b: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:12", "1.1.1.1:2"), - }, - }), - }, - want: "- [AgICA] D2 : 192.168.0.100:12 1.1.1.1:1 \n+ [AgICA] D2 : 192.168.0.100:12 1.1.1.1:2 \n", - }, - { - name: "disco_key_only_change", - a: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:41641", "1.1.1.1:41641"), - DiscoKey: testDiscoKey("f00f00f00f"), - AllowedIPs: []netip.Prefix{netip.PrefixFrom(netaddr.IPv4(100, 102, 103, 104), 32)}, - }, - }), - }, - b: &NetworkMap{ - NodeKey: testNodeKey(1), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 2, - Key: testNodeKey(2), - DERP: "127.3.3.40:2", - Endpoints: eps("192.168.0.100:41641", "1.1.1.1:41641"), - DiscoKey: testDiscoKey("ba4ba4ba4b"), - AllowedIPs: []netip.Prefix{netip.PrefixFrom(netaddr.IPv4(100, 102, 103, 104), 32)}, - }, - }), - }, - want: "- [AgICA] d:f00f00f00f000000 D2 100.102.103.104 : 192.168.0.100:41641 1.1.1.1:41641\n+ [AgICA] d:ba4ba4ba4b000000 D2 100.102.103.104 : 192.168.0.100:41641 1.1.1.1:41641\n", - }, - } { - t.Run(tt.name, func(t *testing.T) { - var got string - n := int(testing.AllocsPerRun(50, func() { - got = tt.b.ConciseDiffFrom(tt.a) - })) - t.Logf("Allocs = %d", n) - if got != tt.want { - t.Errorf("Wrong output\n Got: %q\nWant: %q\n## Got (unescaped):\n%s\n## Want (unescaped):\n%s\n", got, tt.want, got, tt.want) - } - }) - } -} - -func TestPeerIndexByNodeID(t *testing.T) { - var nilPtr *NetworkMap - if nilPtr.PeerIndexByNodeID(123) != -1 { - t.Errorf("nil PeerIndexByNodeID should return -1") - } - var nm NetworkMap - const min = 2 - const max = 10000 - const hole = max / 2 - for nid := tailcfg.NodeID(2); nid <= max; nid++ { - if nid == hole { - continue - } - nm.Peers = append(nm.Peers, (&tailcfg.Node{ID: nid}).View()) - } - for want, nv := range nm.Peers { - got := nm.PeerIndexByNodeID(nv.ID()) - if got != want { - t.Errorf("PeerIndexByNodeID(%v) = %v; want %v", nv.ID(), got, want) - } - } - for _, miss := range []tailcfg.NodeID{min - 1, hole, max + 1} { - if got := nm.PeerIndexByNodeID(miss); got != -1 { - t.Errorf("PeerIndexByNodeID(%v) = %v; want -1", miss, got) - } - } -} diff --git a/types/netmap/nodemut.go b/types/netmap/nodemut.go index 46fbaefc640e0..742ff0ad44ba7 100644 --- a/types/netmap/nodemut.go +++ b/types/netmap/nodemut.go @@ -12,8 +12,8 @@ import ( "sync" "time" - "tailscale.com/tailcfg" - "tailscale.com/types/ptr" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/ptr" ) // NodeMutation is the common interface for types that describe diff --git a/types/netmap/nodemut_test.go b/types/netmap/nodemut_test.go deleted file mode 100644 index 374f8623ad564..0000000000000 --- a/types/netmap/nodemut_test.go +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netmap - -import ( - "fmt" - "net/netip" - "reflect" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" -) - -// tests mapResponseContainsNonPatchFields -func TestMapResponseContainsNonPatchFields(t *testing.T) { - - // reflectNonzero returns a non-zero value of the given type. - reflectNonzero := func(t reflect.Type) reflect.Value { - - switch t.Kind() { - case reflect.Bool: - return reflect.ValueOf(true) - case reflect.String: - if reflect.TypeFor[opt.Bool]() == t { - return reflect.ValueOf("true").Convert(t) - } - return reflect.ValueOf("foo").Convert(t) - case reflect.Int64: - return reflect.ValueOf(int64(1)).Convert(t) - case reflect.Slice: - return reflect.MakeSlice(t, 1, 1) - case reflect.Ptr: - return reflect.New(t.Elem()) - case reflect.Map: - return reflect.MakeMap(t) - } - panic(fmt.Sprintf("unhandled %v", t)) - } - - rt := reflect.TypeFor[tailcfg.MapResponse]() - for i := range rt.NumField() { - f := rt.Field(i) - - var want bool - switch f.Name { - case "MapSessionHandle", "Seq", "KeepAlive", "PingRequest", "PopBrowserURL", "ControlTime": - // There are meta fields that apply to all MapResponse values. - // They should be ignored. - want = false - case "PeersChangedPatch", "PeerSeenChange", "OnlineChange": - // The actual three delta fields we care about handling. - want = false - default: - // Everything else should be conseratively handled as a - // non-delta field. We want it to return true so if - // the field is not listed in the function being tested, - // it'll return false and we'll fail this test. - // This makes sure any new fields added to MapResponse - // are accounted for here. - want = true - } - - var v tailcfg.MapResponse - rv := reflect.ValueOf(&v).Elem() - rv.FieldByName(f.Name).Set(reflectNonzero(f.Type)) - - got := mapResponseContainsNonPatchFields(&v) - if got != want { - t.Errorf("field %q: got %v; want %v\nJSON: %v", f.Name, got, want, logger.AsJSON(v)) - } - } -} - -// tests MutationsFromMapResponse -func TestMutationsFromMapResponse(t *testing.T) { - someTime := time.Unix(123, 0) - fromChanges := func(changes ...*tailcfg.PeerChange) *tailcfg.MapResponse { - return &tailcfg.MapResponse{ - PeersChangedPatch: changes, - } - } - muts := func(muts ...NodeMutation) []NodeMutation { return muts } - tests := []struct { - name string - mr *tailcfg.MapResponse - want []NodeMutation // nil means !ok, zero-length means none - }{ - { - name: "patch-ep", - mr: fromChanges(&tailcfg.PeerChange{ - NodeID: 1, - Endpoints: eps("1.2.3.4:567"), - }, &tailcfg.PeerChange{ - NodeID: 2, - Endpoints: eps("8.9.10.11:1234"), - }), - want: muts( - NodeMutationEndpoints{1, []netip.AddrPort{netip.MustParseAddrPort("1.2.3.4:567")}}, - NodeMutationEndpoints{2, []netip.AddrPort{netip.MustParseAddrPort("8.9.10.11:1234")}}, - ), - }, - { - name: "patch-derp", - mr: fromChanges(&tailcfg.PeerChange{ - NodeID: 1, - DERPRegion: 2, - }), - want: muts(NodeMutationDERPHome{1, 2}), - }, - { - name: "patch-online", - mr: fromChanges(&tailcfg.PeerChange{ - NodeID: 1, - Online: ptr.To(true), - }), - want: muts(NodeMutationOnline{1, true}), - }, - { - name: "patch-online-false", - mr: fromChanges(&tailcfg.PeerChange{ - NodeID: 1, - Online: ptr.To(false), - }), - want: muts(NodeMutationOnline{1, false}), - }, - { - name: "patch-lastseen", - mr: fromChanges(&tailcfg.PeerChange{ - NodeID: 1, - LastSeen: ptr.To(time.Unix(12345, 0)), - }), - want: muts(NodeMutationLastSeen{1, time.Unix(12345, 0)}), - }, - { - name: "legacy-online-change", // the old pre-Patch style - mr: &tailcfg.MapResponse{ - OnlineChange: map[tailcfg.NodeID]bool{ - 1: true, - 2: false, - }, - }, - want: muts( - NodeMutationOnline{1, true}, - NodeMutationOnline{2, false}, - ), - }, - { - name: "legacy-lastseen-change", // the old pre-Patch style - mr: &tailcfg.MapResponse{ - PeerSeenChange: map[tailcfg.NodeID]bool{ - 1: true, - }, - }, - want: muts( - NodeMutationLastSeen{1, someTime}, - ), - }, - { - name: "no-changes", - mr: fromChanges(), - want: make([]NodeMutation, 0), // non-nil to mean want ok but no changes - }, - { - name: "not-okay-patch-node-change", - mr: &tailcfg.MapResponse{ - Node: &tailcfg.Node{}, // non-nil - PeersChangedPatch: []*tailcfg.PeerChange{{ - NodeID: 1, - DERPRegion: 2, - }}, - }, - want: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, gotOK := MutationsFromMapResponse(tt.mr, someTime) - wantOK := tt.want != nil - if gotOK != wantOK { - t.Errorf("got ok=%v; want %v", gotOK, wantOK) - } else if got == nil && gotOK { - got = make([]NodeMutation, 0) // for cmd.Diff - } - if diff := cmp.Diff(tt.want, got, - cmp.Comparer(func(a, b netip.Addr) bool { return a == b }), - cmp.Comparer(func(a, b netip.AddrPort) bool { return a == b }), - cmp.AllowUnexported( - NodeMutationEndpoints{}, - NodeMutationDERPHome{}, - NodeMutationOnline{}, - NodeMutationLastSeen{}, - )); diff != "" { - t.Errorf("wrong result (-want +got):\n%s", diff) - } - }) - } -} diff --git a/types/opt/bool_test.go b/types/opt/bool_test.go deleted file mode 100644 index dddbcfc195d04..0000000000000 --- a/types/opt/bool_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package opt - -import ( - "encoding/json" - "flag" - "reflect" - "strings" - "testing" -) - -func TestBool(t *testing.T) { - tests := []struct { - name string - in any - want string // JSON - wantBack any - }{ - { - name: "null_for_unset", - in: struct { - True Bool - False Bool - Unset Bool - }{ - True: "true", - False: "false", - }, - want: `{"True":true,"False":false,"Unset":null}`, - wantBack: struct { - True Bool - False Bool - Unset Bool - }{ - True: "true", - False: "false", - Unset: "unset", - }, - }, - { - name: "omitempty_unset", - in: struct { - True Bool - False Bool - Unset Bool `json:",omitempty"` - }{ - True: "true", - False: "false", - }, - want: `{"True":true,"False":false}`, - }, - { - name: "unset_marshals_as_null", - in: struct { - True Bool - False Bool - Foo Bool - }{ - True: "true", - False: "false", - Foo: "unset", - }, - want: `{"True":true,"False":false,"Foo":null}`, - wantBack: struct { - True Bool - False Bool - Foo Bool - }{"true", "false", "unset"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - j, err := json.Marshal(tt.in) - if err != nil { - t.Fatal(err) - } - if string(j) != tt.want { - t.Errorf("wrong JSON:\n got: %s\nwant: %s\n", j, tt.want) - } - - wantBack := tt.in - if tt.wantBack != nil { - wantBack = tt.wantBack - } - // And back again: - newVal := reflect.New(reflect.TypeOf(tt.in)) - out := newVal.Interface() - if err := json.Unmarshal(j, out); err != nil { - t.Fatalf("Unmarshal %#q: %v", j, err) - } - got := newVal.Elem().Interface() - if !reflect.DeepEqual(got, wantBack) { - t.Errorf("value mismatch\n got: %+v\nwant: %+v\n", got, wantBack) - } - }) - } -} - -func TestBoolEqualBool(t *testing.T) { - tests := []struct { - b Bool - v bool - want bool - }{ - {"", true, false}, - {"", false, false}, - {"sdflk;", true, false}, - {"sldkf;", false, false}, - {"true", true, true}, - {"true", false, false}, - {"false", true, false}, - {"false", false, true}, - {"1", true, false}, // "1" is not true; only "true" is - {"True", true, false}, // "True" is not true; only "true" is - } - for _, tt := range tests { - if got := tt.b.EqualBool(tt.v); got != tt.want { - t.Errorf("(%q).EqualBool(%v) = %v; want %v", string(tt.b), tt.v, got, tt.want) - } - } -} - -func TestUnmarshalAlloc(t *testing.T) { - b := json.Unmarshaler(new(Bool)) - n := testing.AllocsPerRun(10, func() { b.UnmarshalJSON(trueBytes) }) - if n > 0 { - t.Errorf("got %v allocs, want 0", n) - } -} - -func TestBoolFlag(t *testing.T) { - tests := []struct { - arguments string - wantParseError bool // expect flag.Parse to error - want Bool - }{ - {"", false, Bool("")}, - {"-test", true, Bool("")}, - {`-test=""`, true, Bool("")}, - {"-test invalid", true, Bool("")}, - - {"-test true", false, NewBool(true)}, - {"-test 1", false, NewBool(true)}, - - {"-test false", false, NewBool(false)}, - {"-test 0", false, NewBool(false)}, - } - - for _, tt := range tests { - var got Bool - fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) - fs.Var(&BoolFlag{&got}, "test", "test flag") - - arguments := strings.Split(tt.arguments, " ") - err := fs.Parse(arguments) - if (err != nil) != tt.wantParseError { - t.Errorf("flag.Parse(%q) returned error %v, want %v", arguments, err, tt.wantParseError) - } - - if got != tt.want { - t.Errorf("flag.Parse(%q) got %q, want %q", arguments, got, tt.want) - } - } -} diff --git a/types/opt/value_test.go b/types/opt/value_test.go deleted file mode 100644 index 93d935e27581f..0000000000000 --- a/types/opt/value_test.go +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package opt - -import ( - "encoding/json" - "reflect" - "testing" - - jsonv2 "github.com/go-json-experiment/json" -) - -type testStruct struct { - Int int `json:",omitempty,omitzero"` - Str string `json:",omitempty"` -} - -func TestValue(t *testing.T) { - tests := []struct { - name string - in any - jsonv2 bool - want string // JSON - wantBack any - }{ - { - name: "null_for_unset", - in: struct { - True Value[bool] - False Value[bool] - Unset Value[bool] - ExplicitUnset Value[bool] - }{ - True: ValueOf(true), - False: ValueOf(false), - ExplicitUnset: Value[bool]{}, - }, - want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`, - wantBack: struct { - True Value[bool] - False Value[bool] - Unset Value[bool] - ExplicitUnset Value[bool] - }{ - True: ValueOf(true), - False: ValueOf(false), - Unset: Value[bool]{}, - ExplicitUnset: Value[bool]{}, - }, - }, - { - name: "null_for_unset_jsonv2", - in: struct { - True Value[bool] - False Value[bool] - Unset Value[bool] - ExplicitUnset Value[bool] - }{ - True: ValueOf(true), - False: ValueOf(false), - ExplicitUnset: Value[bool]{}, - }, - jsonv2: true, - want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`, - wantBack: struct { - True Value[bool] - False Value[bool] - Unset Value[bool] - ExplicitUnset Value[bool] - }{ - True: ValueOf(true), - False: ValueOf(false), - Unset: Value[bool]{}, - ExplicitUnset: Value[bool]{}, - }, - }, - { - name: "null_for_unset_omitzero", - in: struct { - True Value[bool] `json:",omitzero"` - False Value[bool] `json:",omitzero"` - Unset Value[bool] `json:",omitzero"` - ExplicitUnset Value[bool] `json:",omitzero"` - }{ - True: ValueOf(true), - False: ValueOf(false), - ExplicitUnset: Value[bool]{}, - }, - want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`, - wantBack: struct { - True Value[bool] `json:",omitzero"` - False Value[bool] `json:",omitzero"` - Unset Value[bool] `json:",omitzero"` - ExplicitUnset Value[bool] `json:",omitzero"` - }{ - True: ValueOf(true), - False: ValueOf(false), - Unset: Value[bool]{}, - ExplicitUnset: Value[bool]{}, - }, - }, - { - name: "null_for_unset_omitzero_jsonv2", - in: struct { - True Value[bool] `json:",omitzero"` - False Value[bool] `json:",omitzero"` - Unset Value[bool] `json:",omitzero"` - ExplicitUnset Value[bool] `json:",omitzero"` - }{ - True: ValueOf(true), - False: ValueOf(false), - ExplicitUnset: Value[bool]{}, - }, - jsonv2: true, - want: `{"True":true,"False":false}`, - wantBack: struct { - True Value[bool] `json:",omitzero"` - False Value[bool] `json:",omitzero"` - Unset Value[bool] `json:",omitzero"` - ExplicitUnset Value[bool] `json:",omitzero"` - }{ - True: ValueOf(true), - False: ValueOf(false), - Unset: Value[bool]{}, - ExplicitUnset: Value[bool]{}, - }, - }, - { - name: "string", - in: struct { - EmptyString Value[string] - NonEmpty Value[string] - Unset Value[string] - }{ - EmptyString: ValueOf(""), - NonEmpty: ValueOf("value"), - Unset: Value[string]{}, - }, - want: `{"EmptyString":"","NonEmpty":"value","Unset":null}`, - wantBack: struct { - EmptyString Value[string] - NonEmpty Value[string] - Unset Value[string] - }{ValueOf(""), ValueOf("value"), Value[string]{}}, - }, - { - name: "integer", - in: struct { - Zero Value[int] - NonZero Value[int] - Unset Value[int] - }{ - Zero: ValueOf(0), - NonZero: ValueOf(42), - Unset: Value[int]{}, - }, - want: `{"Zero":0,"NonZero":42,"Unset":null}`, - wantBack: struct { - Zero Value[int] - NonZero Value[int] - Unset Value[int] - }{ValueOf(0), ValueOf(42), Value[int]{}}, - }, - { - name: "struct", - in: struct { - Zero Value[testStruct] - NonZero Value[testStruct] - Unset Value[testStruct] - }{ - Zero: ValueOf(testStruct{}), - NonZero: ValueOf(testStruct{Int: 42, Str: "String"}), - Unset: Value[testStruct]{}, - }, - want: `{"Zero":{},"NonZero":{"Int":42,"Str":"String"},"Unset":null}`, - wantBack: struct { - Zero Value[testStruct] - NonZero Value[testStruct] - Unset Value[testStruct] - }{ValueOf(testStruct{}), ValueOf(testStruct{Int: 42, Str: "String"}), Value[testStruct]{}}, - }, - { - name: "struct_ptr", - in: struct { - Zero Value[*testStruct] - NonZero Value[*testStruct] - Unset Value[*testStruct] - }{ - Zero: ValueOf(&testStruct{}), - NonZero: ValueOf(&testStruct{Int: 42, Str: "String"}), - Unset: Value[*testStruct]{}, - }, - want: `{"Zero":{},"NonZero":{"Int":42,"Str":"String"},"Unset":null}`, - wantBack: struct { - Zero Value[*testStruct] - NonZero Value[*testStruct] - Unset Value[*testStruct] - }{ValueOf(&testStruct{}), ValueOf(&testStruct{Int: 42, Str: "String"}), Value[*testStruct]{}}, - }, - { - name: "nil-slice-and-map", - in: struct { - Slice Value[[]int] - Map Value[map[string]int] - }{ - Slice: ValueOf[[]int](nil), // marshalled as [] - Map: ValueOf[map[string]int](nil), // marshalled as {} - }, - want: `{"Slice":[],"Map":{}}`, - wantBack: struct { - Slice Value[[]int] - Map Value[map[string]int] - }{ValueOf([]int{}), ValueOf(map[string]int{})}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var j []byte - var err error - if tt.jsonv2 { - j, err = jsonv2.Marshal(tt.in) - } else { - j, err = json.Marshal(tt.in) - } - if err != nil { - t.Fatal(err) - } - if string(j) != tt.want { - t.Errorf("wrong JSON:\n got: %s\nwant: %s\n", j, tt.want) - } - - wantBack := tt.in - if tt.wantBack != nil { - wantBack = tt.wantBack - } - // And back again: - newVal := reflect.New(reflect.TypeOf(tt.in)) - out := newVal.Interface() - if tt.jsonv2 { - err = jsonv2.Unmarshal(j, out) - } else { - err = json.Unmarshal(j, out) - } - if err != nil { - t.Fatalf("Unmarshal %#q: %v", j, err) - } - got := newVal.Elem().Interface() - if !reflect.DeepEqual(got, wantBack) { - t.Errorf("value mismatch\n got: %+v\nwant: %+v\n", got, wantBack) - } - }) - } -} - -func TestValueEqual(t *testing.T) { - tests := []struct { - o Value[bool] - v Value[bool] - want bool - }{ - {ValueOf(true), ValueOf(true), true}, - {ValueOf(true), ValueOf(false), false}, - {ValueOf(true), Value[bool]{}, false}, - {ValueOf(false), ValueOf(false), true}, - {ValueOf(false), ValueOf(true), false}, - {ValueOf(false), Value[bool]{}, false}, - {Value[bool]{}, Value[bool]{}, true}, - {Value[bool]{}, ValueOf(true), false}, - {Value[bool]{}, ValueOf(false), false}, - } - for _, tt := range tests { - if got := tt.o.Equal(tt.v); got != tt.want { - t.Errorf("(%v).Equals(%v) = %v; want %v", tt.o, tt.v, got, tt.want) - } - } -} - -func TestIncomparableValueEqual(t *testing.T) { - tests := []struct { - o Value[[]bool] - v Value[[]bool] - want bool - }{ - {ValueOf([]bool{}), ValueOf([]bool{}), false}, - {ValueOf([]bool{true}), ValueOf([]bool{true}), false}, - {Value[[]bool]{}, ValueOf([]bool{}), false}, - {ValueOf([]bool{}), Value[[]bool]{}, false}, - {Value[[]bool]{}, Value[[]bool]{}, true}, - } - for _, tt := range tests { - if got := tt.o.Equal(tt.v); got != tt.want { - t.Errorf("(%v).Equals(%v) = %v; want %v", tt.o, tt.v, got, tt.want) - } - } -} diff --git a/types/persist/persist.go b/types/persist/persist.go index 8b555abd42c1e..bbe770e03e574 100644 --- a/types/persist/persist.go +++ b/types/persist/persist.go @@ -8,9 +8,9 @@ import ( "fmt" "reflect" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/structs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/structs" ) //go:generate go run tailscale.com/cmd/viewer -type=Persist diff --git a/types/persist/persist_clone.go b/types/persist/persist_clone.go index 95dd65ac18e67..1f2da8cda1398 100644 --- a/types/persist/persist_clone.go +++ b/types/persist/persist_clone.go @@ -6,9 +6,9 @@ package persist import ( - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/structs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/structs" ) // Clone makes a deep copy of Persist. diff --git a/types/persist/persist_test.go b/types/persist/persist_test.go deleted file mode 100644 index 6b159573d4302..0000000000000 --- a/types/persist/persist_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package persist - -import ( - "reflect" - "testing" - - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -func fieldsOf(t reflect.Type) (fields []string) { - for i := range t.NumField() { - if name := t.Field(i).Name; name != "_" { - fields = append(fields, name) - } - } - return -} - -func TestPersistEqual(t *testing.T) { - persistHandles := []string{"LegacyFrontendPrivateMachineKey", "PrivateNodeKey", "OldPrivateNodeKey", "UserProfile", "NetworkLockKey", "NodeID", "DisallowedTKAStateIDs"} - if have := fieldsOf(reflect.TypeFor[Persist]()); !reflect.DeepEqual(have, persistHandles) { - t.Errorf("Persist.Equal check might be out of sync\nfields: %q\nhandled: %q\n", - have, persistHandles) - } - - m1 := key.NewMachine() - k1 := key.NewNode() - nl1 := key.NewNLPrivate() - tests := []struct { - a, b *Persist - want bool - }{ - {nil, nil, true}, - {nil, &Persist{}, false}, - {&Persist{}, nil, false}, - {&Persist{}, &Persist{}, true}, - - { - &Persist{LegacyFrontendPrivateMachineKey: m1}, - &Persist{LegacyFrontendPrivateMachineKey: key.NewMachine()}, - false, - }, - { - &Persist{LegacyFrontendPrivateMachineKey: m1}, - &Persist{LegacyFrontendPrivateMachineKey: m1}, - true, - }, - - { - &Persist{PrivateNodeKey: k1}, - &Persist{PrivateNodeKey: key.NewNode()}, - false, - }, - { - &Persist{PrivateNodeKey: k1}, - &Persist{PrivateNodeKey: k1}, - true, - }, - - { - &Persist{OldPrivateNodeKey: k1}, - &Persist{OldPrivateNodeKey: key.NewNode()}, - false, - }, - { - &Persist{OldPrivateNodeKey: k1}, - &Persist{OldPrivateNodeKey: k1}, - true, - }, - - { - &Persist{UserProfile: tailcfg.UserProfile{ - ID: tailcfg.UserID(3), - }}, - &Persist{UserProfile: tailcfg.UserProfile{ - ID: tailcfg.UserID(3), - }}, - true, - }, - { - &Persist{UserProfile: tailcfg.UserProfile{ - ID: tailcfg.UserID(3), - }}, - &Persist{UserProfile: tailcfg.UserProfile{ - ID: tailcfg.UserID(3), - DisplayName: "foo", - }}, - false, - }, - { - &Persist{NetworkLockKey: nl1}, - &Persist{NetworkLockKey: nl1}, - true, - }, - { - &Persist{NetworkLockKey: nl1}, - &Persist{NetworkLockKey: key.NewNLPrivate()}, - false, - }, - { - &Persist{NodeID: "abc"}, - &Persist{NodeID: "abc"}, - true, - }, - { - &Persist{NodeID: ""}, - &Persist{NodeID: "abc"}, - false, - }, - { - &Persist{DisallowedTKAStateIDs: nil}, - &Persist{DisallowedTKAStateIDs: []string{"0:0"}}, - false, - }, - { - &Persist{DisallowedTKAStateIDs: []string{"0:1"}}, - &Persist{DisallowedTKAStateIDs: []string{"0:1"}}, - true, - }, - { - &Persist{DisallowedTKAStateIDs: []string{}}, - &Persist{DisallowedTKAStateIDs: nil}, - true, - }, - } - for i, test := range tests { - if got := test.a.Equals(test.b); got != test.want { - t.Errorf("%d. Equals = %v; want %v", i, got, test.want) - } - } -} diff --git a/types/persist/persist_view.go b/types/persist/persist_view.go index 1d479b3bf10e7..c3d324da28399 100644 --- a/types/persist/persist_view.go +++ b/types/persist/persist_view.go @@ -9,10 +9,10 @@ import ( "encoding/json" "errors" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/structs" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/structs" + "github.com/sagernet/tailscale/types/views" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=Persist diff --git a/types/prefs/item.go b/types/prefs/item.go index 1032041471a75..1d081f25029ba 100644 --- a/types/prefs/item.go +++ b/types/prefs/item.go @@ -8,10 +8,10 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/types/views" - "tailscale.com/util/must" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/must" ) // Item is a single preference item that can be configured. diff --git a/types/prefs/list.go b/types/prefs/list.go index 9830e79de86cb..fd39c50352909 100644 --- a/types/prefs/list.go +++ b/types/prefs/list.go @@ -10,10 +10,10 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" "golang.org/x/exp/constraints" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/types/views" ) // BasicType is a constraint that allows types whose underlying type is a predeclared diff --git a/types/prefs/map.go b/types/prefs/map.go index 2bd32bfbdec75..a7212cdb72c42 100644 --- a/types/prefs/map.go +++ b/types/prefs/map.go @@ -9,10 +9,10 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" "golang.org/x/exp/constraints" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/types/views" ) // MapKeyType is a constraint allowing types that can be used as [Map] and [StructMap] keys. diff --git a/types/prefs/prefs.go b/types/prefs/prefs.go index 3bbd237fe5efe..a9ef0899d2674 100644 --- a/types/prefs/prefs.go +++ b/types/prefs/prefs.go @@ -23,7 +23,7 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/opt" + "github.com/sagernet/tailscale/types/opt" ) var ( diff --git a/types/prefs/prefs_clone_test.go b/types/prefs/prefs_clone_test.go deleted file mode 100644 index 2a03fba8b092c..0000000000000 --- a/types/prefs/prefs_clone_test.go +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT. - -package prefs - -import ( - "net/netip" - - "tailscale.com/types/ptr" -) - -// Clone makes a deep copy of TestPrefs. -// The result aliases no memory with the original. -func (src *TestPrefs) Clone() *TestPrefs { - if src == nil { - return nil - } - dst := new(TestPrefs) - *dst = *src - dst.StringSlice = *src.StringSlice.Clone() - dst.IntSlice = *src.IntSlice.Clone() - dst.StringStringMap = *src.StringStringMap.Clone() - dst.IntStringMap = *src.IntStringMap.Clone() - dst.AddrIntMap = *src.AddrIntMap.Clone() - dst.Bundle1 = *src.Bundle1.Clone() - dst.Bundle2 = *src.Bundle2.Clone() - dst.Generic = *src.Generic.Clone() - dst.BundleList = *src.BundleList.Clone() - dst.StringBundleMap = *src.StringBundleMap.Clone() - dst.IntBundleMap = *src.IntBundleMap.Clone() - dst.AddrBundleMap = *src.AddrBundleMap.Clone() - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestPrefsCloneNeedsRegeneration = TestPrefs(struct { - Int32Item Item[int32] - UInt64Item Item[uint64] - StringItem1 Item[string] - StringItem2 Item[string] - BoolItem1 Item[bool] - BoolItem2 Item[bool] - StringSlice List[string] - IntSlice List[int] - AddrItem Item[netip.Addr] - StringStringMap Map[string, string] - IntStringMap Map[int, string] - AddrIntMap Map[netip.Addr, int] - Bundle1 Item[*TestBundle] - Bundle2 Item[*TestBundle] - Generic Item[*TestGenericStruct[int]] - BundleList StructList[*TestBundle] - StringBundleMap StructMap[string, *TestBundle] - IntBundleMap StructMap[int, *TestBundle] - AddrBundleMap StructMap[netip.Addr, *TestBundle] - Group TestPrefsGroup -}{}) - -// Clone makes a deep copy of TestBundle. -// The result aliases no memory with the original. -func (src *TestBundle) Clone() *TestBundle { - if src == nil { - return nil - } - dst := new(TestBundle) - *dst = *src - if dst.Nested != nil { - dst.Nested = ptr.To(*src.Nested) - } - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestBundleCloneNeedsRegeneration = TestBundle(struct { - Name string - Nested *TestValueStruct -}{}) - -// Clone makes a deep copy of TestValueStruct. -// The result aliases no memory with the original. -func (src *TestValueStruct) Clone() *TestValueStruct { - if src == nil { - return nil - } - dst := new(TestValueStruct) - *dst = *src - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestValueStructCloneNeedsRegeneration = TestValueStruct(struct { - Value int -}{}) - -// Clone makes a deep copy of TestGenericStruct. -// The result aliases no memory with the original. -func (src *TestGenericStruct[T]) Clone() *TestGenericStruct[T] { - if src == nil { - return nil - } - dst := new(TestGenericStruct[T]) - *dst = *src - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _TestGenericStructCloneNeedsRegeneration[T ImmutableType](TestGenericStruct[T]) { - _TestGenericStructCloneNeedsRegeneration(struct { - Value T - }{}) -} - -// Clone makes a deep copy of TestPrefsGroup. -// The result aliases no memory with the original. -func (src *TestPrefsGroup) Clone() *TestPrefsGroup { - if src == nil { - return nil - } - dst := new(TestPrefsGroup) - *dst = *src - return dst -} - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestPrefsGroupCloneNeedsRegeneration = TestPrefsGroup(struct { - FloatItem Item[float64] - TestStringItem Item[TestStringType] -}{}) diff --git a/types/prefs/prefs_example/prefs_example_clone.go b/types/prefs/prefs_example/prefs_example_clone.go index 5c707b46343e1..dfd0b2c8e6ab8 100644 --- a/types/prefs/prefs_example/prefs_example_clone.go +++ b/types/prefs/prefs_example/prefs_example_clone.go @@ -8,12 +8,12 @@ package prefs_example import ( "net/netip" - "tailscale.com/drive" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/prefs" - "tailscale.com/types/preftype" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/prefs" + "github.com/sagernet/tailscale/types/preftype" ) // Clone makes a deep copy of Prefs. diff --git a/types/prefs/prefs_example/prefs_example_view.go b/types/prefs/prefs_example/prefs_example_view.go index 0256bd7e6d25b..d345048d8da48 100644 --- a/types/prefs/prefs_example/prefs_example_view.go +++ b/types/prefs/prefs_example/prefs_example_view.go @@ -10,12 +10,12 @@ import ( "errors" "net/netip" - "tailscale.com/drive" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/prefs" - "tailscale.com/types/preftype" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/prefs" + "github.com/sagernet/tailscale/types/preftype" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=Prefs,AutoUpdatePrefs,AppConnectorPrefs diff --git a/types/prefs/prefs_example/prefs_test.go b/types/prefs/prefs_example/prefs_test.go deleted file mode 100644 index aefbae9f2873a..0000000000000 --- a/types/prefs/prefs_example/prefs_test.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package prefs_example - -import ( - "fmt" - "net/netip" - - "tailscale.com/ipn" - "tailscale.com/types/prefs" -) - -func ExamplePrefs_AdvertiseRoutes_setValue() { - p := &Prefs{} - - // Initially, preferences are not configured. - fmt.Println("IsSet:", p.AdvertiseRoutes.IsSet()) // prints false - // And the Value method returns the default (or zero) value. - fmt.Println("Initial:", p.AdvertiseRoutes.Value()) // prints [] - - // Preferences can be configured with user-provided values using the - // SetValue method. It may fail if the preference is managed via syspolicy - // or is otherwise read-only. - routes := []netip.Prefix{netip.MustParsePrefix("192.168.1.1/24")} - if err := p.AdvertiseRoutes.SetValue(routes); err != nil { - // This block is never executed in the example because the - // AdvertiseRoutes preference is neither managed nor read-only. - fmt.Println("SetValue:", err) - } - fmt.Println("IsSet:", p.AdvertiseRoutes.IsSet()) // prints true - fmt.Println("Value:", p.AdvertiseRoutes.Value()) // prints 192.168.1.1/24 - - // Preference values are copied on use; you cannot not modify them after they are set. - routes[0] = netip.MustParsePrefix("10.10.10.0/24") // this has no effect - fmt.Println("Unchanged:", p.AdvertiseRoutes.Value()) // still prints 192.168.1.1/24 - // If necessary, the value can be changed by calling the SetValue method again. - p.AdvertiseRoutes.SetValue(routes) - fmt.Println("Changed:", p.AdvertiseRoutes.Value()) // prints 10.10.10.0/24 - - // The following code is fine when defining default or baseline prefs, or - // in tests. However, assigning to a preference field directly overwrites - // syspolicy-managed values and metadata, so it should generally be avoided - // when working with the actual profile or device preferences. - // It is caller's responsibility to use the mutable Prefs struct correctly. - defaults := &Prefs{WantRunning: prefs.ItemOf(true)} - defaults.CorpDNS = prefs.Item[bool]{} - defaults.ExitNodeAllowLANAccess = prefs.ItemOf(true) - _, _, _ = defaults.WantRunning, defaults.CorpDNS, defaults.ExitNodeAllowLANAccess - - // In most contexts, preferences should only be read and never mutated. - // To make it easier to enforce this guarantee, a view type generated with - // [tailscale.com/cmd/viewer] can be used instead of the mutable Prefs struct. - // Preferences accessed via a view have the same set of non-mutating - // methods as the underlying preferences but do not expose [prefs.Item.SetValue] or - // other methods that modify the preference's value or state. - v := p.View() - // Additionally, non-mutating methods like [prefs.ItemView.Value] and [prefs.ItemView.ValueOk] - // return read-only views of the underlying values instead of the actual potentially mutable values. - // For example, on the next line Value() returns a views.Slice[netip.Prefix], not a []netip.Prefix. - _ = v.AdvertiseRoutes().Value() - fmt.Println("Via View:", v.AdvertiseRoutes().Value().At(0)) // prints 10.10.10.0/24 - fmt.Println("IsSet:", v.AdvertiseRoutes().IsSet()) // prints true - fmt.Println("IsManaged:", v.AdvertiseRoutes().IsManaged()) // prints false - fmt.Println("IsReadOnly:", v.AdvertiseRoutes().IsReadOnly()) // prints false - - // Output: - // IsSet: false - // Initial: [] - // IsSet: true - // Value: [192.168.1.1/24] - // Unchanged: [192.168.1.1/24] - // Changed: [10.10.10.0/24] - // Via View: 10.10.10.0/24 - // IsSet: true - // IsManaged: false - // IsReadOnly: false -} - -func ExamplePrefs_ControlURL_setDefaultValue() { - p := &Prefs{} - v := p.View() - - // We can set default values for preferences when their default values - // should differ from the zero values of the corresponding Go types. - // - // Note that in this example, we configure preferences via a mutable - // [Prefs] struct but fetch values via a read-only [PrefsView]. - // Typically, we set and get preference values in different parts - // of the codebase. - p.ControlURL.SetDefaultValue(ipn.DefaultControlURL) - // The default value is used if the preference is not configured... - fmt.Println("Default:", v.ControlURL().Value()) - p.ControlURL.SetValue("https://control.example.com") - fmt.Println("User Set:", v.ControlURL().Value()) - // ...including when it has been reset. - p.ControlURL.ClearValue() - fmt.Println("Reset to Default:", v.ControlURL().Value()) - - // Output: - // Default: https://controlplane.tailscale.com - // User Set: https://control.example.com - // Reset to Default: https://controlplane.tailscale.com -} - -func ExamplePrefs_ExitNodeID_setManagedValue() { - p := &Prefs{} - v := p.View() - - // We can mark preferences as being managed via syspolicy (e.g., via GP/MDM) - // by setting its managed value. - // - // Note that in this example, we enforce syspolicy-managed values - // via a mutable [Prefs] struct but fetch values via a read-only [PrefsView]. - // This is typically spread throughout the codebase. - p.ExitNodeID.SetManagedValue("ManagedExitNode") - // Marking a preference as managed prevents it from being changed by the user. - if err := p.ExitNodeID.SetValue("CustomExitNode"); err != nil { - fmt.Println("SetValue:", err) // reports an error - } - fmt.Println("Exit Node:", v.ExitNodeID().Value()) // prints ManagedExitNode - - // Clients can hide or disable preferences that are managed or read-only. - fmt.Println("IsManaged:", v.ExitNodeID().IsManaged()) // prints true - fmt.Println("IsReadOnly:", v.ExitNodeID().IsReadOnly()) // prints true; managed preferences are always read-only. - - // ClearManaged is called when the preference is no longer managed, - // allowing the user to change it. - p.ExitNodeID.ClearManaged() - fmt.Println("IsManaged:", v.ExitNodeID().IsManaged()) // prints false - fmt.Println("IsReadOnly:", v.ExitNodeID().IsReadOnly()) // prints false - - // Output: - // SetValue: cannot modify a managed preference - // Exit Node: ManagedExitNode - // IsManaged: true - // IsReadOnly: true - // IsManaged: false - // IsReadOnly: false -} diff --git a/types/prefs/prefs_example/prefs_types.go b/types/prefs/prefs_example/prefs_types.go index 49f0d8c3c4b57..281315bc51428 100644 --- a/types/prefs/prefs_example/prefs_types.go +++ b/types/prefs/prefs_example/prefs_types.go @@ -15,12 +15,12 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/drive" - "tailscale.com/tailcfg" - "tailscale.com/types/opt" - "tailscale.com/types/persist" - "tailscale.com/types/prefs" - "tailscale.com/types/preftype" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/persist" + "github.com/sagernet/tailscale/types/prefs" + "github.com/sagernet/tailscale/types/preftype" ) //go:generate go run tailscale.com/cmd/viewer --type=Prefs,AutoUpdatePrefs,AppConnectorPrefs diff --git a/types/prefs/prefs_test.go b/types/prefs/prefs_test.go deleted file mode 100644 index ea4729366bc23..0000000000000 --- a/types/prefs/prefs_test.go +++ /dev/null @@ -1,670 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package prefs - -import ( - "bytes" - "encoding/json" - "errors" - "net/netip" - "reflect" - "testing" - - jsonv2 "github.com/go-json-experiment/json" - "github.com/go-json-experiment/json/jsontext" - "github.com/google/go-cmp/cmp" - "tailscale.com/types/views" -) - -//go:generate go run tailscale.com/cmd/viewer --tags=test --type=TestPrefs,TestBundle,TestValueStruct,TestGenericStruct,TestPrefsGroup - -type TestPrefs struct { - Int32Item Item[int32] `json:",omitzero"` - UInt64Item Item[uint64] `json:",omitzero"` - StringItem1 Item[string] `json:",omitzero"` - StringItem2 Item[string] `json:",omitzero"` - BoolItem1 Item[bool] `json:",omitzero"` - BoolItem2 Item[bool] `json:",omitzero"` - StringSlice List[string] `json:",omitzero"` - IntSlice List[int] `json:",omitzero"` - - AddrItem Item[netip.Addr] `json:",omitzero"` - - StringStringMap Map[string, string] `json:",omitzero"` - IntStringMap Map[int, string] `json:",omitzero"` - AddrIntMap Map[netip.Addr, int] `json:",omitzero"` - - // Bundles are complex preferences that usually consist of - // multiple parameters that must be configured atomically. - Bundle1 Item[*TestBundle] `json:",omitzero"` - Bundle2 Item[*TestBundle] `json:",omitzero"` - Generic Item[*TestGenericStruct[int]] `json:",omitzero"` - - BundleList StructList[*TestBundle] `json:",omitzero"` - - StringBundleMap StructMap[string, *TestBundle] `json:",omitzero"` - IntBundleMap StructMap[int, *TestBundle] `json:",omitzero"` - AddrBundleMap StructMap[netip.Addr, *TestBundle] `json:",omitzero"` - - // Group is a nested struct that contains one or more preferences. - // Each preference in a group can be configured individually. - // Preference groups should be included directly rather than by pointers. - Group TestPrefsGroup `json:",omitzero"` -} - -// MarshalJSONV2 implements [jsonv2.MarshalerV2]. -func (p TestPrefs) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error { - // The testPrefs type shadows the TestPrefs's method set, - // causing jsonv2 to use the default marshaler and avoiding - // infinite recursion. - type testPrefs TestPrefs - return jsonv2.MarshalEncode(out, (*testPrefs)(&p), opts) -} - -// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. -func (p *TestPrefs) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error { - // The testPrefs type shadows the TestPrefs's method set, - // causing jsonv2 to use the default unmarshaler and avoiding - // infinite recursion. - type testPrefs TestPrefs - return jsonv2.UnmarshalDecode(in, (*testPrefs)(p), opts) -} - -// MarshalJSON implements [json.Marshaler]. -func (p TestPrefs) MarshalJSON() ([]byte, error) { - return jsonv2.Marshal(p) // uses MarshalJSONV2 -} - -// UnmarshalJSON implements [json.Unmarshaler]. -func (p *TestPrefs) UnmarshalJSON(b []byte) error { - return jsonv2.Unmarshal(b, p) // uses UnmarshalJSONV2 -} - -// TestBundle is an example structure type that, -// despite containing multiple values, represents -// a single configurable preference item. -type TestBundle struct { - Name string `json:",omitzero"` - Nested *TestValueStruct `json:",omitzero"` -} - -func (b *TestBundle) Equal(b2 *TestBundle) bool { - if b == b2 { - return true - } - if b == nil || b2 == nil { - return false - } - return b.Name == b2.Name && b.Nested.Equal(b2.Nested) -} - -// TestPrefsGroup contains logically grouped preference items. -// Each preference item in a group can be configured individually. -type TestPrefsGroup struct { - FloatItem Item[float64] `json:",omitzero"` - - TestStringItem Item[TestStringType] `json:",omitzero"` -} - -type TestValueStruct struct { - Value int -} - -func (s *TestValueStruct) Equal(s2 *TestValueStruct) bool { - if s == s2 { - return true - } - if s == nil || s2 == nil { - return false - } - return *s == *s2 -} - -type TestGenericStruct[T ImmutableType] struct { - Value T -} - -func (s *TestGenericStruct[T]) Equal(s2 *TestGenericStruct[T]) bool { - if s == s2 { - return true - } - if s == nil || s2 == nil { - return false - } - return *s == *s2 -} - -type TestStringType string - -func TestMarshalUnmarshal(t *testing.T) { - tests := []struct { - name string - prefs *TestPrefs - indent bool - want string - }{ - { - name: "string", - prefs: &TestPrefs{StringItem1: ItemOf("Value1")}, - want: `{"StringItem1": {"Value": "Value1"}}`, - }, - { - name: "empty-string", - prefs: &TestPrefs{StringItem1: ItemOf("")}, - want: `{"StringItem1": {"Value": ""}}`, - }, - { - name: "managed-string", - prefs: &TestPrefs{StringItem1: ItemOf("Value1", Managed)}, - want: `{"StringItem1": {"Value": "Value1", "Managed": true}}`, - }, - { - name: "readonly-item", - prefs: &TestPrefs{StringItem1: ItemWithOpts[string](ReadOnly)}, - want: `{"StringItem1": {"ReadOnly": true}}`, - }, - { - name: "readonly-item-with-value", - prefs: &TestPrefs{StringItem1: ItemOf("RO", ReadOnly)}, - want: `{"StringItem1": {"Value": "RO", "ReadOnly": true}}`, - }, - { - name: "int32", - prefs: &TestPrefs{Int32Item: ItemOf[int32](101)}, - want: `{"Int32Item": {"Value": 101}}`, - }, - { - name: "uint64", - prefs: &TestPrefs{UInt64Item: ItemOf[uint64](42)}, - want: `{"UInt64Item": {"Value": 42}}`, - }, - { - name: "bool-true", - prefs: &TestPrefs{BoolItem1: ItemOf(true)}, - want: `{"BoolItem1": {"Value": true}}`, - }, - { - name: "bool-false", - prefs: &TestPrefs{BoolItem1: ItemOf(false)}, - want: `{"BoolItem1": {"Value": false}}`, - }, - { - name: "empty-slice", - prefs: &TestPrefs{StringSlice: ListOf([]string{})}, - want: `{"StringSlice": {"Value": []}}`, - }, - { - name: "string-slice", - prefs: &TestPrefs{StringSlice: ListOf([]string{"1", "2", "3"})}, - want: `{"StringSlice": {"Value": ["1", "2", "3"]}}`, - }, - { - name: "int-slice", - prefs: &TestPrefs{IntSlice: ListOf([]int{4, 8, 15, 16, 23})}, - want: `{"IntSlice": {"Value": [4, 8, 15, 16, 23]}}`, - }, - { - name: "managed-int-slice", - prefs: &TestPrefs{IntSlice: ListOf([]int{4, 8, 15, 16, 23}, Managed)}, - want: `{"IntSlice": {"Value": [4, 8, 15, 16, 23], "Managed": true}}`, - }, - { - name: "netip-addr", - prefs: &TestPrefs{AddrItem: ItemOf(netip.MustParseAddr("127.0.0.1"))}, - want: `{"AddrItem": {"Value": "127.0.0.1"}}`, - }, - { - name: "string-string-map", - prefs: &TestPrefs{StringStringMap: MapOf(map[string]string{"K1": "V1"})}, - want: `{"StringStringMap": {"Value": {"K1": "V1"}}}`, - }, - { - name: "int-string-map", - prefs: &TestPrefs{IntStringMap: MapOf(map[int]string{42: "V1"})}, - want: `{"IntStringMap": {"Value": {"42": "V1"}}}`, - }, - { - name: "addr-int-map", - prefs: &TestPrefs{AddrIntMap: MapOf(map[netip.Addr]int{netip.MustParseAddr("127.0.0.1"): 42})}, - want: `{"AddrIntMap": {"Value": {"127.0.0.1": 42}}}`, - }, - { - name: "bundle-list", - prefs: &TestPrefs{BundleList: StructListOf([]*TestBundle{{Name: "Bundle1"}, {Name: "Bundle2"}})}, - want: `{"BundleList": {"Value": [{"Name": "Bundle1"},{"Name": "Bundle2"}]}}`, - }, - { - name: "string-bundle-map", - prefs: &TestPrefs{StringBundleMap: StructMapOf(map[string]*TestBundle{ - "K1": {Name: "Bundle1"}, - "K2": {Name: "Bundle2"}, - })}, - want: `{"StringBundleMap": {"Value": {"K1": {"Name": "Bundle1"}, "K2": {"Name": "Bundle2"}}}}`, - }, - { - name: "int-bundle-map", - prefs: &TestPrefs{IntBundleMap: StructMapOf(map[int]*TestBundle{42: {Name: "Bundle1"}})}, - want: `{"IntBundleMap": {"Value": {"42": {"Name": "Bundle1"}}}}`, - }, - { - name: "addr-bundle-map", - prefs: &TestPrefs{AddrBundleMap: StructMapOf(map[netip.Addr]*TestBundle{netip.MustParseAddr("127.0.0.1"): {Name: "Bundle1"}})}, - want: `{"AddrBundleMap": {"Value": {"127.0.0.1": {"Name": "Bundle1"}}}}`, - }, - { - name: "bundle", - prefs: &TestPrefs{Bundle1: ItemOf(&TestBundle{Name: "Bundle1"})}, - want: `{"Bundle1": {"Value": {"Name": "Bundle1"}}}`, - }, - { - name: "managed-bundle", - prefs: &TestPrefs{Bundle2: ItemOf(&TestBundle{Name: "Bundle2", Nested: &TestValueStruct{Value: 17}}, Managed)}, - want: `{"Bundle2": {"Value": {"Name": "Bundle2", "Nested": {"Value": 17}}, "Managed": true}}`, - }, - { - name: "subgroup", - prefs: &TestPrefs{Group: TestPrefsGroup{FloatItem: ItemOf(1.618), TestStringItem: ItemOf(TestStringType("Value"))}}, - want: `{"Group": {"FloatItem": {"Value": 1.618}, "TestStringItem": {"Value": "Value"}}}`, - }, - { - name: "various", - prefs: &TestPrefs{ - Int32Item: ItemOf[int32](101), - UInt64Item: ItemOf[uint64](42), - StringItem1: ItemOf("Value1"), - StringItem2: ItemWithOpts[string](ReadOnly), - BoolItem1: ItemOf(true), - BoolItem2: ItemOf(false, Managed), - StringSlice: ListOf([]string{"1", "2", "3"}), - IntSlice: ListOf([]int{4, 8, 15, 16, 23}, Managed), - AddrItem: ItemOf(netip.MustParseAddr("127.0.0.1")), - StringStringMap: MapOf(map[string]string{"K1": "V1"}), - IntStringMap: MapOf(map[int]string{42: "V1"}), - AddrIntMap: MapOf(map[netip.Addr]int{netip.MustParseAddr("127.0.0.1"): 42}), - BundleList: StructListOf([]*TestBundle{{Name: "Bundle1"}}), - StringBundleMap: StructMapOf(map[string]*TestBundle{"K1": {Name: "Bundle1"}}), - IntBundleMap: StructMapOf(map[int]*TestBundle{42: {Name: "Bundle1"}}), - AddrBundleMap: StructMapOf(map[netip.Addr]*TestBundle{netip.MustParseAddr("127.0.0.1"): {Name: "Bundle1"}}), - Bundle1: ItemOf(&TestBundle{Name: "Bundle1"}), - Bundle2: ItemOf(&TestBundle{Name: "Bundle2", Nested: &TestValueStruct{Value: 17}}, Managed), - Group: TestPrefsGroup{ - FloatItem: ItemOf(1.618), - TestStringItem: ItemOf(TestStringType("Value")), - }, - }, - want: `{ - "Int32Item": {"Value": 101}, - "UInt64Item": {"Value": 42}, - "StringItem1": {"Value": "Value1"}, - "StringItem2": {"ReadOnly": true}, - "BoolItem1": {"Value": true}, - "BoolItem2": {"Value": false, "Managed": true}, - "StringSlice": {"Value": ["1", "2", "3"]}, - "IntSlice": {"Value": [4, 8, 15, 16, 23], "Managed": true}, - "AddrItem": {"Value": "127.0.0.1"}, - "StringStringMap": {"Value": {"K1": "V1"}}, - "IntStringMap": {"Value": {"42": "V1"}}, - "AddrIntMap": {"Value": {"127.0.0.1": 42}}, - "BundleList": {"Value": [{"Name": "Bundle1"}]}, - "StringBundleMap": {"Value": {"K1": {"Name": "Bundle1"}}}, - "IntBundleMap": {"Value": {"42": {"Name": "Bundle1"}}}, - "AddrBundleMap": {"Value": {"127.0.0.1": {"Name": "Bundle1"}}}, - "Bundle1": {"Value": {"Name": "Bundle1"}}, - "Bundle2": {"Value": {"Name": "Bundle2", "Nested": {"Value": 17}}, "Managed": true}, - "Group": { - "FloatItem": {"Value": 1.618}, - "TestStringItem": {"Value": "Value"} - } - }`, - }, - } - - arshalers := []struct { - name string - marshal func(in any) (out []byte, err error) - unmarshal func(in []byte, out any) (err error) - }{ - { - name: "json", - marshal: json.Marshal, - unmarshal: json.Unmarshal, - }, - { - name: "jsonv2", - marshal: func(in any) (out []byte, err error) { return jsonv2.Marshal(in) }, - unmarshal: func(in []byte, out any) (err error) { return jsonv2.Unmarshal(in, out) }, - }, - } - - for _, a := range arshalers { - t.Run(a.name, func(t *testing.T) { - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Run("marshal-directly", func(t *testing.T) { - gotJSON, err := a.marshal(tt.prefs) - if err != nil { - t.Fatalf("marshalling failed: %v", err) - } - - checkJSON(t, gotJSON, jsontext.Value(tt.want)) - - var gotPrefs TestPrefs - if err = a.unmarshal(gotJSON, &gotPrefs); err != nil { - t.Fatalf("unmarshalling failed: %v", err) - } - - if diff := cmp.Diff(tt.prefs, &gotPrefs); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) - } - }) - - t.Run("marshal-via-view", func(t *testing.T) { - gotJSON, err := a.marshal(tt.prefs.View()) - if err != nil { - t.Fatalf("marshalling failed: %v", err) - } - - checkJSON(t, gotJSON, jsontext.Value(tt.want)) - - var gotPrefs TestPrefsView - if err = a.unmarshal(gotJSON, &gotPrefs); err != nil { - t.Fatalf("unmarshalling failed: %v", err) - } - - if diff := cmp.Diff(tt.prefs, gotPrefs.AsStruct()); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) - } - }) - }) - } - }) - } -} - -func TestPreferenceStates(t *testing.T) { - const ( - zeroValue = 0 - defValue = 5 - userValue = 42 - mdmValue = 1001 - ) - i := ItemWithOpts[int]() - checkIsSet(t, &i, false) - checkIsManaged(t, &i, false) - checkIsReadOnly(t, &i, false) - checkValueOk(t, &i, zeroValue, false) - - i.SetDefaultValue(defValue) - checkValue(t, &i, defValue) - checkValueOk(t, &i, defValue, false) - - checkSetValue(t, &i, userValue) - checkValue(t, &i, userValue) - checkValueOk(t, &i, userValue, true) - - i2 := ItemOf(userValue) - checkIsSet(t, &i2, true) - checkValue(t, &i2, userValue) - checkValueOk(t, &i2, userValue, true) - checkEqual(t, i2, i, true) - - i2.SetManagedValue(mdmValue) - // Setting a managed value should set the value, mark the preference - // as managed and read-only, and prevent it from being modified with SetValue. - checkIsSet(t, &i2, true) - checkIsManaged(t, &i2, true) - checkIsReadOnly(t, &i2, true) - checkValue(t, &i2, mdmValue) - checkValueOk(t, &i2, mdmValue, true) - checkCanNotSetValue(t, &i2, userValue, ErrManaged) - checkValue(t, &i2, mdmValue) // the value must not be changed - checkCanNotClearValue(t, &i2, ErrManaged) - - i2.ClearManaged() - // Clearing the managed flag should change the IsManaged and IsReadOnly flags... - checkIsManaged(t, &i2, false) - checkIsReadOnly(t, &i2, false) - // ...but not the value. - checkValue(t, &i2, mdmValue) - - // We should be able to change the value after clearing the managed flag. - checkSetValue(t, &i2, userValue) - checkIsSet(t, &i2, true) - checkValue(t, &i2, userValue) - checkValueOk(t, &i2, userValue, true) - checkEqual(t, i2, i, true) - - i2.SetReadOnly(true) - checkIsReadOnly(t, &i2, true) - checkIsManaged(t, &i2, false) - checkCanNotSetValue(t, &i2, userValue, ErrReadOnly) - checkCanNotClearValue(t, &i2, ErrReadOnly) - - i2.SetReadOnly(false) - i2.SetDefaultValue(defValue) - checkClearValue(t, &i2) - checkIsSet(t, &i2, false) - checkValue(t, &i2, defValue) - checkValueOk(t, &i2, defValue, false) -} - -func TestItemView(t *testing.T) { - i := ItemOf(&TestBundle{Name: "B1"}) - - iv := ItemViewOf(&i) - checkIsSet(t, iv, true) - checkIsManaged(t, iv, false) - checkIsReadOnly(t, iv, false) - checkValue(t, iv, TestBundleView{i.Value()}) - checkValueOk(t, iv, TestBundleView{i.Value()}, true) - - i2 := *iv.AsStruct() - checkEqual(t, i, i2, true) - i2.SetValue(&TestBundle{Name: "B2"}) - - iv2 := ItemViewOf(&i2) - checkEqual(t, iv, iv2, false) -} - -func TestListView(t *testing.T) { - l := ListOf([]int{4, 8, 15, 16, 23, 42}, ReadOnly) - - lv := l.View() - checkIsSet(t, lv, true) - checkIsManaged(t, lv, false) - checkIsReadOnly(t, lv, true) - checkValue(t, lv, views.SliceOf(l.Value())) - checkValueOk(t, lv, views.SliceOf(l.Value()), true) - - l2 := *lv.AsStruct() - checkEqual(t, l, l2, true) -} - -func TestStructListView(t *testing.T) { - l := StructListOf([]*TestBundle{{Name: "E1"}, {Name: "E2"}}, ReadOnly) - - lv := StructListViewOf(&l) - checkIsSet(t, lv, true) - checkIsManaged(t, lv, false) - checkIsReadOnly(t, lv, true) - checkValue(t, lv, views.SliceOfViews(l.Value())) - checkValueOk(t, lv, views.SliceOfViews(l.Value()), true) - - l2 := *lv.AsStruct() - checkEqual(t, l, l2, true) -} - -func TestStructMapView(t *testing.T) { - m := StructMapOf(map[string]*TestBundle{ - "K1": {Name: "E1"}, - "K2": {Name: "E2"}, - }, ReadOnly) - - mv := StructMapViewOf(&m) - checkIsSet(t, mv, true) - checkIsManaged(t, mv, false) - checkIsReadOnly(t, mv, true) - checkValue(t, *mv.AsStruct(), m.Value()) - checkValueOk(t, *mv.AsStruct(), m.Value(), true) - - m2 := *mv.AsStruct() - checkEqual(t, m, m2, true) -} - -// check that the preference types implement the test [pref] interface. -var ( - _ pref[int] = (*Item[int])(nil) - _ pref[*TestBundle] = (*Item[*TestBundle])(nil) - _ pref[[]int] = (*List[int])(nil) - _ pref[[]*TestBundle] = (*StructList[*TestBundle])(nil) - _ pref[map[string]*TestBundle] = (*StructMap[string, *TestBundle])(nil) -) - -// pref is an interface used by [checkSetValue], [checkClearValue], and similar test -// functions that mutate preferences. It is implemented by all preference types, such -// as [Item], [List], [StructList], and [StructMap], and provides both read and write -// access to the preference's value and state. -type pref[T any] interface { - prefView[T] - SetValue(v T) error - ClearValue() error - SetDefaultValue(v T) - SetManagedValue(v T) - ClearManaged() - SetReadOnly(readonly bool) -} - -// check that the preference view types implement the test [prefView] interface. -var ( - _ prefView[int] = (*Item[int])(nil) - _ prefView[TestBundleView] = (*ItemView[*TestBundle, TestBundleView])(nil) - _ prefView[views.Slice[int]] = (*ListView[int])(nil) - _ prefView[views.SliceView[*TestBundle, TestBundleView]] = (*StructListView[*TestBundle, TestBundleView])(nil) - _ prefView[views.MapFn[string, *TestBundle, TestBundleView]] = (*StructMapView[string, *TestBundle, TestBundleView])(nil) -) - -// prefView is an interface used by [checkIsSet], [checkIsManaged], and similar non-mutating -// test functions. It is implemented by all preference types, such as [Item], [List], [StructList], -// and [StructMap], as well as their corresponding views, such as [ItemView], [ListView], [StructListView], -// and [StructMapView], and provides read-only access to the preference's value and state. -type prefView[T any] interface { - IsSet() bool - Value() T - ValueOk() (T, bool) - DefaultValue() T - IsManaged() bool - IsReadOnly() bool -} - -func checkIsSet[T any](tb testing.TB, p prefView[T], wantSet bool) { - tb.Helper() - if gotSet := p.IsSet(); gotSet != wantSet { - tb.Errorf("IsSet: got %v; want %v", gotSet, wantSet) - } -} - -func checkIsManaged[T any](tb testing.TB, p prefView[T], wantManaged bool) { - tb.Helper() - if gotManaged := p.IsManaged(); gotManaged != wantManaged { - tb.Errorf("IsManaged: got %v; want %v", gotManaged, wantManaged) - } -} - -func checkIsReadOnly[T any](tb testing.TB, p prefView[T], wantReadOnly bool) { - tb.Helper() - if gotReadOnly := p.IsReadOnly(); gotReadOnly != wantReadOnly { - tb.Errorf("IsReadOnly: got %v; want %v", gotReadOnly, wantReadOnly) - } -} - -func checkValue[T any](tb testing.TB, p prefView[T], wantValue T) { - tb.Helper() - if gotValue := p.Value(); !testComparerFor[T]()(gotValue, wantValue) { - tb.Errorf("Value: got %v; want %v", gotValue, wantValue) - } -} - -func checkValueOk[T any](tb testing.TB, p prefView[T], wantValue T, wantOk bool) { - tb.Helper() - gotValue, gotOk := p.ValueOk() - - if gotOk != wantOk || !testComparerFor[T]()(gotValue, wantValue) { - tb.Errorf("ValueOk: got (%v, %v); want (%v, %v)", gotValue, gotOk, wantValue, wantOk) - } -} - -func checkEqual[T equatable[T]](tb testing.TB, a, b T, wantEqual bool) { - tb.Helper() - if gotEqual := a.Equal(b); gotEqual != wantEqual { - tb.Errorf("Equal: got %v; want %v", gotEqual, wantEqual) - } -} - -func checkSetValue[T any](tb testing.TB, p pref[T], v T) { - tb.Helper() - if err := p.SetValue(v); err != nil { - tb.Fatalf("SetValue: gotErr %v, wantErr: nil", err) - } -} - -func checkCanNotSetValue[T any](tb testing.TB, p pref[T], v T, wantErr error) { - tb.Helper() - if err := p.SetValue(v); err == nil || !errors.Is(err, wantErr) { - tb.Fatalf("SetValue: gotErr %v, wantErr: %v", err, wantErr) - } -} - -func checkClearValue[T any](tb testing.TB, p pref[T]) { - tb.Helper() - if err := p.ClearValue(); err != nil { - tb.Fatalf("ClearValue: gotErr %v, wantErr: nil", err) - } -} - -func checkCanNotClearValue[T any](tb testing.TB, p pref[T], wantErr error) { - tb.Helper() - err := p.ClearValue() - if err == nil || !errors.Is(err, wantErr) { - tb.Fatalf("ClearValue: gotErr %v, wantErr: %v", err, wantErr) - } -} - -// testComparerFor is like [comparerFor], but uses [reflect.DeepEqual] -// unless T is [equatable]. -func testComparerFor[T any]() func(a, b T) bool { - return func(a, b T) bool { - switch a := any(a).(type) { - case equatable[T]: - return a.Equal(b) - default: - return reflect.DeepEqual(a, b) - } - } -} - -func checkJSON(tb testing.TB, got, want jsontext.Value) { - tb.Helper() - got = got.Clone() - want = want.Clone() - // Compare canonical forms. - if err := got.Canonicalize(); err != nil { - tb.Error(err) - } - if err := want.Canonicalize(); err != nil { - tb.Error(err) - } - if bytes.Equal(got, want) { - return - } - - gotMap := make(map[string]any) - if err := jsonv2.Unmarshal(got, &gotMap); err != nil { - tb.Fatal(err) - } - wantMap := make(map[string]any) - if err := jsonv2.Unmarshal(want, &wantMap); err != nil { - tb.Fatal(err) - } - tb.Errorf("mismatch (-want +got):\n%s", cmp.Diff(wantMap, gotMap)) -} diff --git a/types/prefs/prefs_view_test.go b/types/prefs/prefs_view_test.go deleted file mode 100644 index d76eebb43e9ef..0000000000000 --- a/types/prefs/prefs_view_test.go +++ /dev/null @@ -1,342 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Code generated by tailscale/cmd/viewer; DO NOT EDIT. - -package prefs - -import ( - "encoding/json" - "errors" - "net/netip" -) - -//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TestPrefs,TestBundle,TestValueStruct,TestGenericStruct,TestPrefsGroup -tags=test - -// View returns a readonly view of TestPrefs. -func (p *TestPrefs) View() TestPrefsView { - return TestPrefsView{ж: p} -} - -// TestPrefsView provides a read-only view over TestPrefs. -// -// Its methods should only be called if `Valid()` returns true. -type TestPrefsView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *TestPrefs -} - -// Valid reports whether underlying value is non-nil. -func (v TestPrefsView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v TestPrefsView) AsStruct() *TestPrefs { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v TestPrefsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *TestPrefsView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x TestPrefs - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v TestPrefsView) Int32Item() Item[int32] { return v.ж.Int32Item } -func (v TestPrefsView) UInt64Item() Item[uint64] { return v.ж.UInt64Item } -func (v TestPrefsView) StringItem1() Item[string] { return v.ж.StringItem1 } -func (v TestPrefsView) StringItem2() Item[string] { return v.ж.StringItem2 } -func (v TestPrefsView) BoolItem1() Item[bool] { return v.ж.BoolItem1 } -func (v TestPrefsView) BoolItem2() Item[bool] { return v.ж.BoolItem2 } -func (v TestPrefsView) StringSlice() ListView[string] { return v.ж.StringSlice.View() } -func (v TestPrefsView) IntSlice() ListView[int] { return v.ж.IntSlice.View() } -func (v TestPrefsView) AddrItem() Item[netip.Addr] { return v.ж.AddrItem } -func (v TestPrefsView) StringStringMap() MapView[string, string] { return v.ж.StringStringMap.View() } -func (v TestPrefsView) IntStringMap() MapView[int, string] { return v.ж.IntStringMap.View() } -func (v TestPrefsView) AddrIntMap() MapView[netip.Addr, int] { return v.ж.AddrIntMap.View() } -func (v TestPrefsView) Bundle1() ItemView[*TestBundle, TestBundleView] { - return ItemViewOf(&v.ж.Bundle1) -} -func (v TestPrefsView) Bundle2() ItemView[*TestBundle, TestBundleView] { - return ItemViewOf(&v.ж.Bundle2) -} -func (v TestPrefsView) Generic() ItemView[*TestGenericStruct[int], TestGenericStructView[int]] { - return ItemViewOf(&v.ж.Generic) -} -func (v TestPrefsView) BundleList() StructListView[*TestBundle, TestBundleView] { - return StructListViewOf(&v.ж.BundleList) -} -func (v TestPrefsView) StringBundleMap() StructMapView[string, *TestBundle, TestBundleView] { - return StructMapViewOf(&v.ж.StringBundleMap) -} -func (v TestPrefsView) IntBundleMap() StructMapView[int, *TestBundle, TestBundleView] { - return StructMapViewOf(&v.ж.IntBundleMap) -} -func (v TestPrefsView) AddrBundleMap() StructMapView[netip.Addr, *TestBundle, TestBundleView] { - return StructMapViewOf(&v.ж.AddrBundleMap) -} -func (v TestPrefsView) Group() TestPrefsGroup { return v.ж.Group } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestPrefsViewNeedsRegeneration = TestPrefs(struct { - Int32Item Item[int32] - UInt64Item Item[uint64] - StringItem1 Item[string] - StringItem2 Item[string] - BoolItem1 Item[bool] - BoolItem2 Item[bool] - StringSlice List[string] - IntSlice List[int] - AddrItem Item[netip.Addr] - StringStringMap Map[string, string] - IntStringMap Map[int, string] - AddrIntMap Map[netip.Addr, int] - Bundle1 Item[*TestBundle] - Bundle2 Item[*TestBundle] - Generic Item[*TestGenericStruct[int]] - BundleList StructList[*TestBundle] - StringBundleMap StructMap[string, *TestBundle] - IntBundleMap StructMap[int, *TestBundle] - AddrBundleMap StructMap[netip.Addr, *TestBundle] - Group TestPrefsGroup -}{}) - -// View returns a readonly view of TestBundle. -func (p *TestBundle) View() TestBundleView { - return TestBundleView{ж: p} -} - -// TestBundleView provides a read-only view over TestBundle. -// -// Its methods should only be called if `Valid()` returns true. -type TestBundleView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *TestBundle -} - -// Valid reports whether underlying value is non-nil. -func (v TestBundleView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v TestBundleView) AsStruct() *TestBundle { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v TestBundleView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *TestBundleView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x TestBundle - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v TestBundleView) Name() string { return v.ж.Name } -func (v TestBundleView) Nested() *TestValueStruct { - if v.ж.Nested == nil { - return nil - } - x := *v.ж.Nested - return &x -} - -func (v TestBundleView) Equal(v2 TestBundleView) bool { return v.ж.Equal(v2.ж) } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestBundleViewNeedsRegeneration = TestBundle(struct { - Name string - Nested *TestValueStruct -}{}) - -// View returns a readonly view of TestValueStruct. -func (p *TestValueStruct) View() TestValueStructView { - return TestValueStructView{ж: p} -} - -// TestValueStructView provides a read-only view over TestValueStruct. -// -// Its methods should only be called if `Valid()` returns true. -type TestValueStructView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *TestValueStruct -} - -// Valid reports whether underlying value is non-nil. -func (v TestValueStructView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v TestValueStructView) AsStruct() *TestValueStruct { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v TestValueStructView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *TestValueStructView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x TestValueStruct - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v TestValueStructView) Value() int { return v.ж.Value } -func (v TestValueStructView) Equal(v2 TestValueStructView) bool { return v.ж.Equal(v2.ж) } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestValueStructViewNeedsRegeneration = TestValueStruct(struct { - Value int -}{}) - -// View returns a readonly view of TestGenericStruct. -func (p *TestGenericStruct[T]) View() TestGenericStructView[T] { - return TestGenericStructView[T]{ж: p} -} - -// TestGenericStructView[T] provides a read-only view over TestGenericStruct[T]. -// -// Its methods should only be called if `Valid()` returns true. -type TestGenericStructView[T ImmutableType] struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *TestGenericStruct[T] -} - -// Valid reports whether underlying value is non-nil. -func (v TestGenericStructView[T]) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v TestGenericStructView[T]) AsStruct() *TestGenericStruct[T] { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v TestGenericStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *TestGenericStructView[T]) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x TestGenericStruct[T] - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v TestGenericStructView[T]) Value() T { return v.ж.Value } -func (v TestGenericStructView[T]) Equal(v2 TestGenericStructView[T]) bool { return v.ж.Equal(v2.ж) } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -func _TestGenericStructViewNeedsRegeneration[T ImmutableType](TestGenericStruct[T]) { - _TestGenericStructViewNeedsRegeneration(struct { - Value T - }{}) -} - -// View returns a readonly view of TestPrefsGroup. -func (p *TestPrefsGroup) View() TestPrefsGroupView { - return TestPrefsGroupView{ж: p} -} - -// TestPrefsGroupView provides a read-only view over TestPrefsGroup. -// -// Its methods should only be called if `Valid()` returns true. -type TestPrefsGroupView struct { - // ж is the underlying mutable value, named with a hard-to-type - // character that looks pointy like a pointer. - // It is named distinctively to make you think of how dangerous it is to escape - // to callers. You must not let callers be able to mutate it. - ж *TestPrefsGroup -} - -// Valid reports whether underlying value is non-nil. -func (v TestPrefsGroupView) Valid() bool { return v.ж != nil } - -// AsStruct returns a clone of the underlying value which aliases no memory with -// the original. -func (v TestPrefsGroupView) AsStruct() *TestPrefsGroup { - if v.ж == nil { - return nil - } - return v.ж.Clone() -} - -func (v TestPrefsGroupView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } - -func (v *TestPrefsGroupView) UnmarshalJSON(b []byte) error { - if v.ж != nil { - return errors.New("already initialized") - } - if len(b) == 0 { - return nil - } - var x TestPrefsGroup - if err := json.Unmarshal(b, &x); err != nil { - return err - } - v.ж = &x - return nil -} - -func (v TestPrefsGroupView) FloatItem() Item[float64] { return v.ж.FloatItem } -func (v TestPrefsGroupView) TestStringItem() Item[TestStringType] { return v.ж.TestStringItem } - -// A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TestPrefsGroupViewNeedsRegeneration = TestPrefsGroup(struct { - FloatItem Item[float64] - TestStringItem Item[TestStringType] -}{}) diff --git a/types/prefs/struct_list.go b/types/prefs/struct_list.go index 872cb232655e3..973c2ca03f4b9 100644 --- a/types/prefs/struct_list.go +++ b/types/prefs/struct_list.go @@ -10,9 +10,9 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" ) // StructList is a preference type that holds zero or more potentially mutable struct values. diff --git a/types/prefs/struct_map.go b/types/prefs/struct_map.go index 2003eebe323fa..d693b62077bc6 100644 --- a/types/prefs/struct_map.go +++ b/types/prefs/struct_map.go @@ -8,9 +8,9 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/opt" - "tailscale.com/types/ptr" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/ptr" + "github.com/sagernet/tailscale/types/views" ) // StructMap is a preference type that holds potentially mutable key-value pairs. diff --git a/types/tkatype/tkatype_test.go b/types/tkatype/tkatype_test.go deleted file mode 100644 index c81891b9ce103..0000000000000 --- a/types/tkatype/tkatype_test.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package tkatype - -import ( - "encoding/json" - "testing" - - "golang.org/x/crypto/blake2s" -) - -func TestSigHashSize(t *testing.T) { - var sigHash AUMSigHash - if len(sigHash) != blake2s.Size { - t.Errorf("AUMSigHash is wrong size: got %d, want %d", len(sigHash), blake2s.Size) - } - - var nksHash NKSSigHash - if len(nksHash) != blake2s.Size { - t.Errorf("NKSSigHash is wrong size: got %d, want %d", len(nksHash), blake2s.Size) - } -} - -func TestMarshaledSignatureJSON(t *testing.T) { - sig := MarshaledSignature("abcdef") - j, err := json.Marshal(sig) - if err != nil { - t.Fatal(err) - } - const encoded = `"YWJjZGVm"` - if string(j) != encoded { - t.Errorf("got JSON %q; want %q", j, encoded) - } - - var back MarshaledSignature - if err := json.Unmarshal([]byte(encoded), &back); err != nil { - t.Fatal(err) - } - if string(back) != string(sig) { - t.Errorf("decoded JSON back to %q; want %q", back, sig) - } -} diff --git a/types/views/views_test.go b/types/views/views_test.go deleted file mode 100644 index 8a1ff3fddfc9e..0000000000000 --- a/types/views/views_test.go +++ /dev/null @@ -1,503 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package views - -import ( - "bytes" - "encoding/json" - "fmt" - "net/netip" - "reflect" - "slices" - "strings" - "testing" - "unsafe" - - qt "github.com/frankban/quicktest" -) - -type viewStruct struct { - Int int - Addrs Slice[netip.Prefix] - Strings Slice[string] - AddrsPtr *Slice[netip.Prefix] `json:",omitempty"` - StringsPtr *Slice[string] `json:",omitempty"` -} - -type noPtrStruct struct { - Int int - Str string -} - -type withPtrStruct struct { - Int int - StrPtr *string -} - -func BenchmarkSliceIteration(b *testing.B) { - var data []viewStruct - for i := range 10000 { - data = append(data, viewStruct{Int: i}) - } - b.ResetTimer() - b.Run("Len", func(b *testing.B) { - b.ReportAllocs() - dv := SliceOf(data) - for it := 0; it < b.N; it++ { - sum := 0 - for i := range dv.Len() { - sum += dv.At(i).Int - } - } - }) - b.Run("Cached-Len", func(b *testing.B) { - b.ReportAllocs() - dv := SliceOf(data) - for it := 0; it < b.N; it++ { - sum := 0 - for i, n := 0, dv.Len(); i < n; i++ { - sum += dv.At(i).Int - } - } - }) - b.Run("direct", func(b *testing.B) { - b.ReportAllocs() - for it := 0; it < b.N; it++ { - sum := 0 - for _, d := range data { - sum += d.Int - } - } - }) -} - -func TestViewsJSON(t *testing.T) { - mustCIDR := func(cidrs ...string) (out []netip.Prefix) { - for _, cidr := range cidrs { - out = append(out, netip.MustParsePrefix(cidr)) - } - return - } - ipp := SliceOf(mustCIDR("192.168.0.0/24")) - ss := SliceOf([]string{"bar"}) - tests := []struct { - name string - in viewStruct - wantJSON string - }{ - { - name: "empty", - in: viewStruct{}, - wantJSON: `{"Int":0,"Addrs":null,"Strings":null}`, - }, - { - name: "everything", - in: viewStruct{ - Int: 1234, - Addrs: ipp, - AddrsPtr: &ipp, - StringsPtr: &ss, - Strings: ss, - }, - wantJSON: `{"Int":1234,"Addrs":["192.168.0.0/24"],"Strings":["bar"],"AddrsPtr":["192.168.0.0/24"],"StringsPtr":["bar"]}`, - }, - } - - var buf bytes.Buffer - encoder := json.NewEncoder(&buf) - encoder.SetIndent("", "") - for _, tc := range tests { - buf.Reset() - if err := encoder.Encode(&tc.in); err != nil { - t.Fatal(err) - } - b := buf.Bytes() - gotJSON := strings.TrimSpace(string(b)) - if tc.wantJSON != gotJSON { - t.Fatalf("JSON: %v; want: %v", gotJSON, tc.wantJSON) - } - var got viewStruct - if err := json.Unmarshal(b, &got); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, tc.in) { - t.Fatalf("unmarshal resulted in different output: %+v; want %+v", got, tc.in) - } - } -} - -func TestViewUtils(t *testing.T) { - v := SliceOf([]string{"foo", "bar"}) - c := qt.New(t) - - c.Check(v.ContainsFunc(func(s string) bool { return strings.HasPrefix(s, "f") }), qt.Equals, true) - c.Check(v.ContainsFunc(func(s string) bool { return strings.HasPrefix(s, "g") }), qt.Equals, false) - c.Check(v.IndexFunc(func(s string) bool { return strings.HasPrefix(s, "b") }), qt.Equals, 1) - c.Check(v.IndexFunc(func(s string) bool { return strings.HasPrefix(s, "z") }), qt.Equals, -1) - c.Check(SliceContains(v, "bar"), qt.Equals, true) - c.Check(SliceContains(v, "baz"), qt.Equals, false) - c.Check(SliceEqualAnyOrder(v, v), qt.Equals, true) - c.Check(SliceEqualAnyOrder(v, SliceOf([]string{"bar", "foo"})), qt.Equals, true) - c.Check(SliceEqualAnyOrder(v, SliceOf([]string{"foo"})), qt.Equals, false) - c.Check(SliceEqualAnyOrder(SliceOf([]string{"a", "a", "b"}), SliceOf([]string{"a", "b", "b"})), qt.Equals, false) - - c.Check(SliceEqualAnyOrder( - SliceOf([]string{"a", "b", "c"}).SliceFrom(1), - SliceOf([]string{"b", "c"})), - qt.Equals, true) - c.Check(SliceEqualAnyOrder( - SliceOf([]string{"a", "b", "c"}).Slice(1, 2), - SliceOf([]string{"b", "c"}).SliceTo(1)), - qt.Equals, true) -} - -func TestSliceEqual(t *testing.T) { - a := SliceOf([]string{"foo", "bar"}) - b := SliceOf([]string{"foo", "bar"}) - if !SliceEqual(a, b) { - t.Errorf("got a != b") - } - if !SliceEqual(a.SliceTo(0), b.SliceTo(0)) { - t.Errorf("got a[:0] != b[:0]") - } - if SliceEqual(a.SliceTo(2), a.SliceTo(1)) { - t.Error("got a[:2] == a[:1]") - } -} - -// TestSliceMapKey tests that the MapKey method returns the same key for slices -// with the same underlying slice and different keys for different slices or -// with same underlying slice but different bounds. -func TestSliceMapKey(t *testing.T) { - underlying := []string{"foo", "bar"} - nilSlice := SliceOf[string](nil) - empty := SliceOf([]string{}) - u1 := SliceOf(underlying) - u2 := SliceOf(underlying) - u3 := SliceOf([]string{"foo", "bar"}) // different underlying slice - - sub1 := u1.Slice(0, 1) - sub2 := u1.Slice(1, 2) - sub3 := u1.Slice(0, 2) - - wantSame := []Slice[string]{u1, u2, sub3} - for i := 1; i < len(wantSame); i++ { - s0, si := wantSame[0], wantSame[i] - k0 := s0.MapKey() - ki := si.MapKey() - if ki != k0 { - t.Fatalf("wantSame[%d, %+v, %q) != wantSame[0, %+v, %q)", i, ki, si.AsSlice(), k0, s0.AsSlice()) - } - } - - wantDiff := []Slice[string]{nilSlice, empty, sub1, sub2, sub3, u3} - for i := range len(wantDiff) { - for j := i + 1; j < len(wantDiff); j++ { - si, sj := wantDiff[i], wantDiff[j] - ki, kj := wantDiff[i].MapKey(), wantDiff[j].MapKey() - if ki == kj { - t.Fatalf("wantDiff[%d, %+v, %q] == wantDiff[%d, %+v, %q] ", i, ki, si.AsSlice(), j, kj, sj.AsSlice()) - } - } - } -} - -func TestContainsPointers(t *testing.T) { - tests := []struct { - name string - typ reflect.Type - wantPtrs bool - }{ - { - name: "bool", - typ: reflect.TypeFor[bool](), - wantPtrs: false, - }, - { - name: "int", - typ: reflect.TypeFor[int](), - wantPtrs: false, - }, - { - name: "int8", - typ: reflect.TypeFor[int8](), - wantPtrs: false, - }, - { - name: "int16", - typ: reflect.TypeFor[int16](), - wantPtrs: false, - }, - { - name: "int32", - typ: reflect.TypeFor[int32](), - wantPtrs: false, - }, - { - name: "int64", - typ: reflect.TypeFor[int64](), - wantPtrs: false, - }, - { - name: "uint", - typ: reflect.TypeFor[uint](), - wantPtrs: false, - }, - { - name: "uint8", - typ: reflect.TypeFor[uint8](), - wantPtrs: false, - }, - { - name: "uint16", - typ: reflect.TypeFor[uint16](), - wantPtrs: false, - }, - { - name: "uint32", - typ: reflect.TypeFor[uint32](), - wantPtrs: false, - }, - { - name: "uint64", - typ: reflect.TypeFor[uint64](), - wantPtrs: false, - }, - { - name: "uintptr", - typ: reflect.TypeFor[uintptr](), - wantPtrs: false, - }, - { - name: "string", - typ: reflect.TypeFor[string](), - wantPtrs: false, - }, - { - name: "float32", - typ: reflect.TypeFor[float32](), - wantPtrs: false, - }, - { - name: "float64", - typ: reflect.TypeFor[float64](), - wantPtrs: false, - }, - { - name: "complex64", - typ: reflect.TypeFor[complex64](), - wantPtrs: false, - }, - { - name: "complex128", - typ: reflect.TypeFor[complex128](), - wantPtrs: false, - }, - { - name: "netip-Addr", - typ: reflect.TypeFor[netip.Addr](), - wantPtrs: false, - }, - { - name: "netip-Prefix", - typ: reflect.TypeFor[netip.Prefix](), - wantPtrs: false, - }, - { - name: "netip-AddrPort", - typ: reflect.TypeFor[netip.AddrPort](), - wantPtrs: false, - }, - { - name: "bool-ptr", - typ: reflect.TypeFor[*bool](), - wantPtrs: true, - }, - { - name: "string-ptr", - typ: reflect.TypeFor[*string](), - wantPtrs: true, - }, - { - name: "netip-Addr-ptr", - typ: reflect.TypeFor[*netip.Addr](), - wantPtrs: true, - }, - { - name: "unsafe-ptr", - typ: reflect.TypeFor[unsafe.Pointer](), - wantPtrs: true, - }, - { - name: "no-ptr-struct", - typ: reflect.TypeFor[noPtrStruct](), - wantPtrs: false, - }, - { - name: "ptr-struct", - typ: reflect.TypeFor[withPtrStruct](), - wantPtrs: true, - }, - { - name: "string-array", - typ: reflect.TypeFor[[5]string](), - wantPtrs: false, - }, - { - name: "int-ptr-array", - typ: reflect.TypeFor[[5]*int](), - wantPtrs: true, - }, - { - name: "no-ptr-struct-array", - typ: reflect.TypeFor[[5]noPtrStruct](), - wantPtrs: false, - }, - { - name: "with-ptr-struct-array", - typ: reflect.TypeFor[[5]withPtrStruct](), - wantPtrs: true, - }, - { - name: "string-slice", - typ: reflect.TypeFor[[]string](), - wantPtrs: true, - }, - { - name: "int-ptr-slice", - typ: reflect.TypeFor[[]int](), - wantPtrs: true, - }, - { - name: "no-ptr-struct-slice", - typ: reflect.TypeFor[[]noPtrStruct](), - wantPtrs: true, - }, - { - name: "string-map", - typ: reflect.TypeFor[map[string]string](), - wantPtrs: true, - }, - { - name: "int-map", - typ: reflect.TypeFor[map[int]int](), - wantPtrs: true, - }, - { - name: "no-ptr-struct-map", - typ: reflect.TypeFor[map[string]noPtrStruct](), - wantPtrs: true, - }, - { - name: "chan", - typ: reflect.TypeFor[chan int](), - wantPtrs: true, - }, - { - name: "func", - typ: reflect.TypeFor[func()](), - wantPtrs: true, - }, - { - name: "interface", - typ: reflect.TypeFor[any](), - wantPtrs: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if gotPtrs := containsPointers(tt.typ); gotPtrs != tt.wantPtrs { - t.Errorf("got %v; want %v", gotPtrs, tt.wantPtrs) - } - }) - } -} - -func TestSliceRange(t *testing.T) { - sv := SliceOf([]string{"foo", "bar"}) - var got []string - for i, v := range sv.All() { - got = append(got, fmt.Sprintf("%d-%s", i, v)) - } - want := []string{"0-foo", "1-bar"} - if !slices.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } -} - -type testStruct struct{ value string } - -func (p *testStruct) Clone() *testStruct { - if p == nil { - return p - } - return &testStruct{p.value} -} -func (p *testStruct) View() testStructView { return testStructView{p} } - -type testStructView struct{ p *testStruct } - -func (v testStructView) Valid() bool { return v.p != nil } -func (v testStructView) AsStruct() *testStruct { - if v.p == nil { - return nil - } - return v.p.Clone() -} -func (v testStructView) ValueForTest() string { return v.p.value } - -func TestSliceViewRange(t *testing.T) { - vs := SliceOfViews([]*testStruct{{value: "foo"}, {value: "bar"}}) - var got []string - for i, v := range vs.All() { - got = append(got, fmt.Sprintf("%d-%s", i, v.AsStruct().value)) - } - want := []string{"0-foo", "1-bar"} - if !slices.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestMapIter(t *testing.T) { - m := MapOf(map[string]int{"foo": 1, "bar": 2}) - var got []string - for k, v := range m.All() { - got = append(got, fmt.Sprintf("%s-%d", k, v)) - } - slices.Sort(got) - want := []string{"bar-2", "foo-1"} - if !slices.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestMapSliceIter(t *testing.T) { - m := MapSliceOf(map[string][]int{"foo": {3, 4}, "bar": {1, 2}}) - var got []string - for k, v := range m.All() { - got = append(got, fmt.Sprintf("%s-%d", k, v)) - } - slices.Sort(got) - want := []string{"bar-{[1 2]}", "foo-{[3 4]}"} - if !slices.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestMapFnIter(t *testing.T) { - m := MapFnOf[string, *testStruct, testStructView](map[string]*testStruct{ - "foo": {value: "fooVal"}, - "bar": {value: "barVal"}, - }, func(p *testStruct) testStructView { return testStructView{p} }) - var got []string - for k, v := range m.All() { - got = append(got, fmt.Sprintf("%v-%v", k, v.ValueForTest())) - } - slices.Sort(got) - want := []string{"bar-barVal", "foo-fooVal"} - if !slices.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } -} diff --git a/util/cache/cache_test.go b/util/cache/cache_test.go deleted file mode 100644 index a6683e12dd772..0000000000000 --- a/util/cache/cache_test.go +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cache - -import ( - "errors" - "testing" - "time" -) - -var startTime = time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC) - -func TestSingleCache(t *testing.T) { - testTime := startTime - timeNow := func() time.Time { return testTime } - c := &Single[string, int]{ - timeNow: timeNow, - } - - t.Run("NoServeExpired", func(t *testing.T) { - testCacheImpl(t, c, &testTime, false) - }) - - t.Run("ServeExpired", func(t *testing.T) { - c.Empty() - c.ServeExpired = true - testTime = startTime - testCacheImpl(t, c, &testTime, true) - }) -} - -func TestLocking(t *testing.T) { - testTime := startTime - timeNow := func() time.Time { return testTime } - c := NewLocking(&Single[string, int]{ - timeNow: timeNow, - }) - - // Just verify that the inner cache's behaviour hasn't changed. - testCacheImpl(t, c, &testTime, false) -} - -func testCacheImpl(t *testing.T, c Cache[string, int], testTime *time.Time, serveExpired bool) { - var fillTime time.Time - t.Run("InitialFill", func(t *testing.T) { - fillTime = testTime.Add(time.Hour) - val, err := c.Get("key", func() (int, time.Time, error) { - return 123, fillTime, nil - }) - if err != nil { - t.Fatal(err) - } - if val != 123 { - t.Fatalf("got val=%d; want 123", val) - } - }) - - // Fetching again won't call our fill function - t.Run("SecondFetch", func(t *testing.T) { - *testTime = fillTime.Add(-1 * time.Second) - called := false - val, err := c.Get("key", func() (int, time.Time, error) { - called = true - return -1, fillTime, nil - }) - if called { - t.Fatal("wanted no call to fill function") - } - if err != nil { - t.Fatal(err) - } - if val != 123 { - t.Fatalf("got val=%d; want 123", val) - } - }) - - // Fetching after the expiry time will re-fill - t.Run("ReFill", func(t *testing.T) { - *testTime = fillTime.Add(1) - fillTime = fillTime.Add(time.Hour) - val, err := c.Get("key", func() (int, time.Time, error) { - return 999, fillTime, nil - }) - if err != nil { - t.Fatal(err) - } - if val != 999 { - t.Fatalf("got val=%d; want 999", val) - } - }) - - // An error on fetch will serve the expired value. - t.Run("FetchError", func(t *testing.T) { - if !serveExpired { - t.Skipf("not testing ServeExpired") - } - - *testTime = fillTime.Add(time.Hour + 1) - val, err := c.Get("key", func() (int, time.Time, error) { - return 0, time.Time{}, errors.New("some error") - }) - if err != nil { - t.Fatal(err) - } - if val != 999 { - t.Fatalf("got val=%d; want 999", val) - } - }) - - // Fetching a different key re-fills - t.Run("DifferentKey", func(t *testing.T) { - *testTime = fillTime.Add(time.Hour + 1) - - var calls int - val, err := c.Get("key1", func() (int, time.Time, error) { - calls++ - return 123, fillTime, nil - }) - if err != nil { - t.Fatal(err) - } - if val != 123 { - t.Fatalf("got val=%d; want 123", val) - } - if calls != 1 { - t.Errorf("got %d, want 1 call", calls) - } - - val, err = c.Get("key2", func() (int, time.Time, error) { - calls++ - return 456, fillTime, nil - }) - if err != nil { - t.Fatal(err) - } - if val != 456 { - t.Fatalf("got val=%d; want 456", val) - } - if calls != 2 { - t.Errorf("got %d, want 2 call", calls) - } - }) - - // Calling Forget with the wrong key does nothing, and with the correct - // key will drop the cache. - t.Run("Forget", func(t *testing.T) { - // Add some time so that previously-cached values don't matter. - fillTime = testTime.Add(2 * time.Hour) - *testTime = fillTime.Add(-1 * time.Second) - - const key = "key" - - var calls int - val, err := c.Get(key, func() (int, time.Time, error) { - calls++ - return 123, fillTime, nil - }) - if err != nil { - t.Fatal(err) - } - if val != 123 { - t.Fatalf("got val=%d; want 123", val) - } - if calls != 1 { - t.Errorf("got %d, want 1 call", calls) - } - - // Forgetting the wrong key does nothing - c.Forget("other") - val, err = c.Get(key, func() (int, time.Time, error) { - t.Fatal("should not be called") - panic("unreachable") - }) - if err != nil { - t.Fatal(err) - } - if val != 123 { - t.Fatalf("got val=%d; want 123", val) - } - - // Forgetting the correct key re-fills - c.Forget(key) - - val, err = c.Get("key2", func() (int, time.Time, error) { - calls++ - return 456, fillTime, nil - }) - if err != nil { - t.Fatal(err) - } - if val != 456 { - t.Fatalf("got val=%d; want 456", val) - } - if calls != 2 { - t.Errorf("got %d, want 2 call", calls) - } - }) -} diff --git a/util/clientmetric/clientmetric.go b/util/clientmetric/clientmetric.go index 584a24f73dca8..b823d7671b887 100644 --- a/util/clientmetric/clientmetric.go +++ b/util/clientmetric/clientmetric.go @@ -18,7 +18,7 @@ import ( "sync/atomic" "time" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/util/set" ) var ( diff --git a/util/clientmetric/clientmetric_test.go b/util/clientmetric/clientmetric_test.go deleted file mode 100644 index 555d7a71170a4..0000000000000 --- a/util/clientmetric/clientmetric_test.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package clientmetric - -import ( - "expvar" - "testing" - "time" - - qt "github.com/frankban/quicktest" -) - -func TestDeltaEncBuf(t *testing.T) { - var enc deltaEncBuf - enc.writeName("one_one", TypeCounter) - enc.writeValue(1, 1) - enc.writeName("two_zero", TypeGauge) - enc.writeValue(2, 0) - - enc.writeDelta(1, 63) - enc.writeDelta(2, 64) - enc.writeDelta(1, -65) - enc.writeDelta(2, -64) - - got := enc.buf.String() - const want = "N0eone_oneS0202N1cgauge_two_zeroS0400I027eI048001I028101I047f" - if got != want { - t.Errorf("error\n got %q\nwant %q\n", got, want) - } -} - -func clearMetrics() { - mu.Lock() - defer mu.Unlock() - metrics = map[string]*Metric{} - numWireID = 0 - lastDelta = time.Time{} - sorted = nil - lastLogVal = nil - unsorted = nil -} - -func advanceTime() { - mu.Lock() - defer mu.Unlock() - lastDelta = time.Time{} -} - -func TestEncodeLogTailMetricsDelta(t *testing.T) { - clearMetrics() - - c1 := NewCounter("foo") - c2 := NewGauge("bar") - c1.Add(123) - if got, want := EncodeLogTailMetricsDelta(), "N06fooS02f601"; got != want { - t.Errorf("first = %q; want %q", got, want) - } - - c2.Add(456) - advanceTime() - if got, want := EncodeLogTailMetricsDelta(), "N12gauge_barS049007"; got != want { - t.Errorf("second = %q; want %q", got, want) - } - - advanceTime() - if got, want := EncodeLogTailMetricsDelta(), ""; got != want { - t.Errorf("with no changes = %q; want %q", got, want) - } - - c1.Add(1) - c2.Add(2) - advanceTime() - if got, want := EncodeLogTailMetricsDelta(), "I0202I0404"; got != want { - t.Errorf("with increments = %q; want %q", got, want) - } -} - -func TestDisableDeltas(t *testing.T) { - clearMetrics() - - c := NewCounter("foo") - c.DisableDeltas() - c.Set(123) - - if got, want := EncodeLogTailMetricsDelta(), "N06fooS02f601"; got != want { - t.Errorf("first = %q; want %q", got, want) - } - - c.Set(456) - advanceTime() - if got, want := EncodeLogTailMetricsDelta(), "S029007"; got != want { - t.Errorf("second = %q; want %q", got, want) - } -} - -func TestWithFunc(t *testing.T) { - clearMetrics() - - v := int64(123) - NewCounterFunc("foo", func() int64 { return v }) - - if got, want := EncodeLogTailMetricsDelta(), "N06fooS02f601"; got != want { - t.Errorf("first = %q; want %q", got, want) - } - - v = 456 - advanceTime() - if got, want := EncodeLogTailMetricsDelta(), "I029a05"; got != want { - t.Errorf("second = %q; want %q", got, want) - } -} - -func TestAggregateCounter(t *testing.T) { - clearMetrics() - - c := qt.New(t) - - expv1 := &expvar.Int{} - expv2 := &expvar.Int{} - expv3 := &expvar.Int{} - - aggCounter := NewAggregateCounter("agg_counter") - - aggCounter.Register(expv1) - c.Assert(aggCounter.Value(), qt.Equals, int64(0)) - - expv1.Add(1) - c.Assert(aggCounter.Value(), qt.Equals, int64(1)) - - aggCounter.Register(expv2) - c.Assert(aggCounter.Value(), qt.Equals, int64(1)) - - expv1.Add(1) - expv2.Add(1) - c.Assert(aggCounter.Value(), qt.Equals, int64(3)) - - // Adding a new expvar should not change the value - // and any value the counter already had is reset - expv3.Set(5) - aggCounter.Register(expv3) - c.Assert(aggCounter.Value(), qt.Equals, int64(3)) - - // Registering the same expvar multiple times should not change the value - aggCounter.Register(expv3) - c.Assert(aggCounter.Value(), qt.Equals, int64(3)) - - aggCounter.UnregisterAll() - c.Assert(aggCounter.Value(), qt.Equals, int64(0)) - - // Start over - expv3.Set(5) - aggCounter.Register(expv3) - c.Assert(aggCounter.Value(), qt.Equals, int64(0)) - - expv3.Set(5) - c.Assert(aggCounter.Value(), qt.Equals, int64(5)) -} diff --git a/util/cloudenv/cloudenv.go b/util/cloudenv/cloudenv.go index be60ca0070e54..4da18e4628e0a 100644 --- a/util/cloudenv/cloudenv.go +++ b/util/cloudenv/cloudenv.go @@ -16,8 +16,8 @@ import ( "strings" "time" - "tailscale.com/syncs" - "tailscale.com/types/lazy" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/lazy" ) // CommonNonRoutableMetadataIP is the IP address of the metadata server diff --git a/util/cloudenv/cloudenv_test.go b/util/cloudenv/cloudenv_test.go deleted file mode 100644 index c4486b2841ec1..0000000000000 --- a/util/cloudenv/cloudenv_test.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cloudenv - -import ( - "flag" - "net/netip" - "testing" -) - -var extNetwork = flag.Bool("use-external-network", false, "use the external network in tests") - -// Informational only since we can run tests in a variety of places. -func TestGetCloud(t *testing.T) { - if !*extNetwork { - t.Skip("skipping test without --use-external-network") - } - - cloud := getCloud() - t.Logf("Cloud: %q", cloud) - t.Logf("Cloud.HasInternalTLD: %v", cloud.HasInternalTLD()) - t.Logf("Cloud.ResolverIP: %q", cloud.ResolverIP()) -} - -func TestGetDigitalOceanResolver(t *testing.T) { - addr := netip.MustParseAddr(getDigitalOceanResolver()) - t.Logf("got: %v", addr) -} diff --git a/util/cmpver/version_test.go b/util/cmpver/version_test.go deleted file mode 100644 index 8a3e470d1d37f..0000000000000 --- a/util/cmpver/version_test.go +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cmpver_test - -import ( - "testing" - - "tailscale.com/util/cmpver" -) - -func TestCompare(t *testing.T) { - tests := []struct { - name string - v1, v2 string - want int - }{ - { - name: "both empty", - want: 0, - }, - { - name: "v1 empty", - v2: "1.2.3", - want: -1, - }, - { - name: "v2 empty", - v1: "1.2.3", - want: 1, - }, - - { - name: "semver major", - v1: "2.0.0", - v2: "1.9.9", - want: 1, - }, - { - name: "semver major", - v1: "2.0.0", - v2: "1.9.9", - want: 1, - }, - { - name: "semver minor", - v1: "1.9.0", - v2: "1.8.9", - want: 1, - }, - { - name: "semver patch", - v1: "1.9.9", - v2: "1.9.8", - want: 1, - }, - { - name: "semver equal", - v1: "1.9.8", - v2: "1.9.8", - want: 0, - }, - - { - name: "tailscale major", - v1: "1.0-0", - v2: "0.97-105", - want: 1, - }, - { - name: "tailscale minor", - v1: "0.98-0", - v2: "0.97-105", - want: 1, - }, - { - name: "tailscale patch", - v1: "0.97-120", - v2: "0.97-105", - want: 1, - }, - { - name: "tailscale equal", - v1: "0.97-105", - v2: "0.97-105", - want: 0, - }, - { - name: "tailscale weird extra field", - v1: "0.96.1-0", // more fields == larger - v2: "0.96-105", - want: 1, - }, - { - // Though ۱ and ۲ both satisfy unicode.IsNumber, our previous use - // of strconv.ParseUint with these characters would have lead us to - // panic. We're now only looking at ascii numbers, so test these are - // compared as text. - name: "only ascii numbers", - v1: "۱۱", // 2x EXTENDED ARABIC-INDIC DIGIT ONE - v2: "۲", // 1x EXTENDED ARABIC-INDIC DIGIT TWO - want: -1, - }, - - // A few specific OS version tests below. - { - name: "windows version", - v1: "10.0.19045.3324", - v2: "10.0.18362", - want: 1, - }, - { - name: "windows 11 is everything above 10.0.22000", - v1: "10.0.22631.2262", - v2: "10.0.22000", - want: 1, - }, - { - name: "android short version", - v1: "10", - v2: "7", - want: 1, - }, - { - name: "android longer version", - v1: "7.1.2", - v2: "7", - want: 1, - }, - { - name: "iOS version", - v1: "15.6.1", - v2: "15.6", - want: 1, - }, - { - name: "Linux short kernel version", - v1: "4.4.302+", - v2: "4.0", - want: 1, - }, - { - name: "Linux long kernel version", - v1: "4.14.255-311-248.529.amzn2.x86_64", - v2: "4.0", - want: 1, - }, - { - name: "FreeBSD version", - v1: "14.0-CURRENT", - v2: "14", - want: 1, - }, - { - name: "Synology version", - v1: "Synology 6.2.4; kernel=3.10.105", - v2: "Synology 6", - want: 1, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got := cmpver.Compare(test.v1, test.v2) - if got != test.want { - t.Errorf("Compare(%q, %q) = %v, want %v", test.v1, test.v2, got, test.want) - } - - // Reversing the comparison should reverse the outcome. - got2 := cmpver.Compare(test.v2, test.v1) - if got2 != -test.want { - t.Errorf("Compare(%q, %q) = %v, want %v", test.v2, test.v1, got2, -test.want) - } - - if got, want := cmpver.Less(test.v1, test.v2), test.want < 0; got != want { - t.Errorf("Less(%q, %q) = %v, want %v", test.v1, test.v2, got, want) - } - if got, want := cmpver.Less(test.v2, test.v1), test.want > 0; got != want { - t.Errorf("Less(%q, %q) = %v, want %v", test.v2, test.v1, got, want) - } - if got, want := cmpver.LessEq(test.v1, test.v2), test.want <= 0; got != want { - t.Errorf("LessEq(%q, %q) = %v, want %v", test.v1, test.v2, got, want) - } - if got, want := cmpver.LessEq(test.v2, test.v1), test.want >= 0; got != want { - t.Errorf("LessEq(%q, %q) = %v, want %v", test.v2, test.v1, got, want) - } - - // Check that version comparison does not allocate. - if n := testing.AllocsPerRun(100, func() { cmpver.Compare(test.v1, test.v2) }); n > 0 { - t.Errorf("Compare(%q, %q) got %v allocs per run", test.v1, test.v2, n) - } - }) - } -} diff --git a/util/codegen/codegen.go b/util/codegen/codegen.go index 1b3af10e03ee1..a916c74e73c8a 100644 --- a/util/codegen/codegen.go +++ b/util/codegen/codegen.go @@ -16,9 +16,9 @@ import ( "reflect" "strings" + "github.com/sagernet/tailscale/util/mak" "golang.org/x/tools/go/packages" "golang.org/x/tools/imports" - "tailscale.com/util/mak" ) var flagCopyright = flag.Bool("copyright", true, "add Tailscale copyright to generated file headers") diff --git a/util/codegen/codegen_test.go b/util/codegen/codegen_test.go deleted file mode 100644 index 74715eecae6ef..0000000000000 --- a/util/codegen/codegen_test.go +++ /dev/null @@ -1,470 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package codegen - -import ( - "cmp" - "go/types" - "net/netip" - "strings" - "sync" - "testing" - "time" - "unique" - "unsafe" - - "golang.org/x/exp/constraints" -) - -type AnyParam[T any] struct { - V T -} - -type AnyParamPhantom[T any] struct { -} - -type IntegerParam[T constraints.Integer] struct { - V T -} - -type FloatParam[T constraints.Float] struct { - V T -} - -type StringLikeParam[T ~string] struct { - V T -} - -type BasicType interface { - ~bool | constraints.Integer | constraints.Float | constraints.Complex | ~string -} - -type BasicTypeParam[T BasicType] struct { - V T -} - -type IntPtr *int - -type IntPtrParam[T IntPtr] struct { - V T -} - -type IntegerPtr interface { - *int | *int32 | *int64 -} - -type IntegerPtrParam[T IntegerPtr] struct { - V T -} - -type IntegerParamPtr[T constraints.Integer] struct { - V *T -} - -type IntegerSliceParam[T constraints.Integer] struct { - V []T -} - -type IntegerMapParam[T constraints.Integer] struct { - V []T -} - -type UnsafePointerParam[T unsafe.Pointer] struct { - V T -} - -type ValueUnionParam[T netip.Prefix | BasicType] struct { - V T -} - -type ValueUnionParamPtr[T netip.Prefix | BasicType] struct { - V *T -} - -type PointerUnionParam[T netip.Prefix | BasicType | IntPtr] struct { - V T -} - -type StructWithUniqueHandle struct{ _ unique.Handle[[32]byte] } - -type StructWithTime struct{ _ time.Time } - -type StructWithNetipTypes struct { - _ netip.Addr - _ netip.AddrPort - _ netip.Prefix -} - -type Interface interface { - Method() -} - -type InterfaceParam[T Interface] struct { - V T -} - -func TestGenericContainsPointers(t *testing.T) { - tests := []struct { - typ string - wantPointer bool - }{ - { - typ: "AnyParam", - wantPointer: true, - }, - { - typ: "AnyParamPhantom", - wantPointer: false, // has a pointer type parameter, but no pointer fields - }, - { - typ: "IntegerParam", - wantPointer: false, - }, - { - typ: "FloatParam", - wantPointer: false, - }, - { - typ: "StringLikeParam", - wantPointer: false, - }, - { - typ: "BasicTypeParam", - wantPointer: false, - }, - { - typ: "IntPtrParam", - wantPointer: true, - }, - { - typ: "IntegerPtrParam", - wantPointer: true, - }, - { - typ: "IntegerParamPtr", - wantPointer: true, - }, - { - typ: "IntegerSliceParam", - wantPointer: true, - }, - { - typ: "IntegerMapParam", - wantPointer: true, - }, - { - typ: "UnsafePointerParam", - wantPointer: true, - }, - { - typ: "InterfaceParam", - wantPointer: true, - }, - { - typ: "ValueUnionParam", - wantPointer: false, - }, - { - typ: "ValueUnionParamPtr", - wantPointer: true, - }, - { - typ: "PointerUnionParam", - wantPointer: true, - }, - { - typ: "StructWithUniqueHandle", - wantPointer: false, - }, - { - typ: "StructWithTime", - wantPointer: false, - }, - { - typ: "StructWithNetipTypes", - wantPointer: false, - }, - } - - for _, tt := range tests { - t.Run(tt.typ, func(t *testing.T) { - typ := lookupTestType(t, tt.typ) - if isPointer := ContainsPointers(typ); isPointer != tt.wantPointer { - t.Fatalf("ContainsPointers: got %v, want: %v", isPointer, tt.wantPointer) - } - }) - } -} - -func TestAssertStructUnchanged(t *testing.T) { - type args struct { - t *types.Struct - tname string - params *types.TypeParamList - ctx string - it *ImportTracker - } - - // package t1 with a struct T1 with two fields - p1 := types.NewPackage("t1", "t1") - t1 := types.NewNamed(types.NewTypeName(0, p1, "T1", nil), types.NewStruct([]*types.Var{ - types.NewField(0, nil, "P1", types.Typ[types.Int], false), - types.NewField(0, nil, "P2", types.Typ[types.String], false), - }, nil), nil) - p1.Scope().Insert(t1.Obj()) - - tests := []struct { - name string - args args - want []byte - }{ - { - name: "t1-internally_defined", - args: args{ - t: t1.Underlying().(*types.Struct), - tname: "prefix_", - params: nil, - ctx: "", - it: NewImportTracker(p1), - }, - want: []byte("var _prefix_NeedsRegeneration = prefix_(struct {\n\tP1 int \n\tP2 string \n}{})"), - }, - { - name: "t2-with_named_field", - args: args{ - t: types.NewStruct([]*types.Var{ - types.NewField(0, nil, "T1", t1, false), - types.NewField(0, nil, "P1", types.Typ[types.Int], false), - types.NewField(0, nil, "P2", types.Typ[types.String], false), - }, nil), - tname: "prefix_", - params: nil, - ctx: "", - it: NewImportTracker(types.NewPackage("t2", "t2")), - }, - // the struct should be regenerated with the named field - want: []byte("var _prefix_NeedsRegeneration = prefix_(struct {\n\tT1 t1.T1 \n\tP1 int \n\tP2 string \n}{})"), - }, - { - name: "t3-with_embedded_field", - args: args{ - t: types.NewStruct([]*types.Var{ - types.NewField(0, nil, "T1", t1, true), - types.NewField(0, nil, "P1", types.Typ[types.Int], false), - types.NewField(0, nil, "P2", types.Typ[types.String], false), - }, nil), - tname: "prefix_", - params: nil, - ctx: "", - it: NewImportTracker(types.NewPackage("t3", "t3")), - }, - // the struct should be regenerated with the embedded field - want: []byte("var _prefix_NeedsRegeneration = prefix_(struct {\n\tt1.T1 \n\tP1 int \n\tP2 string \n}{})"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := AssertStructUnchanged(tt.args.t, tt.args.tname, tt.args.params, tt.args.ctx, tt.args.it); !strings.Contains(string(got), string(tt.want)) { - t.Errorf("AssertStructUnchanged() = \n%s\nwant: \n%s", string(got), string(tt.want)) - } - }) - } -} - -type NamedType struct{} - -func (NamedType) Method() {} - -type NamedTypeAlias = NamedType - -type NamedInterface interface { - Method() -} - -type NamedInterfaceAlias = NamedInterface - -type GenericType[T NamedInterface] struct { - TypeParamField T - TypeParamPtrField *T -} - -type GenericTypeWithAliasConstraint[T NamedInterfaceAlias] struct { - TypeParamField T - TypeParamPtrField *T -} - -func TestLookupMethod(t *testing.T) { - tests := []struct { - name string - typ types.Type - methodName string - wantHasMethod bool - wantReceiver types.Type - }{ - { - name: "NamedType/HasMethod", - typ: lookupTestType(t, "NamedType"), - methodName: "Method", - wantHasMethod: true, - }, - { - name: "NamedType/NoMethod", - typ: lookupTestType(t, "NamedType"), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "NamedTypeAlias/HasMethod", - typ: lookupTestType(t, "NamedTypeAlias"), - methodName: "Method", - wantHasMethod: true, - wantReceiver: lookupTestType(t, "NamedType"), - }, - { - name: "NamedTypeAlias/NoMethod", - typ: lookupTestType(t, "NamedTypeAlias"), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "PtrToNamedType/HasMethod", - typ: types.NewPointer(lookupTestType(t, "NamedType")), - methodName: "Method", - wantHasMethod: true, - wantReceiver: lookupTestType(t, "NamedType"), - }, - { - name: "PtrToNamedType/NoMethod", - typ: types.NewPointer(lookupTestType(t, "NamedType")), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "PtrToNamedTypeAlias/HasMethod", - typ: types.NewPointer(lookupTestType(t, "NamedTypeAlias")), - methodName: "Method", - wantHasMethod: true, - wantReceiver: lookupTestType(t, "NamedType"), - }, - { - name: "PtrToNamedTypeAlias/NoMethod", - typ: types.NewPointer(lookupTestType(t, "NamedTypeAlias")), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "NamedInterface/HasMethod", - typ: lookupTestType(t, "NamedInterface"), - methodName: "Method", - wantHasMethod: true, - }, - { - name: "NamedInterface/NoMethod", - typ: lookupTestType(t, "NamedInterface"), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "Interface/HasMethod", - typ: types.NewInterfaceType([]*types.Func{types.NewFunc(0, nil, "Method", types.NewSignatureType(nil, nil, nil, nil, nil, false))}, nil), - methodName: "Method", - wantHasMethod: true, - }, - { - name: "Interface/NoMethod", - typ: types.NewInterfaceType(nil, nil), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "TypeParam/HasMethod", - typ: lookupTestType(t, "GenericType").Underlying().(*types.Struct).Field(0).Type(), - methodName: "Method", - wantHasMethod: true, - wantReceiver: lookupTestType(t, "NamedInterface"), - }, - { - name: "TypeParam/NoMethod", - typ: lookupTestType(t, "GenericType").Underlying().(*types.Struct).Field(0).Type(), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "TypeParamPtr/HasMethod", - typ: lookupTestType(t, "GenericType").Underlying().(*types.Struct).Field(1).Type(), - methodName: "Method", - wantHasMethod: true, - wantReceiver: lookupTestType(t, "NamedInterface"), - }, - { - name: "TypeParamPtr/NoMethod", - typ: lookupTestType(t, "GenericType").Underlying().(*types.Struct).Field(1).Type(), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "TypeParamWithAlias/HasMethod", - typ: lookupTestType(t, "GenericTypeWithAliasConstraint").Underlying().(*types.Struct).Field(0).Type(), - methodName: "Method", - wantHasMethod: true, - wantReceiver: lookupTestType(t, "NamedInterface"), - }, - { - name: "TypeParamWithAlias/NoMethod", - typ: lookupTestType(t, "GenericTypeWithAliasConstraint").Underlying().(*types.Struct).Field(0).Type(), - methodName: "NoMethod", - wantHasMethod: false, - }, - { - name: "TypeParamWithAliasPtr/HasMethod", - typ: lookupTestType(t, "GenericTypeWithAliasConstraint").Underlying().(*types.Struct).Field(1).Type(), - methodName: "Method", - wantHasMethod: true, - wantReceiver: lookupTestType(t, "NamedInterface"), - }, - { - name: "TypeParamWithAliasPtr/NoMethod", - typ: lookupTestType(t, "GenericTypeWithAliasConstraint").Underlying().(*types.Struct).Field(1).Type(), - methodName: "NoMethod", - wantHasMethod: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotMethod := LookupMethod(tt.typ, tt.methodName) - if gotHasMethod := gotMethod != nil; gotHasMethod != tt.wantHasMethod { - t.Fatalf("HasMethod: got %v; want %v", gotMethod, tt.wantHasMethod) - } - if gotMethod == nil { - return - } - if gotMethod.Name() != tt.methodName { - t.Errorf("Name: got %v; want %v", gotMethod.Name(), tt.methodName) - } - if gotRecv, wantRecv := gotMethod.Signature().Recv().Type(), cmp.Or(tt.wantReceiver, tt.typ); !types.Identical(gotRecv, wantRecv) { - t.Errorf("Recv: got %v; want %v", gotRecv, wantRecv) - } - }) - } -} - -var namedTestTypes = sync.OnceValues(func() (map[string]types.Type, error) { - _, namedTypes, err := LoadTypes("test", ".") - return namedTypes, err -}) - -func lookupTestType(t *testing.T, name string) types.Type { - t.Helper() - types, err := namedTestTypes() - if err != nil { - t.Fatal(err) - } - typ, ok := types[name] - if !ok { - t.Fatalf("type %q is not declared in the current package", name) - } - return typ -} diff --git a/util/cstruct/cstruct_example_test.go b/util/cstruct/cstruct_example_test.go deleted file mode 100644 index 17032267b9dc6..0000000000000 --- a/util/cstruct/cstruct_example_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Only built on 64-bit platforms to avoid complexity - -//go:build amd64 || arm64 || mips64le || ppc64le || riscv64 - -package cstruct - -import "fmt" - -// This test provides a semi-realistic example of how you can -// use this package to decode a C structure. -func ExampleDecoder() { - // Our example C structure: - // struct mystruct { - // char *p; - // char c; - // /* implicit: char _pad[3]; */ - // int x; - // }; - // - // The Go structure definition: - type myStruct struct { - Ptr uintptr - Ch byte - Intval uint32 - } - - // Our "in-memory" version of the above structure - buf := []byte{ - 1, 2, 3, 4, 0, 0, 0, 0, // ptr - 5, // ch - 99, 99, 99, // padding - 78, 6, 0, 0, // x - } - d := NewDecoder(buf) - - // Decode the structure; if one of these function returns an error, - // then subsequent decoder functions will return the zero value. - var x myStruct - x.Ptr = d.Uintptr() - x.Ch = d.Byte() - x.Intval = d.Uint32() - - // Note that per the Go language spec: - // [...] when evaluating the operands of an expression, assignment, - // or return statement, all function calls, method calls, and - // (channel) communication operations are evaluated in lexical - // left-to-right order - // - // Since each field is assigned via a function call, one could use the - // following snippet to decode the struct. - // x := myStruct{ - // Ptr: d.Uintptr(), - // Ch: d.Byte(), - // Intval: d.Uint32(), - // } - // - // However, this means that reordering the fields in the initialization - // statement–normally a semantically identical operation–would change - // the way the structure is parsed. Thus we do it as above with - // explicit ordering. - - // After finishing with the decoder, check errors - if err := d.Err(); err != nil { - panic(err) - } - - // Print the decoder offset and structure - fmt.Printf("off=%d struct=%#v\n", d.Offset(), x) - // Output: off=16 struct=cstruct.myStruct{Ptr:0x4030201, Ch:0x5, Intval:0x64e} -} diff --git a/util/cstruct/cstruct_test.go b/util/cstruct/cstruct_test.go deleted file mode 100644 index 5a75f338502bc..0000000000000 --- a/util/cstruct/cstruct_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package cstruct - -import ( - "errors" - "fmt" - "io" - "testing" -) - -func TestPadBytes(t *testing.T) { - testCases := []struct { - offset int - size int - want int - }{ - // No padding at beginning of structure - {0, 1, 0}, - {0, 2, 0}, - {0, 4, 0}, - {0, 8, 0}, - - // No padding for single bytes - {1, 1, 0}, - - // Single byte padding - {1, 2, 1}, - {3, 4, 1}, - - // Multi-byte padding - {1, 4, 3}, - {2, 8, 6}, - } - for _, tc := range testCases { - t.Run(fmt.Sprintf("%d_%d_%d", tc.offset, tc.size, tc.want), func(t *testing.T) { - got := padBytes(tc.offset, tc.size) - if got != tc.want { - t.Errorf("got=%d; want=%d", got, tc.want) - } - }) - } -} - -func TestDecoder(t *testing.T) { - t.Run("UnsignedTypes", func(t *testing.T) { - dec := func(n int) *Decoder { - buf := make([]byte, n) - buf[0] = 1 - - d := NewDecoder(buf) - - // Use t.Cleanup to perform an assertion on this - // decoder after the test code is finished with it. - t.Cleanup(func() { - if err := d.Err(); err != nil { - t.Fatal(err) - } - }) - return d - } - if got := dec(2).Uint16(); got != 1 { - t.Errorf("uint16: got=%d; want=1", got) - } - if got := dec(4).Uint32(); got != 1 { - t.Errorf("uint32: got=%d; want=1", got) - } - if got := dec(8).Uint64(); got != 1 { - t.Errorf("uint64: got=%d; want=1", got) - } - if got := dec(pointerSize / 8).Uintptr(); got != 1 { - t.Errorf("uintptr: got=%d; want=1", got) - } - }) - - t.Run("SignedTypes", func(t *testing.T) { - dec := func(n int) *Decoder { - // Make a buffer of the exact size that consists of 0xff bytes - buf := make([]byte, n) - for i := range n { - buf[i] = 0xff - } - - d := NewDecoder(buf) - - // Use t.Cleanup to perform an assertion on this - // decoder after the test code is finished with it. - t.Cleanup(func() { - if err := d.Err(); err != nil { - t.Fatal(err) - } - }) - return d - } - if got := dec(2).Int16(); got != -1 { - t.Errorf("int16: got=%d; want=-1", got) - } - if got := dec(4).Int32(); got != -1 { - t.Errorf("int32: got=%d; want=-1", got) - } - if got := dec(8).Int64(); got != -1 { - t.Errorf("int64: got=%d; want=-1", got) - } - }) - - t.Run("InsufficientData", func(t *testing.T) { - dec := func(n int) *Decoder { - // Make a buffer that's too small and contains arbitrary bytes - buf := make([]byte, n-1) - for i := range n - 1 { - buf[i] = 0xAD - } - - // Use t.Cleanup to perform an assertion on this - // decoder after the test code is finished with it. - d := NewDecoder(buf) - t.Cleanup(func() { - if err := d.Err(); err == nil || !errors.Is(err, io.EOF) { - t.Errorf("(n=%d) expected io.EOF; got=%v", n, err) - } - }) - return d - } - - dec(2).Uint16() - dec(4).Uint32() - dec(8).Uint64() - dec(pointerSize / 8).Uintptr() - - dec(2).Int16() - dec(4).Int32() - dec(8).Int64() - }) - - t.Run("Bytes", func(t *testing.T) { - d := NewDecoder([]byte("hello worldasdf")) - t.Cleanup(func() { - if err := d.Err(); err != nil { - t.Fatal(err) - } - }) - - buf := make([]byte, 11) - d.Bytes(buf) - if got := string(buf); got != "hello world" { - t.Errorf("bytes: got=%q; want=%q", got, "hello world") - } - }) -} diff --git a/util/ctxkey/key_test.go b/util/ctxkey/key_test.go deleted file mode 100644 index 20d85a3c0d2ae..0000000000000 --- a/util/ctxkey/key_test.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ctxkey - -import ( - "context" - "fmt" - "io" - "regexp" - "testing" - "time" - - qt "github.com/frankban/quicktest" -) - -func TestKey(t *testing.T) { - c := qt.New(t) - ctx := context.Background() - - // Test keys with the same name as being distinct. - k1 := New("same.Name", "") - c.Assert(k1.String(), qt.Equals, "same.Name") - k2 := New("same.Name", "") - c.Assert(k2.String(), qt.Equals, "same.Name") - c.Assert(k1 == k2, qt.Equals, false) - ctx = k1.WithValue(ctx, "hello") - c.Assert(k1.Has(ctx), qt.Equals, true) - c.Assert(k1.Value(ctx), qt.Equals, "hello") - c.Assert(k2.Has(ctx), qt.Equals, false) - c.Assert(k2.Value(ctx), qt.Equals, "") - ctx = k2.WithValue(ctx, "goodbye") - c.Assert(k1.Has(ctx), qt.Equals, true) - c.Assert(k1.Value(ctx), qt.Equals, "hello") - c.Assert(k2.Has(ctx), qt.Equals, true) - c.Assert(k2.Value(ctx), qt.Equals, "goodbye") - - // Test default value. - k3 := New("mapreduce.Timeout", time.Hour) - c.Assert(k3.Has(ctx), qt.Equals, false) - c.Assert(k3.Value(ctx), qt.Equals, time.Hour) - ctx = k3.WithValue(ctx, time.Minute) - c.Assert(k3.Has(ctx), qt.Equals, true) - c.Assert(k3.Value(ctx), qt.Equals, time.Minute) - - // Test incomparable value. - k4 := New("slice", []int(nil)) - c.Assert(k4.Has(ctx), qt.Equals, false) - c.Assert(k4.Value(ctx), qt.DeepEquals, []int(nil)) - ctx = k4.WithValue(ctx, []int{1, 2, 3}) - c.Assert(k4.Has(ctx), qt.Equals, true) - c.Assert(k4.Value(ctx), qt.DeepEquals, []int{1, 2, 3}) - - // Accessors should be allocation free. - c.Assert(testing.AllocsPerRun(100, func() { - k1.Value(ctx) - k1.Has(ctx) - k1.ValueOk(ctx) - }), qt.Equals, 0.0) - - // Test keys that are created without New. - var k5 Key[string] - c.Assert(k5.String(), qt.Equals, "string") - c.Assert(k1 == k5, qt.Equals, false) // should be different from key created by New - c.Assert(k5.Has(ctx), qt.Equals, false) - ctx = k5.WithValue(ctx, "fizz") - c.Assert(k5.Value(ctx), qt.Equals, "fizz") - var k6 Key[string] - c.Assert(k6.String(), qt.Equals, "string") - c.Assert(k5 == k6, qt.Equals, true) - c.Assert(k6.Has(ctx), qt.Equals, true) - ctx = k6.WithValue(ctx, "fizz") - - // Test interface value types. - var k7 Key[any] - c.Assert(k7.Has(ctx), qt.Equals, false) - ctx = k7.WithValue(ctx, "whatever") - c.Assert(k7.Value(ctx), qt.DeepEquals, "whatever") - ctx = k7.WithValue(ctx, []int{1, 2, 3}) - c.Assert(k7.Value(ctx), qt.DeepEquals, []int{1, 2, 3}) - ctx = k7.WithValue(ctx, nil) - c.Assert(k7.Has(ctx), qt.Equals, true) - c.Assert(k7.Value(ctx), qt.DeepEquals, nil) - k8 := New[error]("error", io.EOF) - c.Assert(k8.Has(ctx), qt.Equals, false) - c.Assert(k8.Value(ctx), qt.Equals, io.EOF) - ctx = k8.WithValue(ctx, nil) - c.Assert(k8.Value(ctx), qt.Equals, nil) - c.Assert(k8.Has(ctx), qt.Equals, true) - err := fmt.Errorf("read error: %w", io.ErrUnexpectedEOF) - ctx = k8.WithValue(ctx, err) - c.Assert(k8.Value(ctx), qt.Equals, err) - c.Assert(k8.Has(ctx), qt.Equals, true) -} - -func TestStringer(t *testing.T) { - t.SkipNow() // TODO(https://go.dev/cl/555697): Enable this after fix is merged upstream. - c := qt.New(t) - ctx := context.Background() - c.Assert(fmt.Sprint(New("foo.Bar", "").WithValue(ctx, "baz")), qt.Matches, regexp.MustCompile("foo.Bar.*baz")) - c.Assert(fmt.Sprint(New("", []int{}).WithValue(ctx, []int{1, 2, 3})), qt.Matches, regexp.MustCompile(fmt.Sprintf("%[1]T.*%[1]v", []int{1, 2, 3}))) - c.Assert(fmt.Sprint(New("", 0).WithValue(ctx, 5)), qt.Matches, regexp.MustCompile("int.*5")) - c.Assert(fmt.Sprint(Key[time.Duration]{}.WithValue(ctx, time.Hour)), qt.Matches, regexp.MustCompile(fmt.Sprintf("%[1]T.*%[1]v", time.Hour))) -} diff --git a/util/deephash/deephash.go b/util/deephash/deephash.go index 29f47e3386ebd..5343bd473ef34 100644 --- a/util/deephash/deephash.go +++ b/util/deephash/deephash.go @@ -71,8 +71,8 @@ import ( "sync" "time" - "tailscale.com/util/hashx" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/util/hashx" + "github.com/sagernet/tailscale/util/set" ) // There is much overlap between the theory of serialization and hashing. diff --git a/util/deephash/deephash_test.go b/util/deephash/deephash_test.go deleted file mode 100644 index d5584def33937..0000000000000 --- a/util/deephash/deephash_test.go +++ /dev/null @@ -1,1140 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package deephash - -import ( - "archive/tar" - "crypto/sha256" - "encoding/binary" - "fmt" - "hash" - "math" - "math/bits" - "math/rand" - "net/netip" - "reflect" - "runtime" - "testing" - "testing/quick" - "time" - - qt "github.com/frankban/quicktest" - "go4.org/mem" - "go4.org/netipx" - "tailscale.com/tailcfg" - "tailscale.com/types/dnstype" - "tailscale.com/types/ipproto" - "tailscale.com/types/key" - "tailscale.com/types/ptr" - "tailscale.com/types/views" - "tailscale.com/util/deephash/testtype" - "tailscale.com/util/dnsname" - "tailscale.com/util/hashx" - "tailscale.com/version" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/router" - "tailscale.com/wgengine/wgcfg" -) - -type appendBytes []byte - -func (p appendBytes) AppendTo(b []byte) []byte { - return append(b, p...) -} - -type selfHasherValueRecv struct { - emit uint64 -} - -func (s selfHasherValueRecv) Hash(h *hashx.Block512) { - h.HashUint64(s.emit) -} - -type selfHasherPointerRecv struct { - emit uint64 -} - -func (s *selfHasherPointerRecv) Hash(h *hashx.Block512) { - h.HashUint64(s.emit) -} - -func TestHash(t *testing.T) { - type tuple [2]any - type iface struct{ X any } - type scalars struct { - I8 int8 - I16 int16 - I32 int32 - I64 int64 - I int - U8 uint8 - U16 uint16 - U32 uint32 - U64 uint64 - U uint - UP uintptr - F32 float32 - F64 float64 - C64 complex64 - C128 complex128 - } - type MyBool bool - type MyHeader tar.Header - var zeroFloat64 float64 - tests := []struct { - in tuple - wantEq bool - }{ - {in: tuple{false, true}, wantEq: false}, - {in: tuple{true, true}, wantEq: true}, - {in: tuple{false, false}, wantEq: true}, - { - in: tuple{ - scalars{-8, -16, -32, -64, -1234, 8, 16, 32, 64, 1234, 5678, 32.32, 64.64, 32 + 32i, 64 + 64i}, - scalars{-8, -16, -32, -64, -1234, 8, 16, 32, 64, 1234, 5678, 32.32, 64.64, 32 + 32i, 64 + 64i}, - }, - wantEq: true, - }, - {in: tuple{scalars{I8: math.MinInt8}, scalars{I8: math.MinInt8 / 2}}, wantEq: false}, - {in: tuple{scalars{I16: math.MinInt16}, scalars{I16: math.MinInt16 / 2}}, wantEq: false}, - {in: tuple{scalars{I32: math.MinInt32}, scalars{I32: math.MinInt32 / 2}}, wantEq: false}, - {in: tuple{scalars{I64: math.MinInt64}, scalars{I64: math.MinInt64 / 2}}, wantEq: false}, - {in: tuple{scalars{I: -1234}, scalars{I: -1234 / 2}}, wantEq: false}, - {in: tuple{scalars{U8: math.MaxUint8}, scalars{U8: math.MaxUint8 / 2}}, wantEq: false}, - {in: tuple{scalars{U16: math.MaxUint16}, scalars{U16: math.MaxUint16 / 2}}, wantEq: false}, - {in: tuple{scalars{U32: math.MaxUint32}, scalars{U32: math.MaxUint32 / 2}}, wantEq: false}, - {in: tuple{scalars{U64: math.MaxUint64}, scalars{U64: math.MaxUint64 / 2}}, wantEq: false}, - {in: tuple{scalars{U: 1234}, scalars{U: 1234 / 2}}, wantEq: false}, - {in: tuple{scalars{UP: 5678}, scalars{UP: 5678 / 2}}, wantEq: false}, - {in: tuple{scalars{F32: 32.32}, scalars{F32: math.Nextafter32(32.32, 0)}}, wantEq: false}, - {in: tuple{scalars{F64: 64.64}, scalars{F64: math.Nextafter(64.64, 0)}}, wantEq: false}, - {in: tuple{scalars{F32: float32(math.NaN())}, scalars{F32: float32(math.NaN())}}, wantEq: true}, - {in: tuple{scalars{F64: float64(math.NaN())}, scalars{F64: float64(math.NaN())}}, wantEq: true}, - {in: tuple{scalars{C64: 32 + 32i}, scalars{C64: complex(math.Nextafter32(32, 0), 32)}}, wantEq: false}, - {in: tuple{scalars{C128: 64 + 64i}, scalars{C128: complex(math.Nextafter(64, 0), 64)}}, wantEq: false}, - {in: tuple{[]int(nil), []int(nil)}, wantEq: true}, - {in: tuple{[]int{}, []int(nil)}, wantEq: false}, - {in: tuple{[]int{}, []int{}}, wantEq: true}, - {in: tuple{[]string(nil), []string(nil)}, wantEq: true}, - {in: tuple{[]string{}, []string(nil)}, wantEq: false}, - {in: tuple{[]string{}, []string{}}, wantEq: true}, - {in: tuple{[]appendBytes{{}, {0, 0, 0, 0, 0, 0, 0, 1}}, []appendBytes{{}, {0, 0, 0, 0, 0, 0, 0, 1}}}, wantEq: true}, - {in: tuple{[]appendBytes{{}, {0, 0, 0, 0, 0, 0, 0, 1}}, []appendBytes{{0, 0, 0, 0, 0, 0, 0, 1}, {}}}, wantEq: false}, - {in: tuple{iface{MyBool(true)}, iface{MyBool(true)}}, wantEq: true}, - {in: tuple{iface{true}, iface{MyBool(true)}}, wantEq: false}, - {in: tuple{iface{MyHeader{}}, iface{MyHeader{}}}, wantEq: true}, - {in: tuple{iface{MyHeader{}}, iface{tar.Header{}}}, wantEq: false}, - {in: tuple{iface{&MyHeader{}}, iface{&MyHeader{}}}, wantEq: true}, - {in: tuple{iface{&MyHeader{}}, iface{&tar.Header{}}}, wantEq: false}, - {in: tuple{iface{[]map[string]MyBool{}}, iface{[]map[string]MyBool{}}}, wantEq: true}, - {in: tuple{iface{[]map[string]bool{}}, iface{[]map[string]MyBool{}}}, wantEq: false}, - {in: tuple{zeroFloat64, -zeroFloat64}, wantEq: false}, // Issue 4883 (false alarm) - {in: tuple{[]any(nil), 0.0}, wantEq: false}, // Issue 4883 - {in: tuple{[]any(nil), uint8(0)}, wantEq: false}, // Issue 4883 - {in: tuple{nil, nil}, wantEq: true}, // Issue 4883 - { - in: func() tuple { - i1 := 1 - i2 := 2 - v1 := [3]*int{&i1, &i2, &i1} - v2 := [3]*int{&i1, &i2, &i2} - return tuple{v1, v2} - }(), - wantEq: false, - }, - {in: tuple{netip.Addr{}, netip.Addr{}}, wantEq: true}, - {in: tuple{netip.Addr{}, netip.AddrFrom4([4]byte{})}, wantEq: false}, - {in: tuple{netip.AddrFrom4([4]byte{}), netip.AddrFrom4([4]byte{})}, wantEq: true}, - {in: tuple{netip.AddrFrom4([4]byte{192, 168, 0, 1}), netip.AddrFrom4([4]byte{192, 168, 0, 1})}, wantEq: true}, - {in: tuple{netip.AddrFrom4([4]byte{192, 168, 0, 1}), netip.AddrFrom4([4]byte{192, 168, 0, 2})}, wantEq: false}, - {in: tuple{netip.AddrFrom4([4]byte{}), netip.AddrFrom16([16]byte{})}, wantEq: false}, - {in: tuple{netip.AddrFrom16([16]byte{}), netip.AddrFrom16([16]byte{})}, wantEq: true}, - {in: tuple{netip.AddrPort{}, netip.AddrPort{}}, wantEq: true}, - {in: tuple{netip.AddrPort{}, netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 0)}, wantEq: false}, - {in: tuple{netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 0), netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 0)}, wantEq: true}, - {in: tuple{netip.AddrPortFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1234), netip.AddrPortFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1234)}, wantEq: true}, - {in: tuple{netip.AddrPortFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1234), netip.AddrPortFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1235)}, wantEq: false}, - {in: tuple{netip.AddrPortFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1234), netip.AddrPortFrom(netip.AddrFrom4([4]byte{192, 168, 0, 2}), 1234)}, wantEq: false}, - {in: tuple{netip.Prefix{}, netip.Prefix{}}, wantEq: true}, - {in: tuple{netip.Prefix{}, netip.PrefixFrom(netip.Addr{}, 1)}, wantEq: true}, - {in: tuple{netip.Prefix{}, netip.PrefixFrom(netip.AddrFrom4([4]byte{}), 0)}, wantEq: false}, - {in: tuple{netip.PrefixFrom(netip.AddrFrom4([4]byte{}), 1), netip.PrefixFrom(netip.AddrFrom4([4]byte{}), 1)}, wantEq: true}, - {in: tuple{netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1), netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1)}, wantEq: true}, - {in: tuple{netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1), netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 0)}, wantEq: false}, - {in: tuple{netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), 1), netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 0, 2}), 1)}, wantEq: false}, - {in: tuple{netipx.IPRange{}, netipx.IPRange{}}, wantEq: true}, - {in: tuple{netipx.IPRange{}, netipx.IPRangeFrom(netip.AddrFrom4([4]byte{}), netip.AddrFrom16([16]byte{}))}, wantEq: false}, - {in: tuple{netipx.IPRangeFrom(netip.AddrFrom4([4]byte{}), netip.AddrFrom16([16]byte{})), netipx.IPRangeFrom(netip.AddrFrom4([4]byte{}), netip.AddrFrom16([16]byte{}))}, wantEq: true}, - {in: tuple{netipx.IPRangeFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), netip.AddrFrom4([4]byte{192, 168, 0, 100})), netipx.IPRangeFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), netip.AddrFrom4([4]byte{192, 168, 0, 100}))}, wantEq: true}, - {in: tuple{netipx.IPRangeFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), netip.AddrFrom4([4]byte{192, 168, 0, 100})), netipx.IPRangeFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), netip.AddrFrom4([4]byte{192, 168, 0, 101}))}, wantEq: false}, - {in: tuple{netipx.IPRangeFrom(netip.AddrFrom4([4]byte{192, 168, 0, 1}), netip.AddrFrom4([4]byte{192, 168, 0, 100})), netipx.IPRangeFrom(netip.AddrFrom4([4]byte{192, 168, 0, 2}), netip.AddrFrom4([4]byte{192, 168, 0, 100}))}, wantEq: false}, - {in: tuple{key.DiscoPublic{}, key.DiscoPublic{}}, wantEq: true}, - {in: tuple{key.DiscoPublic{}, key.DiscoPublicFromRaw32(mem.B(func() []byte { - b := make([]byte, 32) - b[0] = 1 - return b - }()))}, wantEq: false}, - {in: tuple{key.NodePublic{}, key.NodePublic{}}, wantEq: true}, - {in: tuple{key.NodePublic{}, key.NodePublicFromRaw32(mem.B(func() []byte { - b := make([]byte, 32) - b[0] = 1 - return b - }()))}, wantEq: false}, - {in: tuple{&selfHasherPointerRecv{}, &selfHasherPointerRecv{}}, wantEq: true}, - {in: tuple{(*selfHasherPointerRecv)(nil), (*selfHasherPointerRecv)(nil)}, wantEq: true}, - {in: tuple{(*selfHasherPointerRecv)(nil), &selfHasherPointerRecv{}}, wantEq: false}, - {in: tuple{&selfHasherPointerRecv{emit: 1}, &selfHasherPointerRecv{emit: 2}}, wantEq: false}, - {in: tuple{selfHasherValueRecv{emit: 1}, selfHasherValueRecv{emit: 2}}, wantEq: false}, - {in: tuple{selfHasherValueRecv{emit: 2}, selfHasherValueRecv{emit: 2}}, wantEq: true}, - } - - for _, tt := range tests { - gotEq := Hash(&tt.in[0]) == Hash(&tt.in[1]) - if gotEq != tt.wantEq { - t.Errorf("(Hash(%T %v) == Hash(%T %v)) = %v, want %v", tt.in[0], tt.in[0], tt.in[1], tt.in[1], gotEq, tt.wantEq) - } - } -} - -func TestDeepHash(t *testing.T) { - // v contains the types of values we care about for our current callers. - // Mostly we're just testing that we don't panic on handled types. - v := getVal() - hash1 := Hash(v) - t.Logf("hash: %v", hash1) - for range 20 { - v := getVal() - hash2 := Hash(v) - if hash1 != hash2 { - t.Error("second hash didn't match") - } - } -} - -// Tests that we actually hash map elements. Whoops. -func TestIssue4868(t *testing.T) { - m1 := map[int]string{1: "foo"} - m2 := map[int]string{1: "bar"} - if Hash(&m1) == Hash(&m2) { - t.Error("bogus") - } -} - -func TestIssue4871(t *testing.T) { - m1 := map[string]string{"": "", "x": "foo"} - m2 := map[string]string{} - if h1, h2 := Hash(&m1), Hash(&m2); h1 == h2 { - t.Errorf("bogus: h1=%x, h2=%x", h1, h2) - } -} - -func TestNilVsEmptymap(t *testing.T) { - m1 := map[string]string(nil) - m2 := map[string]string{} - if h1, h2 := Hash(&m1), Hash(&m2); h1 == h2 { - t.Errorf("bogus: h1=%x, h2=%x", h1, h2) - } -} - -func TestMapFraming(t *testing.T) { - m1 := map[string]string{"foo": "", "fo": "o"} - m2 := map[string]string{} - if h1, h2 := Hash(&m1), Hash(&m2); h1 == h2 { - t.Errorf("bogus: h1=%x, h2=%x", h1, h2) - } -} - -func TestQuick(t *testing.T) { - initSeed() - err := quick.Check(func(v, w map[string]string) bool { - return (Hash(&v) == Hash(&w)) == reflect.DeepEqual(v, w) - }, &quick.Config{MaxCount: 1000, Rand: rand.New(rand.NewSource(int64(seed)))}) - if err != nil { - t.Fatalf("seed=%v, err=%v", seed, err) - } -} - -type tailscaleTypes struct { - WGConfig *wgcfg.Config - RouterConfig *router.Config - MapFQDNAddrs map[dnsname.FQDN][]netip.Addr - MapFQDNAddrPorts map[dnsname.FQDN][]netip.AddrPort - MapDiscoPublics map[key.DiscoPublic]bool - MapResponse *tailcfg.MapResponse - FilterMatch filter.Match -} - -func getVal() *tailscaleTypes { - return &tailscaleTypes{ - &wgcfg.Config{ - Name: "foo", - Addresses: []netip.Prefix{netip.PrefixFrom(netip.AddrFrom16([16]byte{3: 3}).Unmap(), 5)}, - Peers: []wgcfg.Peer{ - { - PublicKey: key.NodePublic{}, - }, - }, - }, - &router.Config{ - Routes: []netip.Prefix{ - netip.MustParsePrefix("1.2.3.0/24"), - netip.MustParsePrefix("1234::/64"), - }, - }, - map[dnsname.FQDN][]netip.Addr{ - dnsname.FQDN("a."): {netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("4.3.2.1")}, - dnsname.FQDN("b."): {netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("9.9.9.9")}, - dnsname.FQDN("c."): {netip.MustParseAddr("6.6.6.6"), netip.MustParseAddr("7.7.7.7")}, - dnsname.FQDN("d."): {netip.MustParseAddr("6.7.6.6"), netip.MustParseAddr("7.7.7.8")}, - dnsname.FQDN("e."): {netip.MustParseAddr("6.8.6.6"), netip.MustParseAddr("7.7.7.9")}, - dnsname.FQDN("f."): {netip.MustParseAddr("6.9.6.6"), netip.MustParseAddr("7.7.7.0")}, - }, - map[dnsname.FQDN][]netip.AddrPort{ - dnsname.FQDN("a."): {netip.MustParseAddrPort("1.2.3.4:11"), netip.MustParseAddrPort("4.3.2.1:22")}, - dnsname.FQDN("b."): {netip.MustParseAddrPort("8.8.8.8:11"), netip.MustParseAddrPort("9.9.9.9:22")}, - dnsname.FQDN("c."): {netip.MustParseAddrPort("8.8.8.8:12"), netip.MustParseAddrPort("9.9.9.9:23")}, - dnsname.FQDN("d."): {netip.MustParseAddrPort("8.8.8.8:13"), netip.MustParseAddrPort("9.9.9.9:24")}, - dnsname.FQDN("e."): {netip.MustParseAddrPort("8.8.8.8:14"), netip.MustParseAddrPort("9.9.9.9:25")}, - }, - map[key.DiscoPublic]bool{ - key.DiscoPublicFromRaw32(mem.B([]byte{1: 1, 31: 0})): true, - key.DiscoPublicFromRaw32(mem.B([]byte{1: 2, 31: 0})): false, - key.DiscoPublicFromRaw32(mem.B([]byte{1: 3, 31: 0})): true, - key.DiscoPublicFromRaw32(mem.B([]byte{1: 4, 31: 0})): false, - }, - &tailcfg.MapResponse{ - DERPMap: &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - RegionID: 1, - RegionCode: "foo", - Nodes: []*tailcfg.DERPNode{ - { - Name: "n1", - RegionID: 1, - HostName: "foo.com", - }, - { - Name: "n2", - RegionID: 1, - HostName: "bar.com", - }, - }, - }, - }, - }, - DNSConfig: &tailcfg.DNSConfig{ - Resolvers: []*dnstype.Resolver{ - {Addr: "10.0.0.1"}, - }, - }, - PacketFilter: []tailcfg.FilterRule{ - { - SrcIPs: []string{"1.2.3.4"}, - DstPorts: []tailcfg.NetPortRange{ - { - IP: "1.2.3.4/32", - Ports: tailcfg.PortRange{First: 1, Last: 2}, - }, - }, - }, - }, - Peers: []*tailcfg.Node{ - { - ID: 1, - }, - { - ID: 2, - }, - }, - UserProfiles: []tailcfg.UserProfile{ - {ID: 1, LoginName: "foo@bar.com"}, - {ID: 2, LoginName: "bar@foo.com"}, - }, - }, - filter.Match{ - IPProto: views.SliceOf([]ipproto.Proto{1, 2, 3}), - }, - } -} - -type IntThenByte struct { - _ int - _ byte -} - -type TwoInts struct{ _, _ int } - -type IntIntByteInt struct { - i1, i2 int32 - b byte // padding after - i3 int32 -} - -func u8(n uint8) string { return string([]byte{n}) } -func u32(n uint32) string { return string(binary.LittleEndian.AppendUint32(nil, n)) } -func u64(n uint64) string { return string(binary.LittleEndian.AppendUint64(nil, n)) } -func ux(n uint) string { - if bits.UintSize == 32 { - return u32(uint32(n)) - } else { - return u64(uint64(n)) - } -} - -func TestGetTypeHasher(t *testing.T) { - switch runtime.GOARCH { - case "amd64", "arm64", "arm", "386", "riscv64": - default: - // Test outputs below are specifically for little-endian machines. - // Just skip everything else for now. Feel free to add more above if - // you have the hardware to test and it's little-endian. - t.Skipf("skipping on %v", runtime.GOARCH) - } - type typedString string - var ( - someInt = int('A') - someComplex128 = complex128(1 + 2i) - someIP = netip.MustParseAddr("1.2.3.4") - ) - tests := []struct { - name string - val any - out string - out32 string // overwrites out if 32-bit - }{ - { - name: "int", - val: int(1), - out: ux(1), - }, - { - name: "int_negative", - val: int(-1), - out: ux(math.MaxUint), - }, - { - name: "int8", - val: int8(1), - out: "\x01", - }, - { - name: "float64", - val: float64(1.0), - out: "\x00\x00\x00\x00\x00\x00\xf0?", - }, - { - name: "float32", - val: float32(1.0), - out: "\x00\x00\x80?", - }, - { - name: "string", - val: "foo", - out: "\x03\x00\x00\x00\x00\x00\x00\x00foo", - }, - { - name: "typedString", - val: typedString("foo"), - out: "\x03\x00\x00\x00\x00\x00\x00\x00foo", - }, - { - name: "string_slice", - val: []string{"foo", "bar"}, - out: "\x01\x02\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00foo\x03\x00\x00\x00\x00\x00\x00\x00bar", - }, - { - name: "int_slice", - val: []int{1, 0, -1}, - out: "\x01\x03\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff", - out32: "\x01\x03\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff", - }, - { - name: "struct", - val: struct { - a, b int - c uint16 - }{1, -1, 2}, - out: "\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x02\x00", - out32: "\x01\x00\x00\x00\xff\xff\xff\xff\x02\x00", - }, - { - name: "nil_int_ptr", - val: (*int)(nil), - out: "\x00", - }, - { - name: "int_ptr", - val: &someInt, - out: "\x01A\x00\x00\x00\x00\x00\x00\x00", - out32: "\x01A\x00\x00\x00", - }, - { - name: "nil_uint32_ptr", - val: (*uint32)(nil), - out: "\x00", - }, - { - name: "complex128_ptr", - val: &someComplex128, - out: "\x01\x00\x00\x00\x00\x00\x00\xf0?\x00\x00\x00\x00\x00\x00\x00@", - }, - { - name: "packet_filter", - val: filterRules, - out: "\x01\x04\x00\x00\x00\x00\x00\x00\x00\x01\x03\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00*\v\x00\x00\x00\x00\x00\x00\x0010.1.3.4/32\v\x00\x00\x00\x00\x00\x00\x0010.0.0.0/24\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\n\x00\x00\x00\x00\x00\x00\x001.2.3.4/32\x01 \x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x04\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04!\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00foo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\v\x00\x00\x00\x00\x00\x00\x00foooooooooo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\f\x00\x00\x00\x00\x00\x00\x00baaaaaarrrrr\x00\x01\x00\x02\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\v\x00\x00\x00\x00\x00\x00\x00foooooooooo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\f\x00\x00\x00\x00\x00\x00\x00baaaaaarrrrr\x00\x01\x00\x02\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\v\x00\x00\x00\x00\x00\x00\x00foooooooooo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\f\x00\x00\x00\x00\x00\x00\x00baaaaaarrrrr\x00\x01\x00\x02\x00\x00\x00", - out32: "\x01\x04\x00\x00\x00\x00\x00\x00\x00\x01\x03\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00*\v\x00\x00\x00\x00\x00\x00\x0010.1.3.4/32\v\x00\x00\x00\x00\x00\x00\x0010.0.0.0/24\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\n\x00\x00\x00\x00\x00\x00\x001.2.3.4/32\x01 \x00\x00\x00\x01\x00\x02\x00\x01\x04\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x04\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04!\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00foo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\v\x00\x00\x00\x00\x00\x00\x00foooooooooo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\f\x00\x00\x00\x00\x00\x00\x00baaaaaarrrrr\x00\x01\x00\x02\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\v\x00\x00\x00\x00\x00\x00\x00foooooooooo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\f\x00\x00\x00\x00\x00\x00\x00baaaaaarrrrr\x00\x01\x00\x02\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\v\x00\x00\x00\x00\x00\x00\x00foooooooooo\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\f\x00\x00\x00\x00\x00\x00\x00baaaaaarrrrr\x00\x01\x00\x02\x00\x00\x00", - }, - { - name: "netip.Addr", - val: netip.MustParseAddr("fe80::123%foo"), - out: u64(16+3) + u64(0x80fe) + u64(0x2301<<48) + "foo", - }, - { - name: "ptr-netip.Addr", - val: &someIP, - out: u8(1) + u64(4) + u32(0x04030201), - }, - { - name: "ptr-nil-netip.Addr", - val: (*netip.Addr)(nil), - out: "\x00", - }, - { - name: "time", - val: time.Unix(1234, 5678).In(time.UTC), - out: u64(1234) + u32(5678) + u32(0), - }, - { - name: "time_ptr", // addressable, as opposed to "time" test above - val: ptr.To(time.Unix(1234, 5678).In(time.UTC)), - out: u8(1) + u64(1234) + u32(5678) + u32(0), - }, - { - name: "time_ptr_via_unexported", - val: testtype.NewUnexportedAddressableTime(time.Unix(1234, 5678).In(time.UTC)), - out: u8(1) + u64(1234) + u32(5678) + u32(0), - }, - { - name: "time_ptr_via_unexported_value", - val: *testtype.NewUnexportedAddressableTime(time.Unix(1234, 5678).In(time.UTC)), - out: u64(1234) + u32(5678) + u32(0), - }, - { - name: "time_custom_zone", - val: time.Unix(1655311822, 0).In(time.FixedZone("FOO", -60*60)), - out: u64(1655311822) + u32(0) + u32(math.MaxUint32-60*60+1), - }, - { - name: "time_nil", - val: (*time.Time)(nil), - out: "\x00", - }, - { - name: "array_memhash", - val: [4]byte{1, 2, 3, 4}, - out: "\x01\x02\x03\x04", - }, - { - name: "array_ptr_memhash", - val: ptr.To([4]byte{1, 2, 3, 4}), - out: "\x01\x01\x02\x03\x04", - }, - { - name: "ptr_to_struct_partially_memhashable", - val: &struct { - A int16 - B int16 - C *int - }{5, 6, nil}, - out: "\x01\x05\x00\x06\x00\x00", - }, - { - name: "struct_partially_memhashable_but_cant_addr", - val: struct { - A int16 - B int16 - C *int - }{5, 6, nil}, - out: "\x05\x00\x06\x00\x00", - }, - { - name: "array_elements", - val: [4]byte{1, 2, 3, 4}, - out: "\x01\x02\x03\x04", - }, - { - name: "bool", - val: true, - out: "\x01", - }, - { - name: "IntIntByteInt", - val: IntIntByteInt{1, 2, 3, 4}, - out: "\x01\x00\x00\x00\x02\x00\x00\x00\x03\x04\x00\x00\x00", - }, - { - name: "IntIntByteInt-canaddr", - val: &IntIntByteInt{1, 2, 3, 4}, - out: "\x01\x01\x00\x00\x00\x02\x00\x00\x00\x03\x04\x00\x00\x00", - }, - { - name: "array-IntIntByteInt", - val: [2]IntIntByteInt{ - {1, 2, 3, 4}, - {5, 6, 7, 8}, - }, - out: "\x01\x00\x00\x00\x02\x00\x00\x00\x03\x04\x00\x00\x00\x05\x00\x00\x00\x06\x00\x00\x00\a\b\x00\x00\x00", - }, - { - name: "array-IntIntByteInt-canaddr", - val: &[2]IntIntByteInt{ - {1, 2, 3, 4}, - {5, 6, 7, 8}, - }, - out: "\x01\x01\x00\x00\x00\x02\x00\x00\x00\x03\x04\x00\x00\x00\x05\x00\x00\x00\x06\x00\x00\x00\a\b\x00\x00\x00", - }, - { - name: "tailcfg.Node", - val: &tailcfg.Node{}, - out: "ANY", // magic value; just check it doesn't fail to hash - out32: "ANY", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rv := reflect.ValueOf(tt.val) - va := reflect.New(rv.Type()).Elem() - va.Set(rv) - fn := lookupTypeHasher(va.Type()) - hb := &hashBuffer{Hash: sha256.New()} - h := new(hasher) - h.Block512.Hash = hb - fn(h, pointerOf(va.Addr())) - const ptrSize = 32 << uintptr(^uintptr(0)>>63) - if tt.out32 != "" && ptrSize == 32 { - tt.out = tt.out32 - } - h.sum() - if got := string(hb.B); got != tt.out && tt.out != "ANY" { - t.Fatalf("got %q; want %q", got, tt.out) - } - }) - } -} - -func TestSliceCycle(t *testing.T) { - type S []S - c := qt.New(t) - - a := make(S, 1) // cyclic graph of 1 node - a[0] = a - b := make(S, 1) // cyclic graph of 1 node - b[0] = b - ha := Hash(&a) - hb := Hash(&b) - c.Assert(ha, qt.Equals, hb) - - c1 := make(S, 1) // cyclic graph of 2 nodes - c2 := make(S, 1) // cyclic graph of 2 nodes - c1[0] = c2 - c2[0] = c1 - hc1 := Hash(&c1) - hc2 := Hash(&c2) - c.Assert(hc1, qt.Equals, hc2) - c.Assert(ha, qt.Not(qt.Equals), hc1) - c.Assert(hb, qt.Not(qt.Equals), hc2) - - c3 := make(S, 1) // graph of 1 node pointing to cyclic graph of 2 nodes - c3[0] = c1 - hc3 := Hash(&c3) - c.Assert(hc1, qt.Not(qt.Equals), hc3) - - c4 := make(S, 2) // cyclic graph of 3 nodes - c5 := make(S, 2) // cyclic graph of 3 nodes - c4[0] = nil - c4[1] = c4 - c5[0] = c5 - c5[1] = nil - hc4 := Hash(&c4) - hc5 := Hash(&c5) - c.Assert(hc4, qt.Not(qt.Equals), hc5) // cycle occurs through different indexes -} - -func TestMapCycle(t *testing.T) { - type M map[string]M - c := qt.New(t) - - a := make(M) // cyclic graph of 1 node - a["self"] = a - b := make(M) // cyclic graph of 1 node - b["self"] = b - ha := Hash(&a) - hb := Hash(&b) - c.Assert(ha, qt.Equals, hb) - - c1 := make(M) // cyclic graph of 2 nodes - c2 := make(M) // cyclic graph of 2 nodes - c1["peer"] = c2 - c2["peer"] = c1 - hc1 := Hash(&c1) - hc2 := Hash(&c2) - c.Assert(hc1, qt.Equals, hc2) - c.Assert(ha, qt.Not(qt.Equals), hc1) - c.Assert(hb, qt.Not(qt.Equals), hc2) - - c3 := make(M) // graph of 1 node pointing to cyclic graph of 2 nodes - c3["child"] = c1 - hc3 := Hash(&c3) - c.Assert(hc1, qt.Not(qt.Equals), hc3) - - c4 := make(M) // cyclic graph of 3 nodes - c5 := make(M) // cyclic graph of 3 nodes - c4["0"] = nil - c4["1"] = c4 - c5["0"] = c5 - c5["1"] = nil - hc4 := Hash(&c4) - hc5 := Hash(&c5) - c.Assert(hc4, qt.Not(qt.Equals), hc5) // cycle occurs through different keys -} - -func TestPointerCycle(t *testing.T) { - type P *P - c := qt.New(t) - - a := new(P) // cyclic graph of 1 node - *a = a - b := new(P) // cyclic graph of 1 node - *b = b - ha := Hash(&a) - hb := Hash(&b) - c.Assert(ha, qt.Equals, hb) - - c1 := new(P) // cyclic graph of 2 nodes - c2 := new(P) // cyclic graph of 2 nodes - *c1 = c2 - *c2 = c1 - hc1 := Hash(&c1) - hc2 := Hash(&c2) - c.Assert(hc1, qt.Equals, hc2) - c.Assert(ha, qt.Not(qt.Equals), hc1) - c.Assert(hb, qt.Not(qt.Equals), hc2) - - c3 := new(P) // graph of 1 node pointing to cyclic graph of 2 nodes - *c3 = c1 - hc3 := Hash(&c3) - c.Assert(hc1, qt.Not(qt.Equals), hc3) -} - -func TestInterfaceCycle(t *testing.T) { - type I struct{ v any } - c := qt.New(t) - - a := new(I) // cyclic graph of 1 node - a.v = a - b := new(I) // cyclic graph of 1 node - b.v = b - ha := Hash(&a) - hb := Hash(&b) - c.Assert(ha, qt.Equals, hb) - - c1 := new(I) // cyclic graph of 2 nodes - c2 := new(I) // cyclic graph of 2 nodes - c1.v = c2 - c2.v = c1 - hc1 := Hash(&c1) - hc2 := Hash(&c2) - c.Assert(hc1, qt.Equals, hc2) - c.Assert(ha, qt.Not(qt.Equals), hc1) - c.Assert(hb, qt.Not(qt.Equals), hc2) - - c3 := new(I) // graph of 1 node pointing to cyclic graph of 2 nodes - c3.v = c1 - hc3 := Hash(&c3) - c.Assert(hc1, qt.Not(qt.Equals), hc3) -} - -var sink Sum - -func BenchmarkHash(b *testing.B) { - b.ReportAllocs() - v := getVal() - for range b.N { - sink = Hash(v) - } -} - -// filterRules is a packet filter that has both everything populated (in its -// first element) and also a few entries that are the typical shape for regular -// packet filters as sent to clients. -var filterRules = []tailcfg.FilterRule{ - { - SrcIPs: []string{"*", "10.1.3.4/32", "10.0.0.0/24"}, - DstPorts: []tailcfg.NetPortRange{{ - IP: "1.2.3.4/32", - Bits: ptr.To(32), - Ports: tailcfg.PortRange{First: 1, Last: 2}, - }}, - IPProto: []int{1, 2, 3, 4}, - CapGrant: []tailcfg.CapGrant{{ - Dsts: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")}, - Caps: []tailcfg.PeerCapability{"foo"}, - }}, - }, - { - SrcIPs: []string{"foooooooooo"}, - DstPorts: []tailcfg.NetPortRange{{ - IP: "baaaaaarrrrr", - Ports: tailcfg.PortRange{First: 1, Last: 2}, - }}, - }, - { - SrcIPs: []string{"foooooooooo"}, - DstPorts: []tailcfg.NetPortRange{{ - IP: "baaaaaarrrrr", - Ports: tailcfg.PortRange{First: 1, Last: 2}, - }}, - }, - { - SrcIPs: []string{"foooooooooo"}, - DstPorts: []tailcfg.NetPortRange{{ - IP: "baaaaaarrrrr", - Ports: tailcfg.PortRange{First: 1, Last: 2}, - }}, - }, -} - -func BenchmarkHashPacketFilter(b *testing.B) { - b.ReportAllocs() - - for range b.N { - sink = Hash(&filterRules) - } -} - -func TestHashMapAcyclic(t *testing.T) { - m := map[int]string{} - for i := range 100 { - m[i] = fmt.Sprint(i) - } - got := map[string]bool{} - - hb := &hashBuffer{Hash: sha256.New()} - - hash := lookupTypeHasher(reflect.TypeFor[map[int]string]()) - for range 20 { - va := reflect.ValueOf(&m).Elem() - hb.Reset() - h := new(hasher) - h.Block512.Hash = hb - hash(h, pointerOf(va.Addr())) - h.sum() - if got[string(hb.B)] { - continue - } - got[string(hb.B)] = true - } - if len(got) != 1 { - t.Errorf("got %d results; want 1", len(got)) - } -} - -func TestPrintArray(t *testing.T) { - type T struct { - X [32]byte - } - x := T{X: [32]byte{1: 1, 31: 31}} - hb := &hashBuffer{Hash: sha256.New()} - h := new(hasher) - h.Block512.Hash = hb - va := reflect.ValueOf(&x).Elem() - hash := lookupTypeHasher(va.Type()) - hash(h, pointerOf(va.Addr())) - h.sum() - const want = "\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f" - if got := hb.B; string(got) != want { - t.Errorf("wrong:\n got: %q\nwant: %q\n", got, want) - } -} - -func BenchmarkHashMapAcyclic(b *testing.B) { - b.ReportAllocs() - m := map[int]string{} - for i := range 100 { - m[i] = fmt.Sprint(i) - } - - hb := &hashBuffer{Hash: sha256.New()} - va := reflect.ValueOf(&m).Elem() - hash := lookupTypeHasher(va.Type()) - - h := new(hasher) - h.Block512.Hash = hb - - for range b.N { - h.Reset() - hash(h, pointerOf(va.Addr())) - } -} - -func BenchmarkTailcfgNode(b *testing.B) { - b.ReportAllocs() - - node := new(tailcfg.Node) - for range b.N { - sink = Hash(node) - } -} - -func TestExhaustive(t *testing.T) { - seen := make(map[Sum]bool) - for i := range 100000 { - s := Hash(&i) - if seen[s] { - t.Fatalf("hash collision %v", i) - } - seen[s] = true - } -} - -// verify this doesn't loop forever, as it used to (Issue 2340) -func TestMapCyclicFallback(t *testing.T) { - type T struct { - M map[string]any - } - v := &T{ - M: map[string]any{}, - } - v.M["m"] = v.M - Hash(v) -} - -func TestArrayAllocs(t *testing.T) { - if version.IsRace() { - t.Skip("skipping test under race detector") - } - - // In theory, there should be no allocations. However, escape analysis on - // certain architectures fails to detect that certain cases do not escape. - // This discrepancy currently affects sha256.digest.Sum. - // Measure the number of allocations in sha256 to ensure that Hash does - // not allocate on top of its usage of sha256. - // See https://golang.org/issue/48055. - var b []byte - h := sha256.New() - want := int(testing.AllocsPerRun(1000, func() { - b = h.Sum(b[:0]) - })) - switch runtime.GOARCH { - case "amd64", "arm64": - want = 0 // ensure no allocations on popular architectures - } - - type T struct { - X [32]byte - } - x := &T{X: [32]byte{1: 1, 2: 2, 3: 3, 4: 4}} - got := int(testing.AllocsPerRun(1000, func() { - sink = Hash(x) - })) - if got > want { - t.Errorf("allocs = %v; want %v", got, want) - } -} - -// Test for http://go/corp/6311 issue. -func TestHashThroughView(t *testing.T) { - type sshPolicyOut struct { - Rules []tailcfg.SSHRuleView - } - type mapResponseOut struct { - SSHPolicy *sshPolicyOut - } - // Just test we don't panic: - _ = Hash(&mapResponseOut{ - SSHPolicy: &sshPolicyOut{ - Rules: []tailcfg.SSHRuleView{ - (&tailcfg.SSHRule{ - RuleExpires: ptr.To(time.Unix(123, 0)), - }).View(), - }, - }, - }) -} - -func BenchmarkHashArray(b *testing.B) { - b.ReportAllocs() - type T struct { - X [32]byte - } - x := &T{X: [32]byte{1: 1, 2: 2, 3: 3, 4: 4}} - - for range b.N { - sink = Hash(x) - } -} - -// hashBuffer is a hash.Hash that buffers all written data. -type hashBuffer struct { - hash.Hash - B []byte -} - -func (h *hashBuffer) Write(b []byte) (int, error) { - n, err := h.Hash.Write(b) - h.B = append(h.B, b[:n]...) - return n, err -} -func (h *hashBuffer) Reset() { - h.Hash.Reset() - h.B = h.B[:0] -} - -func FuzzTime(f *testing.F) { - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(0), false, "", 0) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(0), true, "", 0) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(0), true, "hello", 0) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(0), true, "", 1234) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(0), true, "hello", 1234) - - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(1), false, "", 0) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(1), true, "", 0) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(1), true, "hello", 0) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(1), true, "", 1234) - f.Add(int64(0), int64(0), false, "", 0, int64(0), int64(1), true, "hello", 1234) - - f.Add(int64(math.MaxInt64), int64(math.MaxInt64), false, "", 0, int64(math.MaxInt64), int64(math.MaxInt64), false, "", 0) - f.Add(int64(math.MaxInt64), int64(math.MaxInt64), false, "", 0, int64(math.MaxInt64), int64(math.MaxInt64), true, "", 0) - f.Add(int64(math.MaxInt64), int64(math.MaxInt64), false, "", 0, int64(math.MaxInt64), int64(math.MaxInt64), true, "hello", 0) - f.Add(int64(math.MaxInt64), int64(math.MaxInt64), false, "", 0, int64(math.MaxInt64), int64(math.MaxInt64), true, "", 1234) - f.Add(int64(math.MaxInt64), int64(math.MaxInt64), false, "", 0, int64(math.MaxInt64), int64(math.MaxInt64), true, "hello", 1234) - - f.Add(int64(math.MinInt64), int64(math.MinInt64), false, "", 0, int64(math.MinInt64), int64(math.MinInt64), false, "", 0) - f.Add(int64(math.MinInt64), int64(math.MinInt64), false, "", 0, int64(math.MinInt64), int64(math.MinInt64), true, "", 0) - f.Add(int64(math.MinInt64), int64(math.MinInt64), false, "", 0, int64(math.MinInt64), int64(math.MinInt64), true, "hello", 0) - f.Add(int64(math.MinInt64), int64(math.MinInt64), false, "", 0, int64(math.MinInt64), int64(math.MinInt64), true, "", 1234) - f.Add(int64(math.MinInt64), int64(math.MinInt64), false, "", 0, int64(math.MinInt64), int64(math.MinInt64), true, "hello", 1234) - - f.Fuzz(func(t *testing.T, - s1, ns1 int64, loc1 bool, name1 string, off1 int, - s2, ns2 int64, loc2 bool, name2 string, off2 int, - ) { - t1 := time.Unix(s1, ns1) - if loc1 { - _ = t1.In(time.FixedZone(name1, off1)) - } - t2 := time.Unix(s2, ns2) - if loc2 { - _ = t2.In(time.FixedZone(name2, off2)) - } - got := Hash(&t1) == Hash(&t2) - want := t1.Format(time.RFC3339Nano) == t2.Format(time.RFC3339Nano) - if got != want { - t.Errorf("time.Time(%s) == time.Time(%s) mismatches hash equivalent", t1.Format(time.RFC3339Nano), t2.Format(time.RFC3339Nano)) - } - }) -} - -func FuzzAddr(f *testing.F) { - f.Fuzz(func(t *testing.T, - u1a, u1b uint64, zone1 string, - u2a, u2b uint64, zone2 string, - ) { - var b1, b2 [16]byte - binary.LittleEndian.PutUint64(b1[:8], u1a) - binary.LittleEndian.PutUint64(b1[8:], u1b) - binary.LittleEndian.PutUint64(b2[:8], u2a) - binary.LittleEndian.PutUint64(b2[8:], u2b) - - var ips [4]netip.Addr - ips[0] = netip.AddrFrom4(*(*[4]byte)(b1[:])) - ips[1] = netip.AddrFrom4(*(*[4]byte)(b2[:])) - ips[2] = netip.AddrFrom16(b1) - if zone1 != "" { - ips[2] = ips[2].WithZone(zone1) - } - ips[3] = netip.AddrFrom16(b2) - if zone2 != "" { - ips[3] = ips[2].WithZone(zone2) - } - - for _, ip1 := range ips[:] { - for _, ip2 := range ips[:] { - got := Hash(&ip1) == Hash(&ip2) - want := ip1 == ip2 - if got != want { - t.Errorf("netip.Addr(%s) == netip.Addr(%s) mismatches hash equivalent", ip1.String(), ip2.String()) - } - } - } - }) -} - -func TestAppendTo(t *testing.T) { - v := getVal() - h := Hash(v) - sum := h.AppendTo(nil) - - if s := h.String(); s != string(sum) { - t.Errorf("hash sum mismatch; h.String()=%q h.AppendTo()=%q", s, string(sum)) - } -} - -func TestFilterFields(t *testing.T) { - type T struct { - A int - B int - C int - } - - hashers := map[string]func(*T) Sum{ - "all": HasherForType[T](), - "ac": HasherForType[T](IncludeFields[T]("A", "C")), - "b": HasherForType[T](ExcludeFields[T]("A", "C")), - } - - tests := []struct { - hasher string - a, b T - wantEq bool - }{ - {"all", T{1, 2, 3}, T{1, 2, 3}, true}, - {"all", T{1, 2, 3}, T{0, 2, 3}, false}, - {"all", T{1, 2, 3}, T{1, 0, 3}, false}, - {"all", T{1, 2, 3}, T{1, 2, 0}, false}, - - {"ac", T{0, 0, 0}, T{0, 0, 0}, true}, - {"ac", T{1, 0, 1}, T{1, 1, 1}, true}, - {"ac", T{1, 1, 1}, T{1, 1, 0}, false}, - - {"b", T{0, 0, 0}, T{0, 0, 0}, true}, - {"b", T{1, 0, 1}, T{1, 1, 1}, false}, - {"b", T{1, 1, 1}, T{0, 1, 0}, true}, - } - for _, tt := range tests { - f, ok := hashers[tt.hasher] - if !ok { - t.Fatalf("bad test: unknown hasher %q", tt.hasher) - } - sum1 := f(&tt.a) - sum2 := f(&tt.b) - got := sum1 == sum2 - if got != tt.wantEq { - t.Errorf("hasher %q, for %+v and %v, got equal = %v; want %v", tt.hasher, tt.a, tt.b, got, tt.wantEq) - } - } -} - -func BenchmarkAppendTo(b *testing.B) { - b.ReportAllocs() - v := getVal() - h := Hash(v) - - hashBuf := make([]byte, 0, 100) - b.ResetTimer() - for range b.N { - hashBuf = h.AppendTo(hashBuf[:0]) - } -} diff --git a/util/deephash/types_test.go b/util/deephash/types_test.go deleted file mode 100644 index 78b40d88e5094..0000000000000 --- a/util/deephash/types_test.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package deephash - -import ( - "io" - "reflect" - "testing" - "time" - "unsafe" - - "tailscale.com/tailcfg" - "tailscale.com/types/structs" -) - -func TestTypeIsMemHashable(t *testing.T) { - tests := []struct { - val any - want bool - }{ - {true, true}, - {uint(1), true}, - {uint8(1), true}, - {uint16(1), true}, - {uint32(1), true}, - {uint64(1), true}, - {uintptr(1), true}, - {int(1), true}, - {int8(1), true}, - {int16(1), true}, - {int32(1), true}, - {int64(1), true}, - {float32(1), true}, - {float64(1), true}, - {complex64(1), true}, - {complex128(1), true}, - {[32]byte{}, true}, - {func() {}, false}, - {make(chan int), false}, - {struct{ io.Writer }{nil}, false}, - {unsafe.Pointer(nil), false}, - {new(int), false}, - {TwoInts{}, true}, - {[4]TwoInts{}, true}, - {IntThenByte{}, false}, - {[4]IntThenByte{}, false}, - {tailcfg.PortRange{}, true}, - {int16(0), true}, - {struct { - _ int - _ int - }{}, true}, - {struct { - _ int - _ uint8 - _ int - }{}, false}, // gap - {struct { - _ structs.Incomparable // if not last, zero-width - x int - }{}, true}, - {struct { - x int - _ structs.Incomparable // zero-width last: has space, can't memhash - }{}, - false}, - {[0]chan bool{}, true}, - {struct{ f [0]func() }{}, true}, - {&selfHasherPointerRecv{}, false}, - } - for _, tt := range tests { - got := typeIsMemHashable(reflect.TypeOf(tt.val)) - if got != tt.want { - t.Errorf("for type %T: got %v, want %v", tt.val, got, tt.want) - } - } -} - -func TestTypeIsRecursive(t *testing.T) { - type RecursiveStruct struct { - _ *RecursiveStruct - } - type RecursiveChan chan *RecursiveChan - - tests := []struct { - val any - want bool - }{ - {val: 42, want: false}, - {val: "string", want: false}, - {val: 1 + 2i, want: false}, - {val: struct{}{}, want: false}, - {val: (*RecursiveStruct)(nil), want: true}, - {val: RecursiveStruct{}, want: true}, - {val: time.Unix(0, 0), want: false}, - {val: structs.Incomparable{}, want: false}, // ignore its [0]func() - {val: tailcfg.NetPortRange{}, want: false}, // uses structs.Incomparable - {val: (*tailcfg.Node)(nil), want: false}, - {val: map[string]bool{}, want: false}, - {val: func() {}, want: false}, - {val: make(chan int), want: false}, - {val: unsafe.Pointer(nil), want: false}, - {val: make(RecursiveChan), want: true}, - {val: make(chan int), want: false}, - {val: (*selfHasherPointerRecv)(nil), want: false}, - } - for _, tt := range tests { - got := typeIsRecursive(reflect.TypeOf(tt.val)) - if got != tt.want { - t.Errorf("for type %T: got %v, want %v", tt.val, got, tt.want) - } - } -} diff --git a/util/dirwalk/dirwalk_test.go b/util/dirwalk/dirwalk_test.go deleted file mode 100644 index 15ebc13dd404d..0000000000000 --- a/util/dirwalk/dirwalk_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dirwalk - -import ( - "fmt" - "os" - "path/filepath" - "reflect" - "runtime" - "sort" - "testing" - - "go4.org/mem" - "tailscale.com/tstest" -) - -func TestWalkShallowOSSpecific(t *testing.T) { - if osWalkShallow == nil { - t.Skip("no OS-specific implementation") - } - testWalkShallow(t, false) -} - -func TestWalkShallowPortable(t *testing.T) { - testWalkShallow(t, true) -} - -func testWalkShallow(t *testing.T, portable bool) { - if portable { - tstest.Replace(t, &osWalkShallow, nil) - } - d := t.TempDir() - - t.Run("basics", func(t *testing.T) { - if err := os.WriteFile(filepath.Join(d, "foo"), []byte("1"), 0600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(d, "bar"), []byte("22"), 0400); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(d, "baz"), 0777); err != nil { - t.Fatal(err) - } - - var got []string - if err := WalkShallow(mem.S(d), func(name mem.RO, de os.DirEntry) error { - var size int64 - if fi, err := de.Info(); err != nil { - t.Errorf("Info stat error on %q: %v", de.Name(), err) - } else if !fi.IsDir() { - size = fi.Size() - } - got = append(got, fmt.Sprintf("%q %q dir=%v type=%d size=%v", name.StringCopy(), de.Name(), de.IsDir(), de.Type(), size)) - return nil - }); err != nil { - t.Fatal(err) - } - sort.Strings(got) - want := []string{ - `"bar" "bar" dir=false type=0 size=2`, - `"baz" "baz" dir=true type=2147483648 size=0`, - `"foo" "foo" dir=false type=0 size=1`, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("mismatch:\n got %#q\nwant %#q", got, want) - } - }) - - t.Run("err_not_exist", func(t *testing.T) { - err := WalkShallow(mem.S(filepath.Join(d, "not_exist")), func(name mem.RO, de os.DirEntry) error { - return nil - }) - if !os.IsNotExist(err) { - t.Errorf("unexpected error: %v", err) - } - }) - - t.Run("allocs", func(t *testing.T) { - allocs := int(testing.AllocsPerRun(1000, func() { - if err := WalkShallow(mem.S(d), func(name mem.RO, de os.DirEntry) error { return nil }); err != nil { - t.Fatal(err) - } - })) - t.Logf("allocs = %v", allocs) - if !portable && runtime.GOOS == "linux" && allocs != 0 { - t.Errorf("unexpected allocs: got %v, want 0", allocs) - } - }) -} diff --git a/util/dnsname/dnsname_test.go b/util/dnsname/dnsname_test.go deleted file mode 100644 index 719e28be3966b..0000000000000 --- a/util/dnsname/dnsname_test.go +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package dnsname - -import ( - "strings" - "testing" -) - -func TestFQDN(t *testing.T) { - tests := []struct { - in string - want FQDN - wantErr bool - wantLabels int - }{ - {"", ".", false, 0}, - {".", ".", false, 0}, - {"foo.com", "foo.com.", false, 2}, - {"foo.com.", "foo.com.", false, 2}, - {".foo.com.", "foo.com.", false, 2}, - {".foo.com", "foo.com.", false, 2}, - {"com", "com.", false, 1}, - {"www.tailscale.com", "www.tailscale.com.", false, 3}, - {"_ssh._tcp.tailscale.com", "_ssh._tcp.tailscale.com.", false, 4}, - {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", "", true, 0}, - {strings.Repeat("aaaaa.", 60) + "com", "", true, 0}, - {"foo..com", "", true, 0}, - } - - for _, test := range tests { - t.Run(test.in, func(t *testing.T) { - got, err := ToFQDN(test.in) - if got != test.want { - t.Errorf("ToFQDN(%q) got %q, want %q", test.in, got, test.want) - } - if (err != nil) != test.wantErr { - t.Errorf("ToFQDN(%q) err %v, wantErr=%v", test.in, err, test.wantErr) - } - if err != nil { - return - } - - gotDot := got.WithTrailingDot() - if gotDot != string(test.want) { - t.Errorf("ToFQDN(%q).WithTrailingDot() got %q, want %q", test.in, gotDot, test.want) - } - gotNoDot := got.WithoutTrailingDot() - wantNoDot := string(test.want)[:len(test.want)-1] - if gotNoDot != wantNoDot { - t.Errorf("ToFQDN(%q).WithoutTrailingDot() got %q, want %q", test.in, gotNoDot, wantNoDot) - } - - if gotLabels := got.NumLabels(); gotLabels != test.wantLabels { - t.Errorf("ToFQDN(%q).NumLabels() got %v, want %v", test.in, gotLabels, test.wantLabels) - } - }) - } -} - -func TestFQDNContains(t *testing.T) { - tests := []struct { - a, b string - want bool - }{ - {"", "", true}, - {"", "foo.com", true}, - {"foo.com", "", false}, - {"tailscale.com", "www.tailscale.com", true}, - {"www.tailscale.com", "tailscale.com", false}, - {"scale.com", "tailscale.com", false}, - {"foo.com", "foo.com", true}, - } - - for _, test := range tests { - t.Run(test.a+"_"+test.b, func(t *testing.T) { - a, err := ToFQDN(test.a) - if err != nil { - t.Fatalf("ToFQDN(%q): %v", test.a, err) - } - b, err := ToFQDN(test.b) - if err != nil { - t.Fatalf("ToFQDN(%q): %v", test.b, err) - } - - if got := a.Contains(b); got != test.want { - t.Errorf("ToFQDN(%q).Contains(%q) got %v, want %v", a, b, got, test.want) - } - }) - } -} - -func TestSanitizeLabel(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"empty", "", ""}, - {"space", " ", ""}, - {"upper", "OBERON", "oberon"}, - {"mixed", "Avery's iPhone 4(SE)", "averys-iphone-4se"}, - {"dotted", "mon.ipn.dev", "mon-ipn-dev"}, - {"email", "admin@example.com", "admin-example-com"}, - {"boundary", ".bound.ary.", "bound-ary"}, - {"bad_trailing", "a-", "a"}, - {"bad_leading", "-a", "a"}, - {"bad_both", "-a-", "a"}, - { - "overlong", - strings.Repeat("test.", 20), - "test-test-test-test-test-test-test-test-test-test-test-test-tes", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := SanitizeLabel(tt.in) - if got != tt.want { - t.Errorf("want %q; got %q", tt.want, got) - } - }) - } -} - -func TestTrimCommonSuffixes(t *testing.T) { - tests := []struct { - hostname string - want string - }{ - {"computer.local", "computer"}, - {"computer.localdomain", "computer"}, - {"computer.lan", "computer"}, - {"computer.mynetwork", "computer.mynetwork"}, - } - for _, tt := range tests { - got := TrimCommonSuffixes(tt.hostname) - if got != tt.want { - t.Errorf("TrimCommonSuffixes(%q) = %q; want %q", tt.hostname, got, tt.want) - } - } -} - -func TestHasSuffix(t *testing.T) { - tests := []struct { - name, suffix string - want bool - }{ - {"foo.com", "com", true}, - {"foo.com.", "com", true}, - {"foo.com.", "com.", true}, - {"foo.com", ".com", true}, - - {"", "", false}, - {"foo.com.", "", false}, - {"foo.com.", "o.com", false}, - } - for _, tt := range tests { - got := HasSuffix(tt.name, tt.suffix) - if got != tt.want { - t.Errorf("HasSuffix(%q, %q) = %v; want %v", tt.name, tt.suffix, got, tt.want) - } - } -} - -func TestTrimSuffix(t *testing.T) { - tests := []struct { - name string - suffix string - want string - }{ - {"foo.magicdnssuffix.", "magicdnssuffix", "foo"}, - {"foo.magicdnssuffix", "magicdnssuffix", "foo"}, - {"foo.magicdnssuffix", ".magicdnssuffix", "foo"}, - {"foo.anothersuffix", "magicdnssuffix", "foo.anothersuffix"}, - {"foo.anothersuffix.", "magicdnssuffix", "foo.anothersuffix"}, - {"a.b.c.d", "c.d", "a.b"}, - {"name.", "foo", "name"}, - } - for _, tt := range tests { - got := TrimSuffix(tt.name, tt.suffix) - if got != tt.want { - t.Errorf("TrimSuffix(%q, %q) = %q; want %q", tt.name, tt.suffix, got, tt.want) - } - } -} - -func TestValidHostname(t *testing.T) { - tests := []struct { - hostname string - wantErr string - }{ - {"example", ""}, - {"example.com", ""}, - {" example", `must start with a letter or number`}, - {"example-.com", `must end with a letter or number`}, - {strings.Repeat("a", 63), ""}, - {strings.Repeat("a", 64), `is too long, max length is 63 bytes`}, - {strings.Repeat(strings.Repeat("a", 63)+".", 4), "is too long to be a DNS name"}, - {"www.what🤦lol.example.com", "contains invalid character"}, - } - - for _, test := range tests { - t.Run(test.hostname, func(t *testing.T) { - err := ValidHostname(test.hostname) - if (err == nil) != (test.wantErr == "") || (err != nil && !strings.Contains(err.Error(), test.wantErr)) { - t.Fatalf("ValidHostname(%s)=%v; expected %v", test.hostname, err, test.wantErr) - } - }) - } -} - -var sinkFQDN FQDN - -func BenchmarkToFQDN(b *testing.B) { - tests := []string{ - "www.tailscale.com.", - "www.tailscale.com", - ".www.tailscale.com", - "_ssh._tcp.www.tailscale.com.", - "_ssh._tcp.www.tailscale.com", - } - - for _, test := range tests { - b.Run(test, func(b *testing.B) { - b.ReportAllocs() - for range b.N { - sinkFQDN, _ = ToFQDN(test) - } - }) - } -} diff --git a/util/execqueue/execqueue_test.go b/util/execqueue/execqueue_test.go deleted file mode 100644 index d10b741f72f8f..0000000000000 --- a/util/execqueue/execqueue_test.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package execqueue - -import ( - "context" - "sync/atomic" - "testing" -) - -func TestExecQueue(t *testing.T) { - ctx := context.Background() - var n atomic.Int32 - q := &ExecQueue{} - defer q.Shutdown() - q.Add(func() { n.Add(1) }) - q.Wait(ctx) - if got := n.Load(); got != 1 { - t.Errorf("n=%d; want 1", got) - } -} diff --git a/util/expvarx/expvarx.go b/util/expvarx/expvarx.go index 762f65d069aa6..9a28f07aa1696 100644 --- a/util/expvarx/expvarx.go +++ b/util/expvarx/expvarx.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "tailscale.com/types/lazy" + "github.com/sagernet/tailscale/types/lazy" ) // SafeFunc is a wrapper around [expvar.Func] that guards against unbounded call diff --git a/util/expvarx/expvarx_test.go b/util/expvarx/expvarx_test.go deleted file mode 100644 index 74ec152f476b9..0000000000000 --- a/util/expvarx/expvarx_test.go +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package expvarx - -import ( - "expvar" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" -) - -func ExampleNewSafeFunc() { - // An artificial blocker to emulate a slow operation. - blocker := make(chan struct{}) - - // limit is the amount of time a call can take before Value returns nil. No - // new calls to the unsafe func will be started until the slow call - // completes, at which point onSlow will be called. - limit := time.Millisecond - - // onSlow is called with the final call duration and the final value in the - // event a slow call. - onSlow := func(d time.Duration, v any) { - _ = d // d contains the time the call took - _ = v // v contains the final value computed by the slow call - fmt.Println("slow call!") - } - - // An unsafe expvar.Func that blocks on the blocker channel. - unsafeFunc := expvar.Func(func() any { - for range blocker { - } - return "hello world" - }) - - // f implements the same interface as expvar.Func, but returns nil values - // when the unsafe func is too slow. - f := NewSafeFunc(unsafeFunc, limit, onSlow) - - fmt.Println(f.Value()) - fmt.Println(f.Value()) - close(blocker) - time.Sleep(time.Millisecond) - fmt.Println(f.Value()) - // Output: - // - // slow call! - // hello world -} - -func TestSafeFuncHappyPath(t *testing.T) { - var count int - f := NewSafeFunc(expvar.Func(func() any { - count++ - return count - }), time.Millisecond, nil) - - if got, want := f.Value(), 1; got != want { - t.Errorf("got %v, want %v", got, want) - } - if got, want := f.Value(), 2; got != want { - t.Errorf("got %v, want %v", got, want) - } -} - -func TestSafeFuncSlow(t *testing.T) { - var count int - blocker := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(1) - f := NewSafeFunc(expvar.Func(func() any { - defer wg.Done() - count++ - <-blocker - return count - }), time.Millisecond, nil) - - if got := f.Value(); got != nil { - t.Errorf("got %v; want nil", got) - } - if got := f.Value(); got != nil { - t.Errorf("got %v; want nil", got) - } - - close(blocker) - wg.Wait() - - if count != 1 { - t.Errorf("got count=%d; want 1", count) - } -} - -func TestSafeFuncSlowOnSlow(t *testing.T) { - var count int - blocker := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(2) - var slowDuration atomic.Pointer[time.Duration] - var slowCallCount atomic.Int32 - var slowValue atomic.Value - f := NewSafeFunc(expvar.Func(func() any { - defer wg.Done() - count++ - <-blocker - return count - }), time.Millisecond, func(d time.Duration, v any) { - defer wg.Done() - slowDuration.Store(&d) - slowCallCount.Add(1) - slowValue.Store(v) - }) - - for range 10 { - if got := f.Value(); got != nil { - t.Fatalf("got value=%v; want nil", got) - } - } - - close(blocker) - wg.Wait() - - if count != 1 { - t.Errorf("got count=%d; want 1", count) - } - if got, want := *slowDuration.Load(), 1*time.Millisecond; got < want { - t.Errorf("got slowDuration=%v; want at least %d", got, want) - } - if got, want := slowCallCount.Load(), int32(1); got != want { - t.Errorf("got slowCallCount=%d; want %d", got, want) - } - if got, want := slowValue.Load().(int), 1; got != want { - t.Errorf("got slowValue=%d, want %d", got, want) - } -} diff --git a/util/goroutines/goroutines_test.go b/util/goroutines/goroutines_test.go deleted file mode 100644 index ae17c399ca274..0000000000000 --- a/util/goroutines/goroutines_test.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package goroutines - -import "testing" - -func TestScrubbedGoroutineDump(t *testing.T) { - t.Logf("Got:\n%s\n", ScrubbedGoroutineDump(true)) -} - -func TestScrubHex(t *testing.T) { - tests := []struct { - in, want string - }{ - {"foo", "foo"}, - {"", ""}, - {"0x", "?_"}, - {"0x001 and same 0x001", "v1%1_ and same v1%1_"}, - {"0x008 and same 0x008", "v1%0_ and same v1%0_"}, - {"0x001 and diff 0x002", "v1%1_ and diff v2%2_"}, - } - for _, tt := range tests { - got := scrubHex([]byte(tt.in)) - if string(got) != tt.want { - t.Errorf("for input:\n%s\n\ngot:\n%s\n\nwant:\n%s\n", tt.in, got, tt.want) - } - } -} diff --git a/util/hashx/block512_test.go b/util/hashx/block512_test.go deleted file mode 100644 index ca3ee0d784514..0000000000000 --- a/util/hashx/block512_test.go +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package hashx - -import ( - "crypto/sha256" - "encoding/binary" - "hash" - "math/rand" - "testing" - - qt "github.com/frankban/quicktest" - "tailscale.com/util/must" -) - -// naiveHash is an obviously correct implementation of Hash. -type naiveHash struct { - hash.Hash - scratch [256]byte -} - -func newNaive() *naiveHash { return &naiveHash{Hash: sha256.New()} } -func (h *naiveHash) HashUint8(n uint8) { h.Write(append(h.scratch[:0], n)) } -func (h *naiveHash) HashUint16(n uint16) { h.Write(binary.LittleEndian.AppendUint16(h.scratch[:0], n)) } -func (h *naiveHash) HashUint32(n uint32) { h.Write(binary.LittleEndian.AppendUint32(h.scratch[:0], n)) } -func (h *naiveHash) HashUint64(n uint64) { h.Write(binary.LittleEndian.AppendUint64(h.scratch[:0], n)) } -func (h *naiveHash) HashBytes(b []byte) { h.Write(b) } -func (h *naiveHash) HashString(s string) { h.Write(append(h.scratch[:0], s...)) } - -var bytes = func() (out []byte) { - out = make([]byte, 130) - for i := range out { - out[i] = byte(i) - } - return out -}() - -type hasher interface { - HashUint8(uint8) - HashUint16(uint16) - HashUint32(uint32) - HashUint64(uint64) - HashBytes([]byte) - HashString(string) -} - -func hashSuite(h hasher) { - for i := range 10 { - for j := 0; j < 10; j++ { - h.HashUint8(0x01) - h.HashUint8(0x23) - h.HashUint32(0x456789ab) - h.HashUint8(0xcd) - h.HashUint8(0xef) - h.HashUint16(0x0123) - h.HashUint32(0x456789ab) - h.HashUint16(0xcdef) - h.HashUint8(0x01) - h.HashUint64(0x23456789abcdef01) - h.HashUint16(0x2345) - h.HashUint8(0x67) - h.HashUint16(0x89ab) - h.HashUint8(0xcd) - } - b := bytes[:(i+1)*13] - if i%2 == 0 { - h.HashBytes(b) - } else { - h.HashString(string(b)) - } - } -} - -func Test(t *testing.T) { - c := qt.New(t) - h1 := must.Get(New512(sha256.New())) - h2 := newNaive() - hashSuite(h1) - hashSuite(h2) - c.Assert(h1.Sum(nil), qt.DeepEquals, h2.Sum(nil)) -} - -func TestAllocations(t *testing.T) { - c := qt.New(t) - c.Run("Sum", func(c *qt.C) { - h := must.Get(New512(sha256.New())) - c.Assert(testing.AllocsPerRun(100, func() { - var a [sha256.Size]byte - h.Sum(a[:0]) - }), qt.Equals, 0.0) - }) - c.Run("HashUint8", func(c *qt.C) { - h := must.Get(New512(sha256.New())) - c.Assert(testing.AllocsPerRun(100, func() { - h.HashUint8(0x01) - }), qt.Equals, 0.0) - }) - c.Run("HashUint16", func(c *qt.C) { - h := must.Get(New512(sha256.New())) - c.Assert(testing.AllocsPerRun(100, func() { - h.HashUint16(0x0123) - }), qt.Equals, 0.0) - }) - c.Run("HashUint32", func(c *qt.C) { - h := must.Get(New512(sha256.New())) - c.Assert(testing.AllocsPerRun(100, func() { - h.HashUint32(0x01234567) - }), qt.Equals, 0.0) - }) - c.Run("HashUint64", func(c *qt.C) { - h := must.Get(New512(sha256.New())) - c.Assert(testing.AllocsPerRun(100, func() { - h.HashUint64(0x0123456789abcdef) - }), qt.Equals, 0.0) - }) - c.Run("HashBytes", func(c *qt.C) { - h := must.Get(New512(sha256.New())) - c.Assert(testing.AllocsPerRun(100, func() { - h.HashBytes(bytes) - }), qt.Equals, 0.0) - }) - c.Run("HashString", func(c *qt.C) { - h := must.Get(New512(sha256.New())) - c.Assert(testing.AllocsPerRun(100, func() { - h.HashString("abcdefghijklmnopqrstuvwxyz") - }), qt.Equals, 0.0) - }) -} - -func Fuzz(f *testing.F) { - f.Fuzz(func(t *testing.T, seed int64) { - c := qt.New(t) - - execute := func(h hasher, r *rand.Rand) { - for range r.Intn(256) { - switch r.Intn(5) { - case 0: - n := uint8(r.Uint64()) - h.HashUint8(n) - case 1: - n := uint16(r.Uint64()) - h.HashUint16(n) - case 2: - n := uint32(r.Uint64()) - h.HashUint32(n) - case 3: - n := uint64(r.Uint64()) - h.HashUint64(n) - case 4: - b := make([]byte, r.Intn(256)) - r.Read(b) - h.HashBytes(b) - } - } - } - - r1 := rand.New(rand.NewSource(seed)) - r2 := rand.New(rand.NewSource(seed)) - - h1 := must.Get(New512(sha256.New())) - h2 := newNaive() - - execute(h1, r1) - execute(h2, r2) - - c.Assert(h1.Sum(nil), qt.DeepEquals, h2.Sum(nil)) - - execute(h1, r1) - execute(h2, r2) - - c.Assert(h1.Sum(nil), qt.DeepEquals, h2.Sum(nil)) - - h1.Reset() - h2.Reset() - - execute(h1, r1) - execute(h2, r2) - - c.Assert(h1.Sum(nil), qt.DeepEquals, h2.Sum(nil)) - }) -} - -func Benchmark(b *testing.B) { - var sum [sha256.Size]byte - b.Run("Hash", func(b *testing.B) { - b.ReportAllocs() - h := must.Get(New512(sha256.New())) - for range b.N { - h.Reset() - hashSuite(h) - h.Sum(sum[:0]) - } - }) - b.Run("Naive", func(b *testing.B) { - b.ReportAllocs() - h := newNaive() - for range b.N { - h.Reset() - hashSuite(h) - h.Sum(sum[:0]) - } - }) -} diff --git a/util/httphdr/httphdr_test.go b/util/httphdr/httphdr_test.go deleted file mode 100644 index 81feeaca080d8..0000000000000 --- a/util/httphdr/httphdr_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package httphdr - -import ( - "testing" - - "github.com/google/go-cmp/cmp" -) - -func valOk[T any](v T, ok bool) (out struct { - V T - Ok bool -}) { - out.V = v - out.Ok = ok - return out -} - -func TestRange(t *testing.T) { - tests := []struct { - in string - want []Range - wantOk bool - roundtrip bool - }{ - {"", nil, false, false}, - {"1-3", nil, false, false}, - {"units=1-3", []Range{{1, 3}}, false, false}, - {"bytes=1-3", []Range{{1, 3}}, true, true}, - {"bytes=#-3", nil, false, false}, - {"bytes=#-", nil, false, false}, - {"bytes=13", nil, false, false}, - {"bytes=1-#", nil, false, false}, - {"bytes=-#", nil, false, false}, - {"bytes= , , , ,\t , \t 1-3", []Range{{1, 3}}, true, false}, - {"bytes=1-1", []Range{{1, 1}}, true, true}, - {"bytes=01-01", []Range{{1, 1}}, true, false}, - {"bytes=1-0", nil, false, false}, - {"bytes=0-5,2-3", []Range{{0, 6}, {2, 2}}, true, true}, - {"bytes=2-3,0-5", []Range{{2, 2}, {0, 6}}, true, true}, - {"bytes=0-5,2-,-5", []Range{{0, 6}, {2, 0}, {0, -5}}, true, true}, - } - - for _, tt := range tests { - got, gotOk := ParseRange(tt.in) - if d := cmp.Diff(valOk(got, gotOk), valOk(tt.want, tt.wantOk)); d != "" { - t.Errorf("ParseRange(%q) mismatch (-got +want):\n%s", tt.in, d) - } - if tt.roundtrip { - got, gotOk := FormatRange(tt.want) - if d := cmp.Diff(valOk(got, gotOk), valOk(tt.in, tt.wantOk)); d != "" { - t.Errorf("FormatRange(%v) mismatch (-got +want):\n%s", tt.want, d) - } - } - } -} - -type contentRange struct{ Start, Length, CompleteLength int64 } - -func TestContentRange(t *testing.T) { - tests := []struct { - in string - want contentRange - wantOk bool - roundtrip bool - }{ - {"", contentRange{}, false, false}, - {"bytes 5-6/*", contentRange{5, 2, -1}, true, true}, - {"units 5-6/*", contentRange{}, false, false}, - {"bytes 5-6/*", contentRange{}, false, false}, - {"bytes 5-5/*", contentRange{5, 1, -1}, true, true}, - {"bytes 5-4/*", contentRange{}, false, false}, - {"bytes 5-5/6", contentRange{5, 1, 6}, true, true}, - {"bytes 05-005/0006", contentRange{5, 1, 6}, true, false}, - {"bytes 5-5/5", contentRange{}, false, false}, - {"bytes #-5/6", contentRange{}, false, false}, - {"bytes 5-#/6", contentRange{}, false, false}, - {"bytes 5-5/#", contentRange{}, false, false}, - } - - for _, tt := range tests { - start, length, completeLength, gotOk := ParseContentRange(tt.in) - got := contentRange{start, length, completeLength} - if d := cmp.Diff(valOk(got, gotOk), valOk(tt.want, tt.wantOk)); d != "" { - t.Errorf("ParseContentRange mismatch (-got +want):\n%s", d) - } - if tt.roundtrip { - got, gotOk := FormatContentRange(tt.want.Start, tt.want.Length, tt.want.CompleteLength) - if d := cmp.Diff(valOk(got, gotOk), valOk(tt.in, tt.wantOk)); d != "" { - t.Errorf("FormatContentRange mismatch (-got +want):\n%s", d) - } - } - } -} diff --git a/util/httpm/httpm_test.go b/util/httpm/httpm_test.go deleted file mode 100644 index 0c71edc2f3c42..0000000000000 --- a/util/httpm/httpm_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package httpm - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestUsedConsistently(t *testing.T) { - dir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - rootDir := filepath.Join(dir, "../..") - - // If we don't have a .git directory, we're not in a git checkout (e.g. - // a downstream package); skip this test. - if _, err := os.Stat(filepath.Join(rootDir, ".git")); err != nil { - t.Skipf("skipping test since .git doesn't exist: %v", err) - } - - cmd := exec.Command("git", "grep", "-l", "-F", "http.Method") - cmd.Dir = rootDir - matches, _ := cmd.Output() - for _, fn := range strings.Split(strings.TrimSpace(string(matches)), "\n") { - switch fn { - case "util/httpm/httpm.go", "util/httpm/httpm_test.go": - continue - } - t.Errorf("http.MethodFoo constant used in %s; use httpm.FOO instead", fn) - } -} diff --git a/util/jsonutil/unmarshal_test.go b/util/jsonutil/unmarshal_test.go deleted file mode 100644 index 32f8402f02e58..0000000000000 --- a/util/jsonutil/unmarshal_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package jsonutil - -import ( - "encoding/json" - "reflect" - "testing" -) - -func TestCompareToStd(t *testing.T) { - tests := []string{ - `{}`, - `{"a": 1}`, - `{]`, - `"abc"`, - `5`, - `{"a": 1} `, - `{"a": 1} {}`, - `{} bad data`, - `{"a": 1} "hello"`, - `[]`, - ` {"x": {"t": [3,4,5]}}`, - } - - for _, test := range tests { - b := []byte(test) - var ourV, stdV any - ourErr := Unmarshal(b, &ourV) - stdErr := json.Unmarshal(b, &stdV) - if (ourErr == nil) != (stdErr == nil) { - t.Errorf("Unmarshal(%q): our err = %#[2]v (%[2]T), std err = %#[3]v (%[3]T)", test, ourErr, stdErr) - } - // if !reflect.DeepEqual(ourErr, stdErr) { - // t.Logf("Unmarshal(%q): our err = %#[2]v (%[2]T), std err = %#[3]v (%[3]T)", test, ourErr, stdErr) - // } - if ourErr != nil { - // TODO: if we zero ourV on error, remove this continue. - continue - } - if !reflect.DeepEqual(ourV, stdV) { - t.Errorf("Unmarshal(%q): our val = %v, std val = %v", test, ourV, stdV) - } - } -} - -func BenchmarkUnmarshal(b *testing.B) { - var m any - j := []byte("5") - b.ReportAllocs() - for range b.N { - Unmarshal(j, &m) - } -} - -func BenchmarkStdUnmarshal(b *testing.B) { - var m any - j := []byte("5") - b.ReportAllocs() - for range b.N { - json.Unmarshal(j, &m) - } -} diff --git a/util/limiter/limiter.go b/util/limiter/limiter.go index 5af5f7bd11950..54deeebd3a238 100644 --- a/util/limiter/limiter.go +++ b/util/limiter/limiter.go @@ -11,7 +11,7 @@ import ( "sync" "time" - "tailscale.com/util/lru" + "github.com/sagernet/tailscale/util/lru" ) // Limiter is a keyed token bucket rate limiter. diff --git a/util/limiter/limiter_test.go b/util/limiter/limiter_test.go deleted file mode 100644 index 1f466d88257ab..0000000000000 --- a/util/limiter/limiter_test.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package limiter - -import ( - "bytes" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" -) - -const testRefillInterval = time.Second - -func TestLimiter(t *testing.T) { - // 1qps, burst of 10, 2 keys tracked - l := &Limiter[string]{ - Size: 2, - Max: 10, - RefillInterval: testRefillInterval, - } - - // Consume entire burst - now := time.Now().Truncate(testRefillInterval) - allowed(t, l, "foo", 10, now) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", 0) - - allowed(t, l, "bar", 10, now) - denied(t, l, "bar", 1, now) - hasTokens(t, l, "bar", 0) - - // Refill 1 token for both foo and bar - now = now.Add(time.Second + time.Millisecond) - allowed(t, l, "foo", 1, now) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", 0) - - allowed(t, l, "bar", 1, now) - denied(t, l, "bar", 1, now) - hasTokens(t, l, "bar", 0) - - // Refill 2 tokens for foo and bar - now = now.Add(2*time.Second + time.Millisecond) - allowed(t, l, "foo", 2, now) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", 0) - - allowed(t, l, "bar", 2, now) - denied(t, l, "bar", 1, now) - hasTokens(t, l, "bar", 0) - - // qux can burst 10, evicts foo so it can immediately burst 10 again too - allowed(t, l, "qux", 10, now) - denied(t, l, "qux", 1, now) - notInLimiter(t, l, "foo") - denied(t, l, "bar", 1, now) // refresh bar so foo lookup doesn't evict it - still throttled - - allowed(t, l, "foo", 10, now) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", 0) -} - -func TestLimiterOverdraft(t *testing.T) { - // 1qps, burst of 10, overdraft of 2, 2 keys tracked - l := &Limiter[string]{ - Size: 2, - Max: 10, - Overdraft: 2, - RefillInterval: testRefillInterval, - } - - // Consume entire burst, go 1 into debt - now := time.Now().Truncate(testRefillInterval).Add(time.Millisecond) - allowed(t, l, "foo", 10, now) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", -1) - - allowed(t, l, "bar", 10, now) - denied(t, l, "bar", 1, now) - hasTokens(t, l, "bar", -1) - - // Refill 1 token for both foo and bar. - // Still denied, still in debt. - now = now.Add(time.Second) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", -1) - denied(t, l, "bar", 1, now) - hasTokens(t, l, "bar", -1) - - // Refill 2 tokens for foo and bar (1 available after debt), try - // to consume 4. Overdraft is capped to 2. - now = now.Add(2 * time.Second) - allowed(t, l, "foo", 1, now) - denied(t, l, "foo", 3, now) - hasTokens(t, l, "foo", -2) - - allowed(t, l, "bar", 1, now) - denied(t, l, "bar", 3, now) - hasTokens(t, l, "bar", -2) - - // Refill 1, not enough to allow. - now = now.Add(time.Second) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", -2) - denied(t, l, "bar", 1, now) - hasTokens(t, l, "bar", -2) - - // qux evicts foo, foo can immediately burst 10 again. - allowed(t, l, "qux", 1, now) - hasTokens(t, l, "qux", 9) - notInLimiter(t, l, "foo") - allowed(t, l, "foo", 10, now) - denied(t, l, "foo", 1, now) - hasTokens(t, l, "foo", -1) -} - -func TestDumpHTML(t *testing.T) { - l := &Limiter[string]{ - Size: 3, - Max: 10, - Overdraft: 10, - RefillInterval: testRefillInterval, - } - - now := time.Now().Truncate(testRefillInterval).Add(time.Millisecond) - allowed(t, l, "foo", 10, now) - denied(t, l, "foo", 2, now) - allowed(t, l, "bar", 4, now) - allowed(t, l, "qux", 1, now) - - var out bytes.Buffer - l.DumpHTML(&out, false) - want := strings.Join([]string{ - "", - "", - "", - "", - "", - "
KeyTokens
qux9
bar6
foo-2
", - }, "") - if diff := cmp.Diff(out.String(), want); diff != "" { - t.Fatalf("wrong DumpHTML output (-got+want):\n%s", diff) - } - - out.Reset() - l.DumpHTML(&out, true) - want = strings.Join([]string{ - "", - "", - "", - "
KeyTokens
foo-2
", - }, "") - if diff := cmp.Diff(out.String(), want); diff != "" { - t.Fatalf("wrong DumpHTML output (-got+want):\n%s", diff) - } - - // Check that DumpHTML updates tokens even if the key wasn't hit - // organically. - now = now.Add(3 * time.Second) - out.Reset() - l.dumpHTML(&out, false, now) - want = strings.Join([]string{ - "", - "", - "", - "", - "", - "
KeyTokens
qux10
bar9
foo1
", - }, "") - if diff := cmp.Diff(out.String(), want); diff != "" { - t.Fatalf("wrong DumpHTML output (-got+want):\n%s", diff) - } -} - -func allowed(t *testing.T, l *Limiter[string], key string, count int, now time.Time) { - t.Helper() - for i := range count { - if !l.allow(key, now) { - toks, ok := l.tokensForTest(key) - t.Errorf("after %d times: allow(%q, %q) = false, want true (%d tokens available, in cache = %v)", i, key, now, toks, ok) - } - } -} - -func denied(t *testing.T, l *Limiter[string], key string, count int, now time.Time) { - t.Helper() - for i := range count { - if l.allow(key, now) { - toks, ok := l.tokensForTest(key) - t.Errorf("after %d times: allow(%q, %q) = true, want false (%d tokens available, in cache = %v)", i, key, now, toks, ok) - } - } -} - -func hasTokens(t *testing.T, l *Limiter[string], key string, want int64) { - t.Helper() - got, ok := l.tokensForTest(key) - if !ok { - t.Errorf("key %q missing from limiter", key) - } else if got != want { - t.Errorf("key %q has %d tokens, want %d", key, got, want) - } -} - -func notInLimiter(t *testing.T, l *Limiter[string], key string) { - t.Helper() - if tokens, ok := l.tokensForTest(key); ok { - t.Errorf("key %q unexpectedly tracked by limiter, with %d tokens", key, tokens) - } -} diff --git a/util/lineiter/lineiter.go b/util/lineiter/lineiter.go index 5cb1eeef3ee1d..e4e0340f2c302 100644 --- a/util/lineiter/lineiter.go +++ b/util/lineiter/lineiter.go @@ -11,7 +11,7 @@ import ( "iter" "os" - "tailscale.com/types/result" + "github.com/sagernet/tailscale/types/result" ) // File returns an iterator that reads lines from the named file. diff --git a/util/lineiter/lineiter_test.go b/util/lineiter/lineiter_test.go deleted file mode 100644 index 3373d5fe7b122..0000000000000 --- a/util/lineiter/lineiter_test.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package lineiter - -import ( - "slices" - "strings" - "testing" -) - -func TestBytesLines(t *testing.T) { - var got []string - for line := range Bytes([]byte("foo\n\nbar\nbaz")) { - got = append(got, string(line)) - } - want := []string{"foo", "", "bar", "baz"} - if !slices.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } -} - -func TestReader(t *testing.T) { - var got []string - for line := range Reader(strings.NewReader("foo\n\nbar\nbaz")) { - got = append(got, string(line.MustValue())) - } - want := []string{"foo", "", "bar", "baz"} - if !slices.Equal(got, want) { - t.Errorf("got %q; want %q", got, want) - } -} diff --git a/util/linuxfw/detector.go b/util/linuxfw/detector.go index f3ee4aa0b84f0..537e2601292b5 100644 --- a/util/linuxfw/detector.go +++ b/util/linuxfw/detector.go @@ -9,10 +9,10 @@ import ( "errors" "os/exec" - "tailscale.com/envknob" - "tailscale.com/hostinfo" - "tailscale.com/types/logger" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/version/distro" ) func detectFirewallMode(logf logger.Logf, prefHint string) FirewallMode { diff --git a/util/linuxfw/helpers.go b/util/linuxfw/helpers.go index a4b9fdf402558..9c7338d56a060 100644 --- a/util/linuxfw/helpers.go +++ b/util/linuxfw/helpers.go @@ -11,7 +11,7 @@ import ( "strings" "unicode" - "tailscale.com/util/slicesx" + "github.com/sagernet/tailscale/util/slicesx" ) func formatMaybePrintable(b []byte) string { diff --git a/util/linuxfw/iptables.go b/util/linuxfw/iptables.go index 234fa526ce17c..1eee495468859 100644 --- a/util/linuxfw/iptables.go +++ b/util/linuxfw/iptables.go @@ -12,8 +12,8 @@ import ( "strings" "unicode" - "tailscale.com/types/logger" - "tailscale.com/util/multierr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/multierr" ) // DebugNetfilter prints debug information about iptables rules to the diff --git a/util/linuxfw/iptables_for_svcs_test.go b/util/linuxfw/iptables_for_svcs_test.go deleted file mode 100644 index 99b2f517f1eaf..0000000000000 --- a/util/linuxfw/iptables_for_svcs_test.go +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package linuxfw - -import ( - "net/netip" - "testing" -) - -func Test_iptablesRunner_EnsurePortMapRuleForSvc(t *testing.T) { - v4Addr := netip.MustParseAddr("10.0.0.4") - v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::701:b62a") - testPM := PortMap{Protocol: "tcp", MatchPort: 4003, TargetPort: 80} - testPM2 := PortMap{Protocol: "udp", MatchPort: 4004, TargetPort: 53} - v4Rule := argsForPortMapRule("test-svc", "tailscale0", v4Addr, testPM) - tests := []struct { - name string - targetIP netip.Addr - svc string - pm PortMap - precreateSvcRules [][]string - }{ - { - name: "pm_for_ipv4", - targetIP: v4Addr, - svc: "test-svc", - pm: testPM, - }, - { - name: "pm_for_ipv6", - targetIP: v6Addr, - svc: "test-svc-2", - pm: testPM2, - }, - { - name: "add_existing_rule", - targetIP: v4Addr, - svc: "test-svc", - pm: testPM, - precreateSvcRules: [][]string{v4Rule}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - iptr := NewFakeIPTablesRunner() - table := iptr.getIPTByAddr(tt.targetIP) - for _, ruleset := range tt.precreateSvcRules { - mustPrecreatePortMapRule(t, ruleset, table) - } - if err := iptr.EnsurePortMapRuleForSvc(tt.svc, "tailscale0", tt.targetIP, tt.pm); err != nil { - t.Errorf("[unexpected error] iptablesRunner.EnsurePortMapRuleForSvc() = %v", err) - } - args := argsForPortMapRule(tt.svc, "tailscale0", tt.targetIP, tt.pm) - exists, err := table.Exists("nat", "PREROUTING", args...) - if err != nil { - t.Fatalf("error checking if rule exists: %v", err) - } - if !exists { - t.Errorf("expected rule was not created") - } - }) - } -} - -func Test_iptablesRunner_DeletePortMapRuleForSvc(t *testing.T) { - v4Addr := netip.MustParseAddr("10.0.0.4") - v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::701:b62a") - testPM := PortMap{Protocol: "tcp", MatchPort: 4003, TargetPort: 80} - v4Rule := argsForPortMapRule("test", "tailscale0", v4Addr, testPM) - v6Rule := argsForPortMapRule("test", "tailscale0", v6Addr, testPM) - - tests := []struct { - name string - targetIP netip.Addr - svc string - pm PortMap - precreateSvcRules [][]string - }{ - { - name: "multiple_rules_ipv4_deleted", - targetIP: v4Addr, - svc: "test", - pm: testPM, - precreateSvcRules: [][]string{v4Rule, v6Rule}, - }, - { - name: "multiple_rules_ipv6_deleted", - targetIP: v6Addr, - svc: "test", - pm: testPM, - precreateSvcRules: [][]string{v4Rule, v6Rule}, - }, - { - name: "non-existent_rule_deleted", - targetIP: v4Addr, - svc: "test", - pm: testPM, - precreateSvcRules: [][]string{v6Rule}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - iptr := NewFakeIPTablesRunner() - table := iptr.getIPTByAddr(tt.targetIP) - for _, ruleset := range tt.precreateSvcRules { - mustPrecreatePortMapRule(t, ruleset, table) - } - if err := iptr.DeletePortMapRuleForSvc(tt.svc, "tailscale0", tt.targetIP, tt.pm); err != nil { - t.Errorf("iptablesRunner.DeletePortMapRuleForSvc() errored: %v ", err) - } - deletedRule := argsForPortMapRule(tt.svc, "tailscale0", tt.targetIP, tt.pm) - exists, err := table.Exists("nat", "PREROUTING", deletedRule...) - if err != nil { - t.Fatalf("error verifying that rule does not exist after deletion: %v", err) - } - if exists { - t.Errorf("portmap rule exists after deletion") - } - }) - } -} - -func Test_iptablesRunner_DeleteSvc(t *testing.T) { - v4Addr := netip.MustParseAddr("10.0.0.4") - v6Addr := netip.MustParseAddr("fd7a:115c:a1e0::701:b62a") - testPM := PortMap{Protocol: "tcp", MatchPort: 4003, TargetPort: 80} - iptr := NewFakeIPTablesRunner() - - // create two rules that will consitute svc1 - s1R1 := argsForPortMapRule("svc1", "tailscale0", v4Addr, testPM) - mustPrecreatePortMapRule(t, s1R1, iptr.getIPTByAddr(v4Addr)) - s1R2 := argsForPortMapRule("svc1", "tailscale0", v6Addr, testPM) - mustPrecreatePortMapRule(t, s1R2, iptr.getIPTByAddr(v6Addr)) - - // create two rules that will consitute svc2 - s2R1 := argsForPortMapRule("svc2", "tailscale0", v4Addr, testPM) - mustPrecreatePortMapRule(t, s2R1, iptr.getIPTByAddr(v4Addr)) - s2R2 := argsForPortMapRule("svc2", "tailscale0", v6Addr, testPM) - mustPrecreatePortMapRule(t, s2R2, iptr.getIPTByAddr(v6Addr)) - - // delete svc1 - if err := iptr.DeleteSvc("svc1", "tailscale0", []netip.Addr{v4Addr, v6Addr}, []PortMap{testPM}); err != nil { - t.Fatalf("error deleting service: %v", err) - } - - // validate that svc1 no longer exists - svcMustNotExist(t, "svc1", map[string][]string{v4Addr.String(): s1R1, v6Addr.String(): s1R2}, iptr) - - // validate that svc2 still exists - svcMustExist(t, "svc2", map[string][]string{v4Addr.String(): s2R1, v6Addr.String(): s2R2}, iptr) -} - -func svcMustExist(t *testing.T, svcName string, rules map[string][]string, iptr *iptablesRunner) { - t.Helper() - for dst, ruleset := range rules { - tip := netip.MustParseAddr(dst) - exists, err := iptr.getIPTByAddr(tip).Exists("nat", "PREROUTING", ruleset...) - if err != nil { - t.Fatalf("error checking whether %s exists: %v", svcName, err) - } - if !exists { - t.Fatalf("service %s should be deleted,but found rule for %s", svcName, dst) - } - } -} - -func svcMustNotExist(t *testing.T, svcName string, rules map[string][]string, iptr *iptablesRunner) { - t.Helper() - for dst, ruleset := range rules { - tip := netip.MustParseAddr(dst) - exists, err := iptr.getIPTByAddr(tip).Exists("nat", "PREROUTING", ruleset...) - if err != nil { - t.Fatalf("error checking whether %s exists: %v", svcName, err) - } - if exists { - t.Fatalf("service %s should exist, but rule for %s is missing", svcName, dst) - } - } -} - -func mustPrecreatePortMapRule(t *testing.T, rules []string, table iptablesInterface) { - t.Helper() - exists, err := table.Exists("nat", "PREROUTING", rules...) - if err != nil { - t.Fatalf("error ensuring that nat PREROUTING table exists: %v", err) - } - if exists { - return - } - if err := table.Append("nat", "PREROUTING", rules...); err != nil { - t.Fatalf("error precreating portmap rule: %v", err) - } -} diff --git a/util/linuxfw/iptables_runner.go b/util/linuxfw/iptables_runner.go index 9a6fc02248e62..90d2a935f634c 100644 --- a/util/linuxfw/iptables_runner.go +++ b/util/linuxfw/iptables_runner.go @@ -18,10 +18,10 @@ import ( "strings" "github.com/coreos/go-iptables/iptables" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" - "tailscale.com/util/multierr" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/version/distro" ) // isNotExistError needs to be overridden in tests that rely on distinguishing diff --git a/util/linuxfw/iptables_runner_test.go b/util/linuxfw/iptables_runner_test.go deleted file mode 100644 index 56f13c78a8010..0000000000000 --- a/util/linuxfw/iptables_runner_test.go +++ /dev/null @@ -1,365 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package linuxfw - -import ( - "net/netip" - "strings" - "testing" - - "tailscale.com/net/tsaddr" -) - -var testIsNotExistErr = "exitcode:1" - -func init() { - isNotExistError = func(e error) bool { return e.Error() == testIsNotExistErr } -} - -func TestAddAndDeleteChains(t *testing.T) { - iptr := NewFakeIPTablesRunner() - err := iptr.AddChains() - if err != nil { - t.Fatal(err) - } - - // Check that the chains were created. - tsChains := []struct{ table, chain string }{ // table/chain - {"filter", "ts-input"}, - {"filter", "ts-forward"}, - {"nat", "ts-postrouting"}, - } - - for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} { - for _, tc := range tsChains { - // Exists returns error if the chain doesn't exist. - if _, err := proto.Exists(tc.table, tc.chain); err != nil { - t.Errorf("chain %s/%s doesn't exist", tc.table, tc.chain) - } - } - } - - err = iptr.DelChains() - if err != nil { - t.Fatal(err) - } - - // Check that the chains were deleted. - for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} { - for _, tc := range tsChains { - if _, err = proto.Exists(tc.table, tc.chain); err == nil { - t.Errorf("chain %s/%s still exists", tc.table, tc.chain) - } - } - } - -} - -func TestAddAndDeleteHooks(t *testing.T) { - iptr := NewFakeIPTablesRunner() - // don't need to test what happens if the chains don't exist, because - // this is handled by fake iptables, in realife iptables would return error. - if err := iptr.AddChains(); err != nil { - t.Fatal(err) - } - defer iptr.DelChains() - - if err := iptr.AddHooks(); err != nil { - t.Fatal(err) - } - - // Check that the rules were created. - tsRules := []fakeRule{ // table/chain/rule - {"filter", "INPUT", []string{"-j", "ts-input"}}, - {"filter", "FORWARD", []string{"-j", "ts-forward"}}, - {"nat", "POSTROUTING", []string{"-j", "ts-postrouting"}}, - } - - for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} { - for _, tr := range tsRules { - if exists, err := proto.Exists(tr.table, tr.chain, tr.args...); err != nil { - t.Fatal(err) - } else if !exists { - t.Errorf("rule %s/%s/%s doesn't exist", tr.table, tr.chain, strings.Join(tr.args, " ")) - } - // check if the rule is at front of the chain - if proto.(*fakeIPTables).n[tr.table+"/"+tr.chain][0] != strings.Join(tr.args, " ") { - t.Errorf("v4 rule %s/%s/%s is not at the top", tr.table, tr.chain, strings.Join(tr.args, " ")) - } - } - } - - if err := iptr.DelHooks(t.Logf); err != nil { - t.Fatal(err) - } - - // Check that the rules were deleted. - for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} { - for _, tr := range tsRules { - if exists, err := proto.Exists(tr.table, tr.chain, tr.args...); err != nil { - t.Fatal(err) - } else if exists { - t.Errorf("rule %s/%s/%s still exists", tr.table, tr.chain, strings.Join(tr.args, " ")) - } - } - } - - if err := iptr.AddHooks(); err != nil { - t.Fatal(err) - } -} - -func TestAddAndDeleteBase(t *testing.T) { - iptr := NewFakeIPTablesRunner() - tunname := "tun0" - if err := iptr.AddChains(); err != nil { - t.Fatal(err) - } - - if err := iptr.AddBase(tunname); err != nil { - t.Fatal(err) - } - - // Check that the rules were created. - tsRulesV4 := []fakeRule{ // table/chain/rule - {"filter", "ts-input", []string{"!", "-i", tunname, "-s", tsaddr.ChromeOSVMRange().String(), "-j", "RETURN"}}, - {"filter", "ts-input", []string{"!", "-i", tunname, "-s", tsaddr.CGNATRange().String(), "-j", "DROP"}}, - {"filter", "ts-forward", []string{"-o", tunname, "-s", tsaddr.CGNATRange().String(), "-j", "DROP"}}, - } - - tsRulesCommon := []fakeRule{ // table/chain/rule - {"filter", "ts-input", []string{"-i", tunname, "-j", "ACCEPT"}}, - {"filter", "ts-forward", []string{"-i", tunname, "-j", "MARK", "--set-mark", TailscaleSubnetRouteMark + "/" + TailscaleFwmarkMask}}, - {"filter", "ts-forward", []string{"-m", "mark", "--mark", TailscaleSubnetRouteMark + "/" + TailscaleFwmarkMask, "-j", "ACCEPT"}}, - {"filter", "ts-forward", []string{"-o", tunname, "-j", "ACCEPT"}}, - } - - // check that the rules were created for ipt4 - for _, tr := range append(tsRulesV4, tsRulesCommon...) { - if exists, err := iptr.ipt4.Exists(tr.table, tr.chain, tr.args...); err != nil { - t.Fatal(err) - } else if !exists { - t.Errorf("rule %s/%s/%s doesn't exist", tr.table, tr.chain, strings.Join(tr.args, " ")) - } - } - - // check that the rules were created for ipt6 - for _, tr := range tsRulesCommon { - if exists, err := iptr.ipt6.Exists(tr.table, tr.chain, tr.args...); err != nil { - t.Fatal(err) - } else if !exists { - t.Errorf("rule %s/%s/%s doesn't exist", tr.table, tr.chain, strings.Join(tr.args, " ")) - } - } - - if err := iptr.DelBase(); err != nil { - t.Fatal(err) - } - - // Check that the rules were deleted. - for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} { - for _, tr := range append(tsRulesV4, tsRulesCommon...) { - if exists, err := proto.Exists(tr.table, tr.chain, tr.args...); err != nil { - t.Fatal(err) - } else if exists { - t.Errorf("rule %s/%s/%s still exists", tr.table, tr.chain, strings.Join(tr.args, " ")) - } - } - } - - if err := iptr.DelChains(); err != nil { - t.Fatal(err) - } -} - -func TestAddAndDelLoopbackRule(t *testing.T) { - iptr := NewFakeIPTablesRunner() - // We don't need to test for malformed addresses, AddLoopbackRule - // takes in a netip.Addr, which is already valid. - fakeAddrV4 := netip.MustParseAddr("192.168.0.2") - fakeAddrV6 := netip.MustParseAddr("2001:db8::2") - - if err := iptr.AddChains(); err != nil { - t.Fatal(err) - } - if err := iptr.AddLoopbackRule(fakeAddrV4); err != nil { - t.Fatal(err) - } - if err := iptr.AddLoopbackRule(fakeAddrV6); err != nil { - t.Fatal(err) - } - - // Check that the rules were created. - tsRulesV4 := fakeRule{ // table/chain/rule - "filter", "ts-input", []string{"-i", "lo", "-s", fakeAddrV4.String(), "-j", "ACCEPT"}} - - tsRulesV6 := fakeRule{ // table/chain/rule - "filter", "ts-input", []string{"-i", "lo", "-s", fakeAddrV6.String(), "-j", "ACCEPT"}} - - // check that the rules were created for ipt4 and ipt6 - if exist, err := iptr.ipt4.Exists(tsRulesV4.table, tsRulesV4.chain, tsRulesV4.args...); err != nil { - t.Fatal(err) - } else if !exist { - t.Errorf("rule %s/%s/%s doesn't exist", tsRulesV4.table, tsRulesV4.chain, strings.Join(tsRulesV4.args, " ")) - } - if exist, err := iptr.ipt6.Exists(tsRulesV6.table, tsRulesV6.chain, tsRulesV6.args...); err != nil { - t.Fatal(err) - } else if !exist { - t.Errorf("rule %s/%s/%s doesn't exist", tsRulesV6.table, tsRulesV6.chain, strings.Join(tsRulesV6.args, " ")) - } - - // check that the rule is at the top - chain := "filter/ts-input" - if iptr.ipt4.(*fakeIPTables).n[chain][0] != strings.Join(tsRulesV4.args, " ") { - t.Errorf("v4 rule %s/%s/%s is not at the top", tsRulesV4.table, tsRulesV4.chain, strings.Join(tsRulesV4.args, " ")) - } - if iptr.ipt6.(*fakeIPTables).n[chain][0] != strings.Join(tsRulesV6.args, " ") { - t.Errorf("v6 rule %s/%s/%s is not at the top", tsRulesV6.table, tsRulesV6.chain, strings.Join(tsRulesV6.args, " ")) - } - - // delete the rules - if err := iptr.DelLoopbackRule(fakeAddrV4); err != nil { - t.Fatal(err) - } - if err := iptr.DelLoopbackRule(fakeAddrV6); err != nil { - t.Fatal(err) - } - - // Check that the rules were deleted. - if exist, err := iptr.ipt4.Exists(tsRulesV4.table, tsRulesV4.chain, tsRulesV4.args...); err != nil { - t.Fatal(err) - } else if exist { - t.Errorf("rule %s/%s/%s still exists", tsRulesV4.table, tsRulesV4.chain, strings.Join(tsRulesV4.args, " ")) - } - - if exist, err := iptr.ipt6.Exists(tsRulesV6.table, tsRulesV6.chain, tsRulesV6.args...); err != nil { - t.Fatal(err) - } else if exist { - t.Errorf("rule %s/%s/%s still exists", tsRulesV6.table, tsRulesV6.chain, strings.Join(tsRulesV6.args, " ")) - } - - if err := iptr.DelChains(); err != nil { - t.Fatal(err) - } -} - -func TestAddAndDelSNATRule(t *testing.T) { - iptr := NewFakeIPTablesRunner() - - if err := iptr.AddChains(); err != nil { - t.Fatal(err) - } - - rule := fakeRule{ // table/chain/rule - "nat", "ts-postrouting", []string{"-m", "mark", "--mark", TailscaleSubnetRouteMark + "/" + TailscaleFwmarkMask, "-j", "MASQUERADE"}, - } - - // Add SNAT rule - if err := iptr.AddSNATRule(); err != nil { - t.Fatal(err) - } - - // Check that the rule was created for ipt4 and ipt6 - for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} { - if exist, err := proto.Exists(rule.table, rule.chain, rule.args...); err != nil { - t.Fatal(err) - } else if !exist { - t.Errorf("rule %s/%s/%s doesn't exist", rule.table, rule.chain, strings.Join(rule.args, " ")) - } - } - - // Delete SNAT rule - if err := iptr.DelSNATRule(); err != nil { - t.Fatal(err) - } - - // Check that the rule was deleted for ipt4 and ipt6 - for _, proto := range []iptablesInterface{iptr.ipt4, iptr.ipt6} { - if exist, err := proto.Exists(rule.table, rule.chain, rule.args...); err != nil { - t.Fatal(err) - } else if exist { - t.Errorf("rule %s/%s/%s still exists", rule.table, rule.chain, strings.Join(rule.args, " ")) - } - } - - if err := iptr.DelChains(); err != nil { - t.Fatal(err) - } -} - -func TestEnsureSNATForDst_ipt(t *testing.T) { - ip1, ip2, ip3 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("100.77.77.77") - iptr := NewFakeIPTablesRunner() - - // 1. A new rule gets added - mustCreateSNATRule_ipt(t, iptr, ip1, ip2) - checkSNATRule_ipt(t, iptr, ip1, ip2) - checkSNATRuleCount(t, iptr, ip1, 1) - - // 2. Another call to EnsureSNATForDst with the same src and dst does not result in another rule being added. - mustCreateSNATRule_ipt(t, iptr, ip1, ip2) - checkSNATRule_ipt(t, iptr, ip1, ip2) - checkSNATRuleCount(t, iptr, ip1, 1) // still just 1 rule - - // 3. Another call to EnsureSNATForDst with a different src and the same dst results in the earlier rule being - // deleted. - mustCreateSNATRule_ipt(t, iptr, ip3, ip2) - checkSNATRule_ipt(t, iptr, ip3, ip2) - checkSNATRuleCount(t, iptr, ip1, 1) // still just 1 rule - - // 4. Another call to EnsureSNATForDst with a different dst should not get the earlier rule deleted. - mustCreateSNATRule_ipt(t, iptr, ip3, ip1) - checkSNATRule_ipt(t, iptr, ip3, ip1) - checkSNATRuleCount(t, iptr, ip1, 2) // now 2 rules - - // 5. A call to EnsureSNATForDst with a match dst and a match port should not get deleted by EnsureSNATForDst for the same dst. - args := []string{"--destination", ip1.String(), "-j", "SNAT", "--to-source", "10.0.0.1"} - if err := iptr.getIPTByAddr(ip1).Insert("nat", "POSTROUTING", 1, args...); err != nil { - t.Fatalf("error adding SNAT rule: %v", err) - } - exists, err := iptr.getIPTByAddr(ip1).Exists("nat", "POSTROUTING", args...) - if err != nil { - t.Fatalf("error checking if rule exists: %v", err) - } - if !exists { - t.Fatalf("SNAT rule for destination and port unexpectedly deleted") - } - mustCreateSNATRule_ipt(t, iptr, ip3, ip1) - checkSNATRuleCount(t, iptr, ip1, 3) // now 3 rules -} - -func mustCreateSNATRule_ipt(t *testing.T, iptr *iptablesRunner, src, dst netip.Addr) { - t.Helper() - if err := iptr.EnsureSNATForDst(src, dst); err != nil { - t.Fatalf("error ensuring SNAT rule: %v", err) - } -} - -func checkSNATRule_ipt(t *testing.T, iptr *iptablesRunner, src, dst netip.Addr) { - t.Helper() - dstPrefix, err := dst.Prefix(32) - if err != nil { - t.Fatalf("error converting addr to prefix: %v", err) - } - exists, err := iptr.getIPTByAddr(src).Exists("nat", "POSTROUTING", "-d", dstPrefix.String(), "-j", "SNAT", "--to-source", src.String()) - if err != nil { - t.Fatalf("error checking if rule exists: %v", err) - } - if !exists { - t.Fatalf("SNAT rule for src %s dst %s should exist, but it does not", src, dst) - } -} - -func checkSNATRuleCount(t *testing.T, iptr *iptablesRunner, ip netip.Addr, wantsRules int) { - t.Helper() - rules, err := iptr.getIPTByAddr(ip).List("nat", "POSTROUTING") - if err != nil { - t.Fatalf("error listing rules: %v", err) - } - if len(rules) != wantsRules { - t.Fatalf("wants %d rules, got %d", wantsRules, len(rules)) - } -} diff --git a/util/linuxfw/linuxfw.go b/util/linuxfw/linuxfw.go index be520e7a4a074..a1a84fe88a014 100644 --- a/util/linuxfw/linuxfw.go +++ b/util/linuxfw/linuxfw.go @@ -13,8 +13,8 @@ import ( "strconv" "strings" + "github.com/sagernet/tailscale/types/logger" "github.com/tailscale/netlink" - "tailscale.com/types/logger" ) // MatchDecision is the decision made by the firewall for a packet matched by a rule. diff --git a/util/linuxfw/linuxfw_unsupported.go b/util/linuxfw/linuxfw_unsupported.go index 7bfb4fd010302..14c8ec7944370 100644 --- a/util/linuxfw/linuxfw_unsupported.go +++ b/util/linuxfw/linuxfw_unsupported.go @@ -12,7 +12,7 @@ package linuxfw import ( "errors" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // ErrUnsupported is the error returned from all functions on non-Linux diff --git a/util/linuxfw/linuxfwtest/linuxfwtest.go b/util/linuxfw/linuxfwtest/linuxfwtest.go index ee2cbd1b227f4..822a50122daaf 100644 --- a/util/linuxfw/linuxfwtest/linuxfwtest.go +++ b/util/linuxfw/linuxfwtest/linuxfwtest.go @@ -9,16 +9,16 @@ // in tests intead. package linuxfwtest -import ( - "testing" - "unsafe" -) - /* #include // socket() */ import "C" +import ( + "testing" + "unsafe" +) + type SizeInfo struct { SizeofSocklen uintptr } diff --git a/util/linuxfw/nftables.go b/util/linuxfw/nftables.go index 056563071479f..f13ab92246bf9 100644 --- a/util/linuxfw/nftables.go +++ b/util/linuxfw/nftables.go @@ -16,8 +16,8 @@ import ( "github.com/google/nftables/expr" "github.com/google/nftables/xt" "github.com/josharian/native" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/sys/unix" - "tailscale.com/types/logger" ) // DebugNetfilter prints debug information about netfilter rules to the diff --git a/util/linuxfw/nftables_for_svcs_test.go b/util/linuxfw/nftables_for_svcs_test.go deleted file mode 100644 index d2df6e4bdf2ef..0000000000000 --- a/util/linuxfw/nftables_for_svcs_test.go +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package linuxfw - -import ( - "net/netip" - "testing" - - "github.com/google/nftables" -) - -// This test creates a temporary network namespace for the nftables rules being -// set up, so it needs to run in a privileged mode. Locally it needs to be run -// by root, else it will be silently skipped. In CI it runs in a privileged -// container. -func Test_nftablesRunner_EnsurePortMapRuleForSvc(t *testing.T) { - conn := newSysConn(t) - runner := newFakeNftablesRunnerWithConn(t, conn, true) - ipv4, ipv6 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("fd7a:115c:a1e0::701:b62a") - pmTCP := PortMap{MatchPort: 4003, TargetPort: 80, Protocol: "TCP"} - pmTCP1 := PortMap{MatchPort: 4004, TargetPort: 443, Protocol: "TCP"} - - // Create a rule for service 'foo' to forward TCP traffic to IPv4 endpoint - runner.EnsurePortMapRuleForSvc("foo", "tailscale0", ipv4, pmTCP) - svcChains(t, 1, conn) - chainRuleCount(t, "foo", 1, conn, nftables.TableFamilyIPv4) - checkPortMapRule(t, "foo", ipv4, pmTCP, runner, nftables.TableFamilyIPv4) - - // Create another rule for service 'foo' to forward TCP traffic to the - // same IPv4 endpoint, but to a different port. - runner.EnsurePortMapRuleForSvc("foo", "tailscale0", ipv4, pmTCP1) - svcChains(t, 1, conn) - chainRuleCount(t, "foo", 2, conn, nftables.TableFamilyIPv4) - checkPortMapRule(t, "foo", ipv4, pmTCP1, runner, nftables.TableFamilyIPv4) - - // Create a rule for service 'foo' to forward TCP traffic to an IPv6 endpoint - runner.EnsurePortMapRuleForSvc("foo", "tailscale0", ipv6, pmTCP) - svcChains(t, 2, conn) - chainRuleCount(t, "foo", 1, conn, nftables.TableFamilyIPv6) - checkPortMapRule(t, "foo", ipv6, pmTCP, runner, nftables.TableFamilyIPv6) - - // Create a rule for service 'bar' to forward TCP traffic to IPv4 endpoint - runner.EnsurePortMapRuleForSvc("bar", "tailscale0", ipv4, pmTCP) - svcChains(t, 3, conn) - chainRuleCount(t, "bar", 1, conn, nftables.TableFamilyIPv4) - checkPortMapRule(t, "bar", ipv4, pmTCP, runner, nftables.TableFamilyIPv4) - - // Create a rule for service 'bar' to forward TCP traffic to an IPv6 endpoint - runner.EnsurePortMapRuleForSvc("bar", "tailscale0", ipv6, pmTCP) - svcChains(t, 4, conn) - chainRuleCount(t, "bar", 1, conn, nftables.TableFamilyIPv6) - checkPortMapRule(t, "bar", ipv6, pmTCP, runner, nftables.TableFamilyIPv6) - - // Delete service bar - runner.DeleteSvc("bar", "tailscale0", []netip.Addr{ipv4, ipv6}, []PortMap{pmTCP}) - svcChains(t, 2, conn) - - // Delete a rule from service foo - runner.DeletePortMapRuleForSvc("foo", "tailscale0", ipv4, pmTCP) - svcChains(t, 2, conn) - chainRuleCount(t, "foo", 1, conn, nftables.TableFamilyIPv4) - - // Delete service foo - runner.DeleteSvc("foo", "tailscale0", []netip.Addr{ipv4, ipv6}, []PortMap{pmTCP, pmTCP1}) - svcChains(t, 0, conn) -} - -// svcChains verifies that the expected number of chains exist (for either IP -// family) and that each of them is configured as NAT prerouting chain. -func svcChains(t *testing.T, wantCount int, conn *nftables.Conn) { - t.Helper() - chains, err := conn.ListChains() - if err != nil { - t.Fatalf("error listing chains: %v", err) - } - if len(chains) != wantCount { - t.Fatalf("wants %d chains, got %d", wantCount, len(chains)) - } - for _, ch := range chains { - if *ch.Policy != nftables.ChainPolicyAccept { - t.Fatalf("chain %s has unexpected policy %v", ch.Name, *ch.Policy) - } - if ch.Type != nftables.ChainTypeNAT { - t.Fatalf("chain %s has unexpected type %v", ch.Name, ch.Type) - } - if *ch.Hooknum != *nftables.ChainHookPrerouting { - t.Fatalf("chain %s is attached to unexpected hook %v", ch.Name, ch.Hooknum) - } - if *ch.Priority != *nftables.ChainPriorityNATDest { - t.Fatalf("chain %s has unexpected priority %v", ch.Name, ch.Priority) - } - } -} - -// chainRuleCount verifies that the named chain in the given table contains the provided number of rules. -func chainRuleCount(t *testing.T, name string, numOfRules int, conn *nftables.Conn, fam nftables.TableFamily) { - t.Helper() - chains, err := conn.ListChainsOfTableFamily(fam) - if err != nil { - t.Fatalf("error listing chains: %v", err) - } - - for _, ch := range chains { - if ch.Name == name { - checkChainRules(t, conn, ch, numOfRules) - return - } - } - t.Fatalf("chain %s does not exist", name) -} - -// checkPortMapRule verifies that rule for the provided target IP and PortMap exists in a chain identified by service -// name and IP family. -func checkPortMapRule(t *testing.T, svc string, targetIP netip.Addr, pm PortMap, runner *nftablesRunner, fam nftables.TableFamily) { - t.Helper() - chains, err := runner.conn.ListChainsOfTableFamily(fam) - if err != nil { - t.Fatalf("error listing chains: %v", err) - } - var chain *nftables.Chain - for _, ch := range chains { - if ch.Name == svc { - chain = ch - break - } - } - if chain == nil { - t.Fatalf("chain for service %s does not exist", svc) - } - meta := svcPortMapRuleMeta(svc, targetIP, pm) - p, err := protoFromString(pm.Protocol) - if err != nil { - t.Fatalf("error converting protocol: %v", err) - } - wantsRule := portMapRule(chain.Table, chain, "tailscale0", targetIP, pm.MatchPort, pm.TargetPort, p, meta) - checkRule(t, wantsRule, runner.conn) -} - -// checkRule checks that the provided rules exists. -func checkRule(t *testing.T, rule *nftables.Rule, conn *nftables.Conn) { - t.Helper() - gotRule, err := findRule(conn, rule) - if err != nil { - t.Fatalf("error looking up rule: %v", err) - } - if gotRule == nil { - t.Fatal("rule not found") - } -} diff --git a/util/linuxfw/nftables_runner.go b/util/linuxfw/nftables_runner.go index 0f411521bb562..0328e46cd58d1 100644 --- a/util/linuxfw/nftables_runner.go +++ b/util/linuxfw/nftables_runner.go @@ -17,10 +17,10 @@ import ( "github.com/google/nftables" "github.com/google/nftables/expr" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/ptr" "golang.org/x/sys/unix" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" - "tailscale.com/types/ptr" ) const ( diff --git a/util/linuxfw/nftables_runner_test.go b/util/linuxfw/nftables_runner_test.go deleted file mode 100644 index 712a7b93955da..0000000000000 --- a/util/linuxfw/nftables_runner_test.go +++ /dev/null @@ -1,1026 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux - -package linuxfw - -import ( - "bytes" - "errors" - "fmt" - "net/netip" - "os" - "runtime" - "strings" - "testing" - - "github.com/google/nftables" - "github.com/google/nftables/expr" - "github.com/mdlayher/netlink" - "github.com/vishvananda/netns" - "tailscale.com/net/tsaddr" - "tailscale.com/tstest" - "tailscale.com/types/logger" -) - -// nfdump returns a hexdump of 4 bytes per line (like nft --debug=all), allowing -// users to make sense of large byte literals more easily. -func nfdump(b []byte) string { - var buf bytes.Buffer - i := 0 - for ; i < len(b); i += 4 { - // TODO: show printable characters as ASCII - fmt.Fprintf(&buf, "%02x %02x %02x %02x\n", - b[i], - b[i+1], - b[i+2], - b[i+3]) - } - for ; i < len(b); i++ { - fmt.Fprintf(&buf, "%02x ", b[i]) - } - return buf.String() -} - -func TestMaskof(t *testing.T) { - pfx, err := netip.ParsePrefix("192.168.1.0/24") - if err != nil { - t.Fatal(err) - } - want := []byte{0xff, 0xff, 0xff, 0x00} - if got := maskof(pfx); !bytes.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } -} - -// linediff returns a side-by-side diff of two nfdump() return values, flagging -// lines which are not equal with an exclamation point prefix. -func linediff(a, b string) string { - var buf bytes.Buffer - fmt.Fprintf(&buf, "got -- want\n") - linesA := strings.Split(a, "\n") - linesB := strings.Split(b, "\n") - for idx, lineA := range linesA { - if idx >= len(linesB) { - break - } - lineB := linesB[idx] - prefix := "! " - if lineA == lineB { - prefix = " " - } - fmt.Fprintf(&buf, "%s%s -- %s\n", prefix, lineA, lineB) - } - return buf.String() -} - -func newTestConn(t *testing.T, want [][]byte) *nftables.Conn { - conn, err := nftables.New(nftables.WithTestDial( - func(req []netlink.Message) ([]netlink.Message, error) { - for idx, msg := range req { - b, err := msg.MarshalBinary() - if err != nil { - t.Fatal(err) - } - if len(b) < 16 { - continue - } - b = b[16:] - if len(want) == 0 { - t.Errorf("no want entry for message %d: %x", idx, b) - continue - } - if got, want := b, want[0]; !bytes.Equal(got, want) { - t.Errorf("message %d: %s", idx, linediff(nfdump(got), nfdump(want))) - } - want = want[1:] - } - return req, nil - })) - if err != nil { - t.Fatal(err) - } - return conn -} - -func TestInsertHookRule(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-input-test { type filter hook input priority 0 \; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x03\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add chain ip ts-filter-test ts-jumpto - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x0e\x00\x03\x00\x74\x73\x2d\x6a\x75\x6d\x70\x74\x6f\x00\x00\x00"), - // nft add rule ip ts-filter-test ts-input-test counter jump ts-jumptp - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x02\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x70\x00\x04\x80\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x2c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x20\x00\x02\x80\x1c\x00\x02\x80\x08\x00\x01\x00\xff\xff\xff\xfd\x0e\x00\x02\x00\x74\x73\x2d\x6a\x75\x6d\x70\x74\x6f\x00\x00\x00"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - - fromchain := testConn.AddChain(&nftables.Chain{ - Name: "ts-input-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - }) - - tochain := testConn.AddChain(&nftables.Chain{ - Name: "ts-jumpto", - Table: table, - }) - - err := addHookRule(testConn, table, fromchain, tochain.Name) - if err != nil { - t.Fatal(err) - } - -} - -func TestInsertLoopbackRule(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-input-test { type filter hook input priority 0 \; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x03\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-input-test iifname "lo" ip saddr 192.168.0.2 counter accept - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x02\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x10\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x06\x00\x01\x00\x6c\x6f\x00\x00\x34\x00\x01\x80\x0c\x00\x01\x00\x70\x61\x79\x6c\x6f\x61\x64\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x0c\x08\x00\x04\x00\x00\x00\x00\x04\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\xc0\xa8\x00\x02\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-input-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - }) - - addr := netip.MustParseAddr("192.168.0.2") - - err := insertLoopbackRule(testConn, proto, table, chain, addr) - if err != nil { - t.Fatal(err) - } -} - -func TestInsertLoopbackRuleV6(t *testing.T) { - protoV6 := nftables.TableFamilyIPv6 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip6 ts-filter-test - []byte("\x0a\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip6 ts-filter-test ts-input-test { type filter hook input priority 0\; } - []byte("\x0a\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x03\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip6 ts-filter-test ts-input-test iifname "lo" ip6 addr 2001:db8::1 counter accept - []byte("\x0a\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x02\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x1c\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x06\x00\x01\x00\x6c\x6f\x00\x00\x34\x00\x01\x80\x0c\x00\x01\x00\x70\x61\x79\x6c\x6f\x61\x64\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x08\x08\x00\x04\x00\x00\x00\x00\x10\x38\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x2c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x18\x00\x03\x80\x14\x00\x01\x00\x20\x01\x0d\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - tableV6 := testConn.AddTable(&nftables.Table{ - Family: protoV6, - Name: "ts-filter-test", - }) - - chainV6 := testConn.AddChain(&nftables.Chain{ - Name: "ts-input-test", - Table: tableV6, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - }) - - addrV6 := netip.MustParseAddr("2001:db8::1") - - err := insertLoopbackRule(testConn, protoV6, tableV6, chainV6, addrV6) - if err != nil { - t.Fatal(err) - } -} - -func TestAddReturnChromeOSVMRangeRule(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-input-test { type filter hook input priority 0\; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x03\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-input-test iifname != "testTunn" ip saddr 100.115.92.0/23 counter return - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x02\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x58\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x34\x00\x01\x80\x0c\x00\x01\x00\x70\x61\x79\x6c\x6f\x61\x64\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x0c\x08\x00\x04\x00\x00\x00\x00\x04\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\xff\xff\xfe\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x64\x73\x5c\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\xff\xff\xff\xfb"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-input-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - }) - err := addReturnChromeOSVMRangeRule(testConn, table, chain, "testTunn") - if err != nil { - t.Fatal(err) - } -} - -func TestAddDropCGNATRangeRule(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-input-test { type filter hook input priority filter; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x03\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-input-test iifname != "testTunn" ip saddr 100.64.0.0/10 counter drop - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x02\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x58\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x34\x00\x01\x80\x0c\x00\x01\x00\x70\x61\x79\x6c\x6f\x61\x64\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x0c\x08\x00\x04\x00\x00\x00\x00\x04\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\xff\xc0\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x64\x40\x00\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-input-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - }) - err := addDropCGNATRangeRule(testConn, table, chain, "testTunn") - if err != nil { - t.Fatal(err) - } -} - -func TestAddSetSubnetRouteMarkRule(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-forward-test { type filter hook forward priority 0\; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x03\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x02\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-forward-test iifname "testTunn" counter meta mark set mark and 0xff00ffff xor 0x40000 - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x10\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\xff\x00\xff\xff\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x04\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x03\x00\x00\x00\x00\x01"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-forward-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - }) - err := addSetSubnetRouteMarkRule(testConn, table, chain, "testTunn") - if err != nil { - t.Fatal(err) - } -} - -func TestAddDropOutgoingPacketFromCGNATRangeRuleWithTunname(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-forward-test { type filter hook forward priority 0\; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x03\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x02\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-forward-test oifname "testTunn" ip saddr 100.64.0.0/10 counter drop - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x58\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x07\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x34\x00\x01\x80\x0c\x00\x01\x00\x70\x61\x79\x6c\x6f\x61\x64\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x0c\x08\x00\x04\x00\x00\x00\x00\x04\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\xff\xc0\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x64\x40\x00\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-forward-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - }) - err := addDropOutgoingPacketFromCGNATRangeRuleWithTunname(testConn, table, chain, "testTunn") - if err != nil { - t.Fatal(err) - } -} - -func TestAddAcceptOutgoingPacketRule(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-forward-test { type filter hook forward priority 0\; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x03\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x02\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-forward-test oifname "testTunn" counter accept - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\xb4\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x07\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-forward-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - }) - err := addAcceptOutgoingPacketRule(testConn, table, chain, "testTunn") - if err != nil { - t.Fatal(err) - } -} - -func TestAddAcceptIncomingPacketRule(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-input-test { type filter hook input priority 0\; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x03\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-input-test iifname "testTunn" counter accept - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x12\x00\x02\x00\x74\x73\x2d\x69\x6e\x70\x75\x74\x2d\x74\x65\x73\x74\x00\x00\x00\xb4\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x30\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x10\x00\x03\x80\x0c\x00\x01\x00\x74\x65\x73\x74\x54\x75\x6e\x6e\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-input-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - }) - err := addAcceptIncomingPacketRule(testConn, table, chain, "testTunn") - if err != nil { - t.Fatal(err) - } -} - -func TestAddMatchSubnetRouteMarkRuleMasq(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-nat-test - []byte("\x02\x00\x00\x00\x10\x00\x01\x00\x74\x73\x2d\x6e\x61\x74\x2d\x74\x65\x73\x74\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-nat-test ts-postrouting-test { type nat hook postrouting priority 100; } - []byte("\x02\x00\x00\x00\x10\x00\x01\x00\x74\x73\x2d\x6e\x61\x74\x2d\x74\x65\x73\x74\x00\x18\x00\x03\x00\x74\x73\x2d\x70\x6f\x73\x74\x72\x6f\x75\x74\x69\x6e\x67\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x04\x08\x00\x02\x00\x00\x00\x00\x64\x08\x00\x07\x00\x6e\x61\x74\x00"), - // nft add rule ip ts-nat-test ts-postrouting-test meta mark & 0x00ff0000 == 0x00040000 counter masquerade - []byte("\x02\x00\x00\x00\x10\x00\x01\x00\x74\x73\x2d\x6e\x61\x74\x2d\x74\x65\x73\x74\x00\x18\x00\x02\x00\x74\x73\x2d\x70\x6f\x73\x74\x72\x6f\x75\x74\x69\x6e\x67\x2d\x74\x65\x73\x74\x00\xf4\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\xff\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x04\x00\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-nat-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-postrouting-test", - Table: table, - Type: nftables.ChainTypeNAT, - Hooknum: nftables.ChainHookPostrouting, - Priority: nftables.ChainPriorityNATSource, - }) - err := addMatchSubnetRouteMarkRule(testConn, table, chain, Accept) - if err != nil { - t.Fatal(err) - } -} - -func TestAddMatchSubnetRouteMarkRuleAccept(t *testing.T) { - proto := nftables.TableFamilyIPv4 - want := [][]byte{ - // batch begin - []byte("\x00\x00\x00\x0a"), - // nft add table ip ts-filter-test - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x08\x00\x02\x00\x00\x00\x00\x00"), - // nft add chain ip ts-filter-test ts-forward-test { type filter hook forward priority 0\; } - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x03\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\x14\x00\x04\x80\x08\x00\x01\x00\x00\x00\x00\x02\x08\x00\x02\x00\x00\x00\x00\x00\x0b\x00\x07\x00\x66\x69\x6c\x74\x65\x72\x00\x00"), - // nft add rule ip ts-filter-test ts-forward-test meta mark and 0x00ff0000 eq 0x00040000 counter accept - []byte("\x02\x00\x00\x00\x13\x00\x01\x00\x74\x73\x2d\x66\x69\x6c\x74\x65\x72\x2d\x74\x65\x73\x74\x00\x00\x14\x00\x02\x00\x74\x73\x2d\x66\x6f\x72\x77\x61\x72\x64\x2d\x74\x65\x73\x74\x00\xf4\x00\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x03\x08\x00\x01\x00\x00\x00\x00\x01\x44\x00\x01\x80\x0c\x00\x01\x00\x62\x69\x74\x77\x69\x73\x65\x00\x34\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x01\x08\x00\x03\x00\x00\x00\x00\x04\x0c\x00\x04\x80\x08\x00\x01\x00\x00\xff\x00\x00\x0c\x00\x05\x80\x08\x00\x01\x00\x00\x00\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x08\x00\x01\x00\x00\x04\x00\x00\x2c\x00\x01\x80\x0c\x00\x01\x00\x63\x6f\x75\x6e\x74\x65\x72\x00\x1c\x00\x02\x80\x0c\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x1c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x00\x10\x00\x02\x80\x0c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01"), - // batch end - []byte("\x00\x00\x00\x0a"), - } - testConn := newTestConn(t, want) - table := testConn.AddTable(&nftables.Table{ - Family: proto, - Name: "ts-filter-test", - }) - chain := testConn.AddChain(&nftables.Chain{ - Name: "ts-forward-test", - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - }) - err := addMatchSubnetRouteMarkRule(testConn, table, chain, Accept) - if err != nil { - t.Fatal(err) - } -} - -func newSysConn(t *testing.T) *nftables.Conn { - t.Helper() - if os.Geteuid() != 0 { - t.Skip(t.Name(), " requires privileges to create a namespace in order to run") - return nil - } - - runtime.LockOSThread() - - ns, err := netns.New() - if err != nil { - t.Fatalf("netns.New() failed: %v", err) - } - c, err := nftables.New(nftables.WithNetNSFd(int(ns))) - if err != nil { - t.Fatalf("nftables.New() failed: %v", err) - } - - t.Cleanup(func() { cleanupSysConn(t, ns) }) - - return c -} - -func cleanupSysConn(t *testing.T, ns netns.NsHandle) { - defer runtime.UnlockOSThread() - - if err := ns.Close(); err != nil { - t.Fatalf("newNS.Close() failed: %v", err) - } -} - -func checkChains(t *testing.T, conn *nftables.Conn, fam nftables.TableFamily, wantCount int) { - t.Helper() - got, err := conn.ListChainsOfTableFamily(fam) - if err != nil { - t.Fatalf("conn.ListChainsOfTableFamily(%v) failed: %v", fam, err) - } - if len(got) != wantCount { - t.Fatalf("len(got) = %d, want %d", len(got), wantCount) - } -} -func checkTables(t *testing.T, conn *nftables.Conn, fam nftables.TableFamily, wantCount int) { - t.Helper() - got, err := conn.ListTablesOfFamily(fam) - if err != nil { - t.Fatalf("conn.ListTablesOfFamily(%v) failed: %v", fam, err) - } - if len(got) != wantCount { - t.Fatalf("len(got) = %d, want %d", len(got), wantCount) - } -} - -func TestAddAndDelNetfilterChains(t *testing.T) { - type test struct { - hostHasIPv6 bool - initIPv4ChainCount int - initIPv6ChainCount int - ipv4TableCount int - ipv6TableCount int - ipv4ChainCount int - ipv6ChainCount int - ipv4ChainCountPostDelete int - ipv6ChainCountPostDelete int - } - tests := []test{ - { - hostHasIPv6: true, - initIPv4ChainCount: 0, - initIPv6ChainCount: 0, - ipv4TableCount: 2, - ipv6TableCount: 2, - ipv4ChainCount: 6, - ipv6ChainCount: 6, - ipv4ChainCountPostDelete: 3, - ipv6ChainCountPostDelete: 3, - }, - { // host without IPv6 support - ipv4TableCount: 2, - ipv4ChainCount: 6, - ipv4ChainCountPostDelete: 3, - }} - for _, tt := range tests { - t.Logf("running a test case for IPv6 support: %v", tt.hostHasIPv6) - conn := newSysConn(t) - runner := newFakeNftablesRunnerWithConn(t, conn, tt.hostHasIPv6) - - // Check that we start off with no chains. - checkChains(t, conn, nftables.TableFamilyIPv4, tt.initIPv4ChainCount) - checkChains(t, conn, nftables.TableFamilyIPv6, tt.initIPv6ChainCount) - - if err := runner.AddChains(); err != nil { - t.Fatalf("runner.AddChains() failed: %v", err) - } - - // Check that the amount of tables for each IP family is as expected. - checkTables(t, conn, nftables.TableFamilyIPv4, tt.ipv4TableCount) - checkTables(t, conn, nftables.TableFamilyIPv6, tt.ipv6TableCount) - - // Check that the amount of chains for each IP family is as expected. - checkChains(t, conn, nftables.TableFamilyIPv4, tt.ipv4ChainCount) - checkChains(t, conn, nftables.TableFamilyIPv6, tt.ipv6ChainCount) - - if err := runner.DelChains(); err != nil { - t.Fatalf("runner.DelChains() failed: %v", err) - } - - // Test that the tables as well as the default chains are still present. - checkChains(t, conn, nftables.TableFamilyIPv4, tt.ipv4ChainCountPostDelete) - checkChains(t, conn, nftables.TableFamilyIPv6, tt.ipv6ChainCountPostDelete) - checkTables(t, conn, nftables.TableFamilyIPv4, tt.ipv4TableCount) - checkTables(t, conn, nftables.TableFamilyIPv6, tt.ipv6TableCount) - } -} - -func getTsChains( - conn *nftables.Conn, - proto nftables.TableFamily) (*nftables.Chain, *nftables.Chain, *nftables.Chain, error) { - chains, err := conn.ListChainsOfTableFamily(nftables.TableFamilyIPv4) - if err != nil { - return nil, nil, nil, fmt.Errorf("list chains failed: %w", err) - } - var chainInput, chainForward, chainPostrouting *nftables.Chain - for _, chain := range chains { - switch chain.Name { - case "ts-input": - chainInput = chain - case "ts-forward": - chainForward = chain - case "ts-postrouting": - chainPostrouting = chain - } - } - return chainInput, chainForward, chainPostrouting, nil -} - -// findV4BaseRules verifies that the base rules are present in the input and forward chains. -func findV4BaseRules( - conn *nftables.Conn, - inpChain *nftables.Chain, - forwChain *nftables.Chain, - tunname string) ([]*nftables.Rule, error) { - want := []*nftables.Rule{} - rule, err := createRangeRule(inpChain.Table, inpChain, tunname, tsaddr.ChromeOSVMRange(), expr.VerdictReturn) - if err != nil { - return nil, fmt.Errorf("create rule: %w", err) - } - want = append(want, rule) - rule, err = createRangeRule(inpChain.Table, inpChain, tunname, tsaddr.CGNATRange(), expr.VerdictDrop) - if err != nil { - return nil, fmt.Errorf("create rule: %w", err) - } - want = append(want, rule) - rule, err = createDropOutgoingPacketFromCGNATRangeRuleWithTunname(forwChain.Table, forwChain, tunname) - if err != nil { - return nil, fmt.Errorf("create rule: %w", err) - } - want = append(want, rule) - - get := []*nftables.Rule{} - for _, rule := range want { - getRule, err := findRule(conn, rule) - if err != nil { - return nil, fmt.Errorf("find rule: %w", err) - } - get = append(get, getRule) - } - return get, nil -} - -func findCommonBaseRules( - conn *nftables.Conn, - forwChain *nftables.Chain, - tunname string) ([]*nftables.Rule, error) { - want := []*nftables.Rule{} - rule, err := createSetSubnetRouteMarkRule(forwChain.Table, forwChain, tunname) - if err != nil { - return nil, fmt.Errorf("create rule: %w", err) - } - want = append(want, rule) - rule, err = createMatchSubnetRouteMarkRule(forwChain.Table, forwChain, Accept) - if err != nil { - return nil, fmt.Errorf("create rule: %w", err) - } - want = append(want, rule) - rule = createAcceptOutgoingPacketRule(forwChain.Table, forwChain, tunname) - want = append(want, rule) - - get := []*nftables.Rule{} - for _, rule := range want { - getRule, err := findRule(conn, rule) - if err != nil { - return nil, fmt.Errorf("find rule: %w", err) - } - get = append(get, getRule) - } - - return get, nil -} - -// checkChainRules verifies that the chain has the expected number of rules. -func checkChainRules(t *testing.T, conn *nftables.Conn, chain *nftables.Chain, wantCount int) { - t.Helper() - got, err := conn.GetRules(chain.Table, chain) - if err != nil { - t.Fatalf("conn.GetRules() failed: %v", err) - } - if len(got) != wantCount { - t.Fatalf("got = %d, want %d", len(got), wantCount) - } -} - -func TestNFTAddAndDelNetfilterBase(t *testing.T) { - conn := newSysConn(t) - - runner := newFakeNftablesRunnerWithConn(t, conn, true) - - if err := runner.AddChains(); err != nil { - t.Fatalf("AddChains() failed: %v", err) - } - defer runner.DelChains() - if err := runner.AddBase("testTunn"); err != nil { - t.Fatalf("AddBase() failed: %v", err) - } - - // check number of rules in each IPv4 TS chain - inputV4, forwardV4, postroutingV4, err := getTsChains(conn, nftables.TableFamilyIPv4) - if err != nil { - t.Fatalf("getTsChains() failed: %v", err) - } - checkChainRules(t, conn, inputV4, 3) - checkChainRules(t, conn, forwardV4, 4) - checkChainRules(t, conn, postroutingV4, 0) - - _, err = findV4BaseRules(conn, inputV4, forwardV4, "testTunn") - if err != nil { - t.Fatalf("missing v4 base rule: %v", err) - } - _, err = findCommonBaseRules(conn, forwardV4, "testTunn") - if err != nil { - t.Fatalf("missing v4 base rule: %v", err) - } - - // Check number of rules in each IPv6 TS chain. - inputV6, forwardV6, postroutingV6, err := getTsChains(conn, nftables.TableFamilyIPv6) - if err != nil { - t.Fatalf("getTsChains() failed: %v", err) - } - checkChainRules(t, conn, inputV6, 3) - checkChainRules(t, conn, forwardV6, 4) - checkChainRules(t, conn, postroutingV6, 0) - - _, err = findCommonBaseRules(conn, forwardV6, "testTunn") - if err != nil { - t.Fatalf("missing v6 base rule: %v", err) - } - - runner.DelBase() - - chains, err := conn.ListChains() - if err != nil { - t.Fatalf("conn.ListChains() failed: %v", err) - } - for _, chain := range chains { - checkChainRules(t, conn, chain, 0) - } -} - -func findLoopBackRule(conn *nftables.Conn, proto nftables.TableFamily, table *nftables.Table, chain *nftables.Chain, addr netip.Addr) (*nftables.Rule, error) { - matchingAddr := addr.AsSlice() - saddrExpr, err := newLoadSaddrExpr(proto, 1) - if err != nil { - return nil, fmt.Errorf("get expr: %w", err) - } - loopBackRule := &nftables.Rule{ - Table: table, - Chain: chain, - Exprs: []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyIIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte("lo"), - }, - saddrExpr, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: matchingAddr, - }, - &expr.Counter{}, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - }, - } - - existingLoopBackRule, err := findRule(conn, loopBackRule) - if err != nil { - return nil, fmt.Errorf("find loop back rule: %w", err) - } - return existingLoopBackRule, nil -} - -func TestNFTAddAndDelLoopbackRule(t *testing.T) { - conn := newSysConn(t) - - runner := newFakeNftablesRunnerWithConn(t, conn, true) - if err := runner.AddChains(); err != nil { - t.Fatalf("AddChains() failed: %v", err) - } - defer runner.DelChains() - - inputV4, _, _, err := getTsChains(conn, nftables.TableFamilyIPv4) - if err != nil { - t.Fatalf("getTsChains() failed: %v", err) - } - - inputV6, _, _, err := getTsChains(conn, nftables.TableFamilyIPv6) - if err != nil { - t.Fatalf("getTsChains() failed: %v", err) - } - checkChainRules(t, conn, inputV4, 0) - checkChainRules(t, conn, inputV6, 0) - - runner.AddBase("testTunn") - defer runner.DelBase() - checkChainRules(t, conn, inputV4, 3) - checkChainRules(t, conn, inputV6, 3) - - addr := netip.MustParseAddr("192.168.0.2") - addrV6 := netip.MustParseAddr("2001:db8::2") - runner.AddLoopbackRule(addr) - runner.AddLoopbackRule(addrV6) - - checkChainRules(t, conn, inputV4, 4) - checkChainRules(t, conn, inputV6, 4) - - existingLoopBackRule, err := findLoopBackRule(conn, nftables.TableFamilyIPv4, runner.nft4.Filter, inputV4, addr) - if err != nil { - t.Fatalf("findLoopBackRule() failed: %v", err) - } - - if existingLoopBackRule.Position != 0 { - t.Fatalf("existingLoopBackRule.Handle = %d, want 0", existingLoopBackRule.Handle) - } - - existingLoopBackRuleV6, err := findLoopBackRule(conn, nftables.TableFamilyIPv6, runner.nft6.Filter, inputV6, addrV6) - if err != nil { - t.Fatalf("findLoopBackRule() failed: %v", err) - } - - if existingLoopBackRuleV6.Position != 0 { - t.Fatalf("existingLoopBackRule.Handle = %d, want 0", existingLoopBackRule.Handle) - } - - runner.DelLoopbackRule(addr) - runner.DelLoopbackRule(addrV6) - - checkChainRules(t, conn, inputV4, 3) - checkChainRules(t, conn, inputV6, 3) -} - -func TestNFTAddAndDelHookRule(t *testing.T) { - conn := newSysConn(t) - runner := newFakeNftablesRunnerWithConn(t, conn, true) - if err := runner.AddChains(); err != nil { - t.Fatalf("AddChains() failed: %v", err) - } - defer runner.DelChains() - if err := runner.AddHooks(); err != nil { - t.Fatalf("AddHooks() failed: %v", err) - } - - forwardChain, err := getChainFromTable(conn, runner.nft4.Filter, "FORWARD") - if err != nil { - t.Fatalf("failed to get forwardChain: %v", err) - } - inputChain, err := getChainFromTable(conn, runner.nft4.Filter, "INPUT") - if err != nil { - t.Fatalf("failed to get inputChain: %v", err) - } - postroutingChain, err := getChainFromTable(conn, runner.nft4.Nat, "POSTROUTING") - if err != nil { - t.Fatalf("failed to get postroutingChain: %v", err) - } - - checkChainRules(t, conn, forwardChain, 1) - checkChainRules(t, conn, inputChain, 1) - checkChainRules(t, conn, postroutingChain, 1) - - runner.DelHooks(t.Logf) - - checkChainRules(t, conn, forwardChain, 0) - checkChainRules(t, conn, inputChain, 0) - checkChainRules(t, conn, postroutingChain, 0) -} - -type testFWDetector struct { - iptRuleCount, nftRuleCount int - iptErr, nftErr error -} - -func (t *testFWDetector) iptDetect() (int, error) { - return t.iptRuleCount, t.iptErr -} - -func (t *testFWDetector) nftDetect() (int, error) { - return t.nftRuleCount, t.nftErr -} - -// TestCreateDummyPostroutingChains tests that on a system with nftables -// available, the function does not return an error and that the dummy -// postrouting chains are cleaned up. -func TestCreateDummyPostroutingChains(t *testing.T) { - conn := newSysConn(t) - runner := newFakeNftablesRunnerWithConn(t, conn, true) - if err := runner.createDummyPostroutingChains(); err != nil { - t.Fatalf("createDummyPostroutingChains() failed: %v", err) - } - for _, table := range runner.getTables() { - nt, err := getTableIfExists(conn, table.Proto, tsDummyTableName) - if err != nil { - t.Fatalf("getTableIfExists() failed: %v", err) - } - if nt != nil { - t.Fatalf("expected table to be nil, got %v", nt) - } - } -} - -func TestPickFirewallModeFromInstalledRules(t *testing.T) { - tests := []struct { - name string - det *testFWDetector - want FirewallMode - }{ - { - name: "using iptables legacy", - det: &testFWDetector{iptRuleCount: 1}, - want: FirewallModeIPTables, - }, - { - name: "using nftables", - det: &testFWDetector{nftRuleCount: 1}, - want: FirewallModeNfTables, - }, - { - name: "using both iptables and nftables", - det: &testFWDetector{iptRuleCount: 2, nftRuleCount: 2}, - want: FirewallModeNfTables, - }, - { - name: "not using any firewall, both available", - det: &testFWDetector{}, - want: FirewallModeNfTables, - }, - { - name: "not using any firewall, iptables available only", - det: &testFWDetector{iptRuleCount: 1, nftErr: errors.New("nft error")}, - want: FirewallModeIPTables, - }, - { - name: "not using any firewall, nftables available only", - det: &testFWDetector{iptErr: errors.New("iptables error"), nftRuleCount: 1}, - want: FirewallModeNfTables, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := pickFirewallModeFromInstalledRules(t.Logf, tt.det) - if got != tt.want { - t.Errorf("chooseFireWallMode() = %v, want %v", got, tt.want) - } - }) - } -} - -// This test creates a temporary network namespace for the nftables rules being -// set up, so it needs to run in a privileged mode. Locally it needs to be run -// by root, else it will be silently skipped. In CI it runs in a privileged -// container. -func TestEnsureSNATForDst_nftables(t *testing.T) { - conn := newSysConn(t) - runner := newFakeNftablesRunnerWithConn(t, conn, true) - ip1, ip2, ip3 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("100.77.77.77") - - // 1. A new rule gets added - mustCreateSNATRule_nft(t, runner, ip1, ip2) - chainRuleCount(t, "POSTROUTING", 1, conn, nftables.TableFamilyIPv4) - checkSNATRule_nft(t, runner, runner.nft4.Proto, ip1, ip2) - - // 2. Another call to EnsureSNATForDst with the same src and dst does not result in another rule being added. - mustCreateSNATRule_nft(t, runner, ip1, ip2) - chainRuleCount(t, "POSTROUTING", 1, conn, nftables.TableFamilyIPv4) // still just one rule - checkSNATRule_nft(t, runner, runner.nft4.Proto, ip1, ip2) - - // 3. Another call to EnsureSNATForDst with a different src and the same dst results in the earlier rule being - // deleted. - mustCreateSNATRule_nft(t, runner, ip3, ip2) - chainRuleCount(t, "POSTROUTING", 1, conn, nftables.TableFamilyIPv4) // still just one rule - checkSNATRule_nft(t, runner, runner.nft4.Proto, ip3, ip2) - - // 4. Another call to EnsureSNATForDst with a different dst should not get the earlier rule deleted. - mustCreateSNATRule_nft(t, runner, ip3, ip1) - chainRuleCount(t, "POSTROUTING", 2, conn, nftables.TableFamilyIPv4) // now two rules - checkSNATRule_nft(t, runner, runner.nft4.Proto, ip3, ip1) -} - -func newFakeNftablesRunnerWithConn(t *testing.T, conn *nftables.Conn, hasIPv6 bool) *nftablesRunner { - t.Helper() - if !hasIPv6 { - tstest.Replace(t, &checkIPv6ForTest, func(logger.Logf) error { - return errors.New("test: no IPv6") - }) - - } - return newNfTablesRunnerWithConn(t.Logf, conn) -} - -func mustCreateSNATRule_nft(t *testing.T, runner *nftablesRunner, src, dst netip.Addr) { - t.Helper() - if err := runner.EnsureSNATForDst(src, dst); err != nil { - t.Fatalf("error ensuring SNAT rule: %v", err) - } -} - -// checkSNATRule_nft verifies that a SNAT rule for the given destination and source exists. -func checkSNATRule_nft(t *testing.T, runner *nftablesRunner, fam nftables.TableFamily, src, dst netip.Addr) { - t.Helper() - chains, err := runner.conn.ListChainsOfTableFamily(fam) - if err != nil { - t.Fatalf("error listing chains: %v", err) - } - var chain *nftables.Chain - for _, ch := range chains { - if ch.Name == "POSTROUTING" { - chain = ch - break - } - } - if chain == nil { - t.Fatal("POSTROUTING chain does not exist") - } - meta := []byte(fmt.Sprintf("dst:%s,src:%s", dst.String(), src.String())) - wantsRule := snatRule(chain.Table, chain, src, dst, meta) - checkRule(t, wantsRule, runner.conn) -} diff --git a/util/lru/lru_test.go b/util/lru/lru_test.go deleted file mode 100644 index fb538efbe7957..0000000000000 --- a/util/lru/lru_test.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package lru - -import ( - "bytes" - "math/rand" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - xmaps "golang.org/x/exp/maps" -) - -func TestLRU(t *testing.T) { - var c Cache[int, string] - c.Set(1, "one") - c.Set(2, "two") - if g, w := c.Get(1), "one"; g != w { - t.Errorf("got %q; want %q", g, w) - } - if g, w := c.Get(2), "two"; g != w { - t.Errorf("got %q; want %q", g, w) - } - c.DeleteOldest() - if g, w := c.Get(1), ""; g != w { - t.Errorf("got %q; want %q", g, w) - } - if g, w := c.Len(), 1; g != w { - t.Errorf("Len = %d; want %d", g, w) - } - c.MaxEntries = 2 - c.Set(1, "one") - c.Set(2, "two") - c.Set(3, "three") - if c.Contains(1) { - t.Errorf("contains 1; should not") - } - if !c.Contains(2) { - t.Errorf("doesn't contain 2; should") - } - if !c.Contains(3) { - t.Errorf("doesn't contain 3; should") - } - c.Delete(3) - if c.Contains(3) { - t.Errorf("contains 3; should not") - } - c.Clear() - if g, w := c.Len(), 0; g != w { - t.Errorf("Len = %d; want %d", g, w) - } -} - -func TestLRUDeleteCorruption(t *testing.T) { - // Regression test for tailscale/corp#14747 - - c := Cache[int, bool]{} - - c.Set(1, true) - c.Set(2, true) // now 2 is the head - c.Delete(2) // delete the head - c.check(t) -} - -func TestStressEvictions(t *testing.T) { - const ( - cacheSize = 1_000 - numKeys = 10_000 - numProbes = 100_000 - ) - - vm := map[uint64]bool{} - for len(vm) < numKeys { - vm[rand.Uint64()] = true - } - vals := xmaps.Keys(vm) - - c := Cache[uint64, bool]{ - MaxEntries: cacheSize, - } - - for range numProbes { - v := vals[rand.Intn(len(vals))] - c.Set(v, true) - if l := c.Len(); l > cacheSize { - t.Fatalf("Cache size now %d, want max %d", l, cacheSize) - } - } -} - -func TestStressBatchedEvictions(t *testing.T) { - // One of Cache's consumers dynamically adjusts the cache size at - // runtime, and does batched evictions as needed. This test - // simulates that behavior. - - const ( - cacheSizeMin = 1_000 - cacheSizeMax = 2_000 - numKeys = 10_000 - numProbes = 100_000 - ) - - vm := map[uint64]bool{} - for len(vm) < numKeys { - vm[rand.Uint64()] = true - } - vals := xmaps.Keys(vm) - - c := Cache[uint64, bool]{} - - for range numProbes { - v := vals[rand.Intn(len(vals))] - c.Set(v, true) - if c.Len() == cacheSizeMax { - // Batch eviction down to cacheSizeMin - for c.Len() > cacheSizeMin { - c.DeleteOldest() - } - } - if l := c.Len(); l > cacheSizeMax { - t.Fatalf("Cache size now %d, want max %d", l, cacheSizeMax) - } - } -} - -func TestLRUStress(t *testing.T) { - var c Cache[int, int] - const ( - maxSize = 500 - numProbes = 5_000 - ) - for range numProbes { - n := rand.Intn(maxSize * 2) - op := rand.Intn(4) - switch op { - case 0: - c.Get(n) - case 1: - c.Set(n, n) - case 2: - c.Delete(n) - case 3: - for c.Len() > maxSize { - c.DeleteOldest() - } - } - c.check(t) - } -} - -// check verifies that c.lookup and c.head are consistent in size with -// each other, and that the ring has the same size when traversed in -// both directions. -func (c *Cache[K, V]) check(t testing.TB) { - size := c.Len() - nextLen := c.nextLen(t, size) - prevLen := c.prevLen(t, size) - if nextLen != size { - t.Fatalf("next list len %v != map len %v", nextLen, size) - } - if prevLen != size { - t.Fatalf("prev list len %v != map len %v", prevLen, size) - } -} - -// nextLen returns the length of the ring at c.head when traversing -// the .next pointers. -func (c *Cache[K, V]) nextLen(t testing.TB, limit int) (n int) { - if c.head == nil { - return 0 - } - n = 1 - at := c.head.next - for at != c.head { - limit-- - if limit < 0 { - t.Fatal("next list is too long") - } - n++ - at = at.next - } - return n -} - -// prevLen returns the length of the ring at c.head when traversing -// the .prev pointers. -func (c *Cache[K, V]) prevLen(t testing.TB, limit int) (n int) { - if c.head == nil { - return 0 - } - n = 1 - at := c.head.prev - for at != c.head { - limit-- - if limit < 0 { - t.Fatal("next list is too long") - } - n++ - at = at.prev - } - return n -} - -func TestDumpHTML(t *testing.T) { - c := Cache[int, string]{MaxEntries: 3} - - c.Set(1, "foo") - c.Set(2, "bar") - c.Set(3, "qux") - c.Set(4, "wat") - - var out bytes.Buffer - c.DumpHTML(&out) - - want := strings.Join([]string{ - "", - "", - "", - "", - "", - "
KeyValue
4wat
3qux
2bar
", - }, "") - - if diff := cmp.Diff(out.String(), want); diff != "" { - t.Fatalf("wrong DumpHTML output (-got+want):\n%s", diff) - } -} - -func BenchmarkLRU(b *testing.B) { - const lruSize = 10 - const maxval = 15 // 33% more keys than the LRU can hold - - c := Cache[int, bool]{MaxEntries: lruSize} - b.ReportAllocs() - for range b.N { - k := rand.Intn(maxval) - if !c.Get(k) { - c.Set(k, true) - } - } -} diff --git a/util/mak/mak_test.go b/util/mak/mak_test.go deleted file mode 100644 index 4de499a9d5040..0000000000000 --- a/util/mak/mak_test.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package mak contains code to help make things. -package mak - -import ( - "reflect" - "testing" -) - -type M map[string]int - -func TestSet(t *testing.T) { - t.Run("unnamed", func(t *testing.T) { - var m map[string]int - Set(&m, "foo", 42) - Set(&m, "bar", 1) - Set(&m, "bar", 2) - want := map[string]int{ - "foo": 42, - "bar": 2, - } - if got := m; !reflect.DeepEqual(got, want) { - t.Errorf("got %v; want %v", got, want) - } - }) - t.Run("named", func(t *testing.T) { - var m M - Set(&m, "foo", 1) - Set(&m, "bar", 1) - Set(&m, "bar", 2) - want := M{ - "foo": 1, - "bar": 2, - } - if got := m; !reflect.DeepEqual(got, want) { - t.Errorf("got %v; want %v", got, want) - } - }) -} - -func TestNonNil(t *testing.T) { - var s []string - NonNil(&s) - if len(s) != 0 { - t.Errorf("slice len = %d; want 0", len(s)) - } - if s == nil { - t.Error("slice still nil") - } - - s = append(s, "foo") - NonNil(&s) - if len(s) != 1 { - t.Errorf("len = %d; want 1", len(s)) - } - if s[0] != "foo" { - t.Errorf("value = %q; want foo", s) - } - - var m map[string]string - NonNil(&m) - if len(m) != 0 { - t.Errorf("map len = %d; want 0", len(s)) - } - if m == nil { - t.Error("map still nil") - } -} - -func TestNonNilMapForJSON(t *testing.T) { - type M map[string]int - var m M - NonNilMapForJSON(&m) - if m == nil { - t.Fatal("still nil") - } -} - -func TestNonNilSliceForJSON(t *testing.T) { - type S []int - var s S - NonNilSliceForJSON(&s) - if s == nil { - t.Fatal("still nil") - } -} diff --git a/util/multierr/multierr_test.go b/util/multierr/multierr_test.go deleted file mode 100644 index de7721a665f40..0000000000000 --- a/util/multierr/multierr_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package multierr_test - -import ( - "errors" - "fmt" - "io" - "testing" - - qt "github.com/frankban/quicktest" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/util/multierr" -) - -func TestAll(t *testing.T) { - C := qt.New(t) - eqErr := qt.CmpEquals(cmpopts.EquateErrors()) - - type E = []error - N := multierr.New - - a := errors.New("a") - b := errors.New("b") - c := errors.New("c") - d := errors.New("d") - x := errors.New("x") - abcd := E{a, b, c, d} - - tests := []struct { - In E // input to New - WantNil bool // want nil returned? - WantSingle error // if non-nil, want this single error returned - WantErrors []error // if non-nil, want an Error composed of these errors returned - }{ - {In: nil, WantNil: true}, - - {In: E{nil}, WantNil: true}, - {In: E{nil, nil}, WantNil: true}, - - {In: E{a}, WantSingle: a}, - {In: E{a, nil}, WantSingle: a}, - {In: E{nil, a}, WantSingle: a}, - {In: E{nil, a, nil}, WantSingle: a}, - - {In: E{a, b}, WantErrors: E{a, b}}, - {In: E{nil, a, nil, b, nil}, WantErrors: E{a, b}}, - - {In: E{a, b, N(c, d)}, WantErrors: E{a, b, c, d}}, - {In: E{a, N(b, c), d}, WantErrors: E{a, b, c, d}}, - {In: E{N(a, b), c, d}, WantErrors: E{a, b, c, d}}, - {In: E{N(a, b), N(c, d)}, WantErrors: E{a, b, c, d}}, - {In: E{nil, N(a, nil, b), nil, N(c, d)}, WantErrors: E{a, b, c, d}}, - - {In: E{N(a, N(b, N(c, N(d))))}, WantErrors: E{a, b, c, d}}, - {In: E{N(N(N(N(a), b), c), d)}, WantErrors: E{a, b, c, d}}, - - {In: E{N(abcd...)}, WantErrors: E{a, b, c, d}}, - {In: E{N(abcd...), N(abcd...)}, WantErrors: E{a, b, c, d, a, b, c, d}}, - } - - for _, test := range tests { - got := multierr.New(test.In...) - if test.WantNil { - C.Assert(got, qt.IsNil) - continue - } - if test.WantSingle != nil { - C.Assert(got, eqErr, test.WantSingle) - continue - } - ee, _ := got.(multierr.Error) - C.Assert(ee.Errors(), eqErr, test.WantErrors) - - for _, e := range test.WantErrors { - C.Assert(ee.Is(e), qt.IsTrue) - } - C.Assert(ee.Is(x), qt.IsFalse) - } -} - -func TestRange(t *testing.T) { - C := qt.New(t) - - errA := errors.New("A") - errB := errors.New("B") - errC := errors.New("C") - errD := errors.New("D") - errCD := multierr.New(errC, errD) - errCD1 := fmt.Errorf("1:%w", errCD) - errE := errors.New("E") - errE1 := fmt.Errorf("1:%w", errE) - errE2 := fmt.Errorf("2:%w", errE1) - errF := errors.New("F") - root := multierr.New(errA, errB, errCD1, errE2, errF) - - var got []error - want := []error{root, errA, errB, errCD1, errCD, errC, errD, errE2, errE1, errE, errF} - multierr.Range(root, func(err error) bool { - got = append(got, err) - return true - }) - C.Assert(got, qt.CmpEquals(cmp.Comparer(func(x, y error) bool { - return x.Error() == y.Error() - })), want) -} - -var sink error - -func BenchmarkEmpty(b *testing.B) { - b.ReportAllocs() - for range b.N { - sink = multierr.New(nil, nil, nil, multierr.Error{}) - } -} - -func BenchmarkNonEmpty(b *testing.B) { - merr := multierr.New(io.ErrShortBuffer, io.ErrNoProgress) - b.ReportAllocs() - for range b.N { - sink = multierr.New(io.ErrUnexpectedEOF, merr, io.ErrClosedPipe) - } -} diff --git a/util/nocasemaps/nocase_test.go b/util/nocasemaps/nocase_test.go deleted file mode 100644 index 5275b3ee6ef23..0000000000000 --- a/util/nocasemaps/nocase_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package nocasemaps - -import ( - "strings" - "testing" - - qt "github.com/frankban/quicktest" - xmaps "golang.org/x/exp/maps" -) - -func pair[A, B any](a A, b B) (out struct { - A A - B B -}) { - out.A = a - out.B = b - return out -} - -func Test(t *testing.T) { - c := qt.New(t) - m := make(map[string]int) - Set(m, "hello", 1) - c.Assert(m, qt.DeepEquals, map[string]int{"hello": 1}) - Set(m, "HeLlO", 2) - c.Assert(m, qt.DeepEquals, map[string]int{"hello": 2}) - c.Assert(Get(m, "hello"), qt.Equals, 2) - c.Assert(pair(GetOk(m, "hello")), qt.Equals, pair(2, true)) - c.Assert(Get(m, "HeLlO"), qt.Equals, 2) - c.Assert(pair(GetOk(m, "HeLlO")), qt.Equals, pair(2, true)) - c.Assert(Get(m, "HELLO"), qt.Equals, 2) - c.Assert(pair(GetOk(m, "HELLO")), qt.Equals, pair(2, true)) - c.Assert(Get(m, "missing"), qt.Equals, 0) - c.Assert(pair(GetOk(m, "missing")), qt.Equals, pair(0, false)) - Set(m, "foo", 3) - Set(m, "BAR", 4) - Set(m, "bAz", 5) - c.Assert(m, qt.DeepEquals, map[string]int{"hello": 2, "foo": 3, "bar": 4, "baz": 5}) - Delete(m, "foo") - c.Assert(m, qt.DeepEquals, map[string]int{"hello": 2, "bar": 4, "baz": 5}) - Delete(m, "bar") - c.Assert(m, qt.DeepEquals, map[string]int{"hello": 2, "baz": 5}) - Delete(m, "BAZ") - c.Assert(m, qt.DeepEquals, map[string]int{"hello": 2}) - // test cases for AppendSliceElem with int slices - appendTestInt := make(map[string][]int) - Set(appendTestInt, "firsT", []int{7}) - c.Assert(appendTestInt, qt.DeepEquals, map[string][]int{"first": {7}}) - AppendSliceElem(appendTestInt, "firsT", 77) - c.Assert(appendTestInt, qt.DeepEquals, map[string][]int{"first": {7, 77}}) - Set(appendTestInt, "SeCOnd", []int{56}) - c.Assert(appendTestInt, qt.DeepEquals, map[string][]int{"first": {7, 77}, "second": {56}}) - AppendSliceElem(appendTestInt, "seCOnd", 563, 23) - c.Assert(appendTestInt, qt.DeepEquals, map[string][]int{"first": {7, 77}, "second": {56, 563, 23}}) - // test cases for AppendSliceElem with string slices - appendTestString := make(map[string][]string) - Set(appendTestString, "firsTSTRING", []string{"hi"}) - c.Assert(appendTestString, qt.DeepEquals, map[string][]string{"firststring": {"hi"}}) - AppendSliceElem(appendTestString, "firsTSTRING", "hello", "bye") - c.Assert(appendTestString, qt.DeepEquals, map[string][]string{"firststring": {"hi", "hello", "bye"}}) - -} - -var lowerTests = []struct{ in, want string }{ - {"", ""}, - {"abc", "abc"}, - {"AbC123", "abc123"}, - {"azAZ09_", "azaz09_"}, - {"longStrinGwitHmixofsmaLLandcAps", "longstringwithmixofsmallandcaps"}, - {"renan bastos 93 AOSDAJDJAIDJAIDAJIaidsjjaidijadsjiadjiOOKKO", "renan bastos 93 aosdajdjaidjaidajiaidsjjaidijadsjiadjiookko"}, - {"LONG\u2C6FSTRING\u2C6FWITH\u2C6FNONASCII\u2C6FCHARS", "long\u0250string\u0250with\u0250nonascii\u0250chars"}, - {"\u2C6D\u2C6D\u2C6D\u2C6D\u2C6D", "\u0251\u0251\u0251\u0251\u0251"}, // shrinks one byte per char - {"A\u0080\U0010FFFF", "a\u0080\U0010FFFF"}, // test utf8.RuneSelf and utf8.MaxRune -} - -func TestAppendToLower(t *testing.T) { - for _, tt := range lowerTests { - got := string(appendToLower(nil, tt.in)) - if got != tt.want { - t.Errorf("appendToLower(%q) = %q, want %q", tt.in, got, tt.want) - } - } -} - -func FuzzAppendToLower(f *testing.F) { - for _, tt := range lowerTests { - f.Add(tt.in) - } - f.Fuzz(func(t *testing.T, in string) { - got := string(appendToLower(nil, in)) - want := strings.ToLower(in) - if got != want { - t.Errorf("appendToLower(%q) = %q, want %q", in, got, want) - } - }) -} - -var ( - testLower = "production-server" - testUpper = "PRODUCTION-SERVER" - testMap = make(map[string]int) - testValue = 5 - testSink int -) - -func Benchmark(b *testing.B) { - for i, key := range []string{testLower, testUpper} { - b.Run([]string{"Lower", "Upper"}[i], func(b *testing.B) { - b.Run("Get", func(b *testing.B) { - b.Run("Naive", func(b *testing.B) { - b.ReportAllocs() - for range b.N { - testSink = testMap[strings.ToLower(key)] - } - }) - b.Run("NoCase", func(b *testing.B) { - b.ReportAllocs() - for range b.N { - testSink = Get(testMap, key) - } - }) - }) - b.Run("Set", func(b *testing.B) { - b.Run("Naive", func(b *testing.B) { - b.ReportAllocs() - testMap[strings.ToLower(key)] = testValue - for range b.N { - testMap[strings.ToLower(key)] = testValue - } - xmaps.Clear(testMap) - }) - b.Run("NoCase", func(b *testing.B) { - b.ReportAllocs() - Set(testMap, key, testValue) - for range b.N { - Set(testMap, key, testValue) - } - xmaps.Clear(testMap) - }) - }) - b.Run("Delete", func(b *testing.B) { - b.Run("Naive", func(b *testing.B) { - b.ReportAllocs() - for range b.N { - delete(testMap, strings.ToLower(key)) - } - }) - b.Run("NoCase", func(b *testing.B) { - b.ReportAllocs() - for range b.N { - Delete(testMap, key) - } - }) - }) - }) - } -} diff --git a/util/osdiag/osdiag_windows.go b/util/osdiag/osdiag_windows.go index 5dcce3beaf76e..bf4cbeb52d9d8 100644 --- a/util/osdiag/osdiag_windows.go +++ b/util/osdiag/osdiag_windows.go @@ -14,11 +14,11 @@ import ( "github.com/dblohm7/wingoes/com" "github.com/dblohm7/wingoes/pe" + "github.com/sagernet/tailscale/util/osdiag/internal/wsc" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/util/winutil/authenticode" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "tailscale.com/util/osdiag/internal/wsc" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/authenticode" ) var ( diff --git a/util/osdiag/osdiag_windows_test.go b/util/osdiag/osdiag_windows_test.go deleted file mode 100644 index b29b602ccb73c..0000000000000 --- a/util/osdiag/osdiag_windows_test.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package osdiag - -import ( - "errors" - "fmt" - "maps" - "strings" - "testing" - - "golang.org/x/sys/windows/registry" -) - -func makeLongBinaryValue() []byte { - buf := make([]byte, maxBinaryValueLen*2) - for i, _ := range buf { - buf[i] = byte(i % 0xFF) - } - return buf -} - -var testData = map[string]any{ - "": "I am the default", - "StringEmpty": "", - "StringShort": "Hello", - "StringLong": strings.Repeat("7", initialValueBufLen+1), - "MultiStringEmpty": []string{}, - "MultiStringSingle": []string{"Foo"}, - "MultiStringSingleEmpty": []string{""}, - "MultiString": []string{"Foo", "Bar", "Baz"}, - "MultiStringWithEmptyBeginning": []string{"", "Foo", "Bar"}, - "MultiStringWithEmptyMiddle": []string{"Foo", "", "Bar"}, - "MultiStringWithEmptyEnd": []string{"Foo", "Bar", ""}, - "DWord": uint32(0x12345678), - "QWord": uint64(0x123456789abcdef0), - "BinaryEmpty": []byte{}, - "BinaryShort": []byte{0x01, 0x02, 0x03, 0x04}, - "BinaryLong": makeLongBinaryValue(), -} - -const ( - keyNameTest = `SOFTWARE\Tailscale Test` - subKeyNameTest = "SubKey" -) - -func setValues(t *testing.T, k registry.Key) { - for vk, v := range testData { - var err error - switch tv := v.(type) { - case string: - err = k.SetStringValue(vk, tv) - case []string: - err = k.SetStringsValue(vk, tv) - case uint32: - err = k.SetDWordValue(vk, tv) - case uint64: - err = k.SetQWordValue(vk, tv) - case []byte: - err = k.SetBinaryValue(vk, tv) - default: - t.Fatalf("Unknown type") - } - - if err != nil { - t.Fatalf("Error setting %q: %v", vk, err) - } - } -} - -func TestRegistrySupportInfo(t *testing.T) { - // Make sure the key doesn't exist yet - k, err := registry.OpenKey(registry.CURRENT_USER, keyNameTest, registry.READ) - switch { - case err == nil: - k.Close() - t.Fatalf("Test key already exists") - case !errors.Is(err, registry.ErrNotExist): - t.Fatal(err) - } - - func() { - k, _, err := registry.CreateKey(registry.CURRENT_USER, keyNameTest, registry.WRITE) - if err != nil { - t.Fatalf("Error creating test key: %v", err) - } - defer k.Close() - - setValues(t, k) - - sk, _, err := registry.CreateKey(k, subKeyNameTest, registry.WRITE) - if err != nil { - t.Fatalf("Error creating test subkey: %v", err) - } - defer sk.Close() - - setValues(t, sk) - }() - - t.Cleanup(func() { - registry.DeleteKey(registry.CURRENT_USER, keyNameTest+"\\"+subKeyNameTest) - registry.DeleteKey(registry.CURRENT_USER, keyNameTest) - }) - - wantValuesData := maps.Clone(testData) - wantValuesData["BinaryLong"] = (wantValuesData["BinaryLong"].([]byte))[:maxBinaryValueLen] - - wantKeyData := make(map[string]any) - maps.Copy(wantKeyData, wantValuesData) - wantSubKeyData := make(map[string]any) - maps.Copy(wantSubKeyData, wantValuesData) - wantKeyData[subKeyNameTest] = wantSubKeyData - - wantData := map[string]any{ - "HKCU\\" + keyNameTest: wantKeyData, - } - - gotData, err := getRegistrySupportInfo(registry.CURRENT_USER, []string{keyNameTest}) - if err != nil { - t.Errorf("getRegistrySupportInfo error: %v", err) - } - - want, got := fmt.Sprintf("%#v", wantData), fmt.Sprintf("%#v", gotData) - if want != got { - t.Errorf("Compare error: want\n%s,\ngot %s", want, got) - } -} diff --git a/util/osshare/filesharingstatus_noop.go b/util/osshare/filesharingstatus_noop.go index 7f2b131904ea9..6e175e2fb909d 100644 --- a/util/osshare/filesharingstatus_noop.go +++ b/util/osshare/filesharingstatus_noop.go @@ -6,7 +6,7 @@ package osshare import ( - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) func SetFileSharingEnabled(enabled bool, logf logger.Logf) {} diff --git a/util/osshare/filesharingstatus_windows.go b/util/osshare/filesharingstatus_windows.go index 999fc1cf77372..7703b8aade715 100644 --- a/util/osshare/filesharingstatus_windows.go +++ b/util/osshare/filesharingstatus_windows.go @@ -11,8 +11,8 @@ import ( "path/filepath" "sync" + "github.com/sagernet/tailscale/types/logger" "golang.org/x/sys/windows/registry" - "tailscale.com/types/logger" ) const ( diff --git a/util/osuser/group_ids.go b/util/osuser/group_ids.go index f25861dbb4519..a76c91d08dec1 100644 --- a/util/osuser/group_ids.go +++ b/util/osuser/group_ids.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/version/distro" ) // GetGroupIds returns the list of group IDs that the user is a member of, or diff --git a/util/osuser/group_ids_test.go b/util/osuser/group_ids_test.go deleted file mode 100644 index 69e8336ea6872..0000000000000 --- a/util/osuser/group_ids_test.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package osuser - -import ( - "slices" - "testing" -) - -func TestParseGroupIds(t *testing.T) { - tests := []struct { - in string - expected []string - }{ - {"5000\x005001\n", []string{"5000", "5001"}}, - {"5000\n", []string{"5000"}}, - {"\n", []string{""}}, - } - for _, test := range tests { - actual := parseGroupIds([]byte(test.in)) - if !slices.Equal(actual, test.expected) { - t.Errorf("parseGroupIds(%q) = %q, wanted %s", test.in, actual, test.expected) - } - } -} diff --git a/util/osuser/user.go b/util/osuser/user.go index 2c7f2e24b9b11..facdbc44a4c3d 100644 --- a/util/osuser/user.go +++ b/util/osuser/user.go @@ -16,7 +16,7 @@ import ( "time" "unicode/utf8" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/version/distro" ) // LookupByUIDWithShell is like os/user.LookupId but handles a few edge cases diff --git a/util/pidowner/pidowner_linux.go b/util/pidowner/pidowner_linux.go index a07f512427062..c2b617a4ec334 100644 --- a/util/pidowner/pidowner_linux.go +++ b/util/pidowner/pidowner_linux.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "tailscale.com/util/lineiter" + "github.com/sagernet/tailscale/util/lineiter" ) func ownerOfPID(pid int) (userID string, err error) { diff --git a/util/pidowner/pidowner_test.go b/util/pidowner/pidowner_test.go deleted file mode 100644 index 19c9ab46dff01..0000000000000 --- a/util/pidowner/pidowner_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package pidowner - -import ( - "math/rand" - "os" - "os/user" - "testing" -) - -func TestOwnerOfPID(t *testing.T) { - id, err := OwnerOfPID(os.Getpid()) - if err == ErrNotImplemented { - t.Skip(err) - } - if err != nil { - t.Fatal(err) - } - t.Logf("id=%q", id) - - u, err := user.LookupId(id) - if err != nil { - t.Fatalf("LookupId: %v", err) - } - t.Logf("Got: %+v", u) -} - -// validate that OS implementation returns ErrProcessNotFound. -func TestNotFoundError(t *testing.T) { - // Try a bunch of times to stumble upon a pid that doesn't exist... - const tries = 50 - for range tries { - _, err := OwnerOfPID(rand.Intn(1e9)) - if err == ErrNotImplemented { - t.Skip(err) - } - if err == nil { - // We got unlucky and this pid existed. Try again. - continue - } - if err == ErrProcessNotFound { - // Pass. - return - } - t.Fatalf("Error is not ErrProcessNotFound: %T %v", err, err) - } - t.Errorf("after %d tries, couldn't find a process that didn't exist", tries) -} diff --git a/util/pool/pool.go b/util/pool/pool.go index 7014751e7ab77..e058ff79717ad 100644 --- a/util/pool/pool.go +++ b/util/pool/pool.go @@ -13,7 +13,7 @@ import ( "fmt" "math/rand/v2" - "tailscale.com/types/ptr" + "github.com/sagernet/tailscale/types/ptr" ) // consistencyCheck enables additional runtime checks to ensure that the pool diff --git a/util/pool/pool_test.go b/util/pool/pool_test.go deleted file mode 100644 index 9d8eacbcb9d0b..0000000000000 --- a/util/pool/pool_test.go +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package pool - -import ( - "slices" - "testing" -) - -func TestPool(t *testing.T) { - p := Pool[int]{} - - if got, want := p.Len(), 0; got != want { - t.Errorf("got initial length %v; want %v", got, want) - } - - h1 := p.Add(101) - h2 := p.Add(102) - h3 := p.Add(103) - h4 := p.Add(104) - - if got, want := p.Len(), 4; got != want { - t.Errorf("got length %v; want %v", got, want) - } - - tests := []struct { - h Handle[int] - want int - }{ - {h1, 101}, - {h2, 102}, - {h3, 103}, - {h4, 104}, - } - for i, test := range tests { - got, ok := p.Peek(test.h) - if !ok { - t.Errorf("test[%d]: did not find item", i) - continue - } - if got != test.want { - t.Errorf("test[%d]: got %v; want %v", i, got, test.want) - } - } - - if deleted := p.Delete(h2); !deleted { - t.Errorf("h2 not deleted") - } - if deleted := p.Delete(h2); deleted { - t.Errorf("h2 should not be deleted twice") - } - if got, want := p.Len(), 3; got != want { - t.Errorf("got length %v; want %v", got, want) - } - if _, ok := p.Peek(h2); ok { - t.Errorf("h2 still in pool") - } - - // Remove an item by handle - got, ok := p.Take(h4) - if !ok { - t.Errorf("h4 not found") - } - if got != 104 { - t.Errorf("got %v; want 104", got) - } - - // Take doesn't work on previously-taken or deleted items. - if _, ok := p.Take(h4); ok { - t.Errorf("h4 should not be taken twice") - } - if _, ok := p.Take(h2); ok { - t.Errorf("h2 should not be taken after delete") - } - - // Remove all items and return them - items := p.AppendTakeAll(nil) - want := []int{101, 103} - if !slices.Equal(items, want) { - t.Errorf("got items %v; want %v", items, want) - } - if got := p.Len(); got != 0 { - t.Errorf("got length %v; want 0", got) - } - - // Insert and then clear should result in no items. - p.Add(105) - p.Clear() - if got := p.Len(); got != 0 { - t.Errorf("got length %v; want 0", got) - } -} - -func TestTakeRandom(t *testing.T) { - p := Pool[int]{} - for i := 0; i < 10; i++ { - p.Add(i + 100) - } - - seen := make(map[int]bool) - for i := 0; i < 10; i++ { - item, ok := p.TakeRandom() - if !ok { - t.Errorf("unexpected empty pool") - break - } - if seen[item] { - t.Errorf("got duplicate item %v", item) - } - seen[item] = true - } - - // Verify that the pool is empty - if _, ok := p.TakeRandom(); ok { - t.Errorf("expected empty pool") - } - - for i := 0; i < 10; i++ { - want := 100 + i - if !seen[want] { - t.Errorf("item %v not seen", want) - } - } - - if t.Failed() { - t.Logf("seen: %+v", seen) - } -} - -func BenchmarkPool_AddDelete(b *testing.B) { - b.Run("impl=Pool", func(b *testing.B) { - p := Pool[int]{} - - // Warm up/force an initial allocation - h := p.Add(0) - p.Delete(h) - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - h := p.Add(i) - p.Delete(h) - } - }) - b.Run("impl=map", func(b *testing.B) { - p := make(map[int]bool) - - // Force initial allocation - p[0] = true - delete(p, 0) - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - p[i] = true - delete(p, i) - } - }) -} - -func BenchmarkPool_TakeRandom(b *testing.B) { - b.Run("impl=Pool", func(b *testing.B) { - p := Pool[int]{} - - // Insert the number of items we'll be taking, then reset the timer. - for i := 0; i < b.N; i++ { - p.Add(i) - } - b.ResetTimer() - - // Now benchmark taking all the items. - for i := 0; i < b.N; i++ { - p.TakeRandom() - } - - if p.Len() != 0 { - b.Errorf("pool not empty") - } - }) - b.Run("impl=map", func(b *testing.B) { - p := make(map[int]bool) - - // Insert the number of items we'll be taking, then reset the timer. - for i := 0; i < b.N; i++ { - p[i] = true - } - b.ResetTimer() - - // Now benchmark taking all the items. - for i := 0; i < b.N; i++ { - // Taking a random item is simulated by a single map iteration. - for k := range p { - delete(p, k) // "take" the item by removing it - break - } - } - - if len(p) != 0 { - b.Errorf("map not empty") - } - }) -} diff --git a/util/precompress/precompress.go b/util/precompress/precompress.go index 6d1a26efdd767..f859b7d80d297 100644 --- a/util/precompress/precompress.go +++ b/util/precompress/precompress.go @@ -17,8 +17,8 @@ import ( "path/filepath" "github.com/andybalholm/brotli" + "github.com/sagernet/tailscale/tsweb" "golang.org/x/sync/errgroup" - "tailscale.com/tsweb" ) // PrecompressDir compresses static assets in dirPath using Gzip and Brotli, so diff --git a/util/race/race_test.go b/util/race/race_test.go deleted file mode 100644 index d3838271226ac..0000000000000 --- a/util/race/race_test.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package race - -import ( - "context" - "errors" - "testing" - "time" - - "tailscale.com/tstest" -) - -func TestRaceSuccess1(t *testing.T) { - tstest.ResourceCheck(t) - - const want = "success" - rh := New[string]( - 10*time.Second, - func(context.Context) (string, error) { - return want, nil - }, func(context.Context) (string, error) { - t.Fatal("should not be called") - return "", nil - }) - res, err := rh.Start(context.Background()) - if err != nil { - t.Fatal(err) - } - if res != want { - t.Errorf("got res=%q, want %q", res, want) - } -} - -func TestRaceRetry(t *testing.T) { - tstest.ResourceCheck(t) - - const want = "fallback" - rh := New[string]( - 10*time.Second, - func(context.Context) (string, error) { - return "", errors.New("some error") - }, func(context.Context) (string, error) { - return want, nil - }) - res, err := rh.Start(context.Background()) - if err != nil { - t.Fatal(err) - } - if res != want { - t.Errorf("got res=%q, want %q", res, want) - } -} - -func TestRaceTimeout(t *testing.T) { - tstest.ResourceCheck(t) - - const want = "fallback" - rh := New[string]( - 100*time.Millisecond, - func(ctx context.Context) (string, error) { - // Block forever - <-ctx.Done() - return "", ctx.Err() - }, func(context.Context) (string, error) { - return want, nil - }) - res, err := rh.Start(context.Background()) - if err != nil { - t.Fatal(err) - } - if res != want { - t.Errorf("got res=%q, want %q", res, want) - } -} - -func TestRaceError(t *testing.T) { - tstest.ResourceCheck(t) - - err1 := errors.New("error 1") - err2 := errors.New("error 2") - - rh := New[string]( - 100*time.Millisecond, - func(ctx context.Context) (string, error) { - return "", err1 - }, func(context.Context) (string, error) { - return "", err2 - }) - - _, err := rh.Start(context.Background()) - if !errors.Is(err, err1) { - t.Errorf("wanted err to contain err1; got %v", err) - } - if !errors.Is(err, err2) { - t.Errorf("wanted err to contain err2; got %v", err) - } -} diff --git a/util/rands/cheap.go b/util/rands/cheap.go index 69785e086e664..f2531ec98abb4 100644 --- a/util/rands/cheap.go +++ b/util/rands/cheap.go @@ -9,7 +9,6 @@ package rands import ( "math/bits" - randv2 "math/rand/v2" ) diff --git a/util/rands/cheap_test.go b/util/rands/cheap_test.go deleted file mode 100644 index 756b55b4e0ddc..0000000000000 --- a/util/rands/cheap_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package rands - -import ( - "slices" - "testing" - - randv2 "math/rand/v2" -) - -func TestShuffleNoAllocs(t *testing.T) { - seed := randv2.Uint64() - data := make([]int, 100) - for i := range data { - data[i] = i - } - if n := testing.AllocsPerRun(1000, func() { - Shuffle(seed, data) - }); n > 0 { - t.Errorf("Rand got %v allocs per run", n) - } -} - -func BenchmarkStdRandV2Shuffle(b *testing.B) { - seed := randv2.Uint64() - data := make([]int, 100) - for i := range data { - data[i] = i - } - b.ReportAllocs() - for range b.N { - // PCG is the lightest source, taking just two uint64s, the chacha8 - // source has much larger state. - rng := randv2.New(randv2.NewPCG(seed, seed)) - rng.Shuffle(len(data), func(i, j int) { data[i], data[j] = data[j], data[i] }) - } -} - -func BenchmarkLocalShuffle(b *testing.B) { - seed := randv2.Uint64() - data := make([]int, 100) - for i := range data { - data[i] = i - } - b.ReportAllocs() - for range b.N { - Shuffle(seed, data) - } -} - -func TestPerm(t *testing.T) { - seed := uint64(12345) - p := Perm(seed, 100) - if len(p) != 100 { - t.Errorf("got %v; want 100", len(p)) - } - expect := [][]int{ - {5, 7, 1, 4, 0, 9, 2, 3, 6, 8}, - {0, 5, 9, 8, 1, 6, 2, 4, 3, 7}, - {5, 2, 3, 1, 9, 7, 6, 8, 4, 0}, - {4, 5, 7, 1, 6, 3, 8, 2, 0, 9}, - {5, 7, 0, 9, 2, 1, 8, 4, 6, 3}, - } - for i := range 5 { - got := Perm(seed+uint64(i), 10) - want := expect[i] - if !slices.Equal(got, want) { - t.Errorf("got %v; want %v", got, want) - } - } -} - -func TestShuffle(t *testing.T) { - seed := uint64(12345) - p := Perm(seed, 10) - if len(p) != 10 { - t.Errorf("got %v; want 10", len(p)) - } - - expect := [][]int{ - {9, 3, 7, 0, 5, 8, 1, 4, 2, 6}, - {9, 8, 6, 2, 3, 1, 7, 5, 0, 4}, - {1, 6, 2, 8, 4, 5, 7, 0, 3, 9}, - {4, 5, 0, 6, 7, 8, 3, 2, 1, 9}, - {8, 2, 4, 9, 0, 5, 1, 7, 3, 6}, - } - for i := range 5 { - Shuffle(seed+uint64(i), p) - want := expect[i] - if !slices.Equal(p, want) { - t.Errorf("got %v; want %v", p, want) - } - } -} diff --git a/util/rands/rands_test.go b/util/rands/rands_test.go deleted file mode 100644 index 5813f2bb46763..0000000000000 --- a/util/rands/rands_test.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package rands - -import "testing" - -func TestHexString(t *testing.T) { - for i := 0; i <= 8; i++ { - s := HexString(i) - if len(s) != i { - t.Errorf("HexString(%v) = %q; want len %v, not %v", i, s, i, len(s)) - } - } -} diff --git a/util/reload/reload.go b/util/reload/reload.go index f18f9ebd1028c..450d09e22ce78 100644 --- a/util/reload/reload.go +++ b/util/reload/reload.go @@ -14,8 +14,8 @@ import ( "reflect" "time" - "tailscale.com/syncs" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/logger" ) // DefaultInterval is the default value for ReloadOpts.Interval if none is diff --git a/util/reload/reload_test.go b/util/reload/reload_test.go deleted file mode 100644 index f6a38168659cd..0000000000000 --- a/util/reload/reload_test.go +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package reload - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "sync/atomic" - "testing" - "time" - - "tailscale.com/tstest" -) - -func TestReloader(t *testing.T) { - buf := []byte("hello world") - - ctx := context.Background() - r, err := newUnstarted[string](ctx, ReloadOpts[string]{ - Logf: t.Logf, - Read: func(context.Context) ([]byte, error) { - return buf, nil - }, - Unmarshal: func(b []byte) (string, error) { - return "The value is: " + string(b), nil - }, - }) - if err != nil { - t.Fatal(err) - } - - // We should have an initial value. - const wantInitial = "The value is: hello world" - if v := r.store.Load(); v != wantInitial { - t.Errorf("got initial value %q, want %q", v, wantInitial) - } - - // Reloading should result in a new value - buf = []byte("new value") - if err := r.updateOnce(); err != nil { - t.Fatal(err) - } - - const wantReload = "The value is: new value" - if v := r.store.Load(); v != wantReload { - t.Errorf("got reloaded value %q, want %q", v, wantReload) - } -} - -func TestReloader_InitialError(t *testing.T) { - fakeErr := errors.New("fake error") - - ctx := context.Background() - _, err := newUnstarted[string](ctx, ReloadOpts[string]{ - Logf: t.Logf, - Read: func(context.Context) ([]byte, error) { return nil, fakeErr }, - Unmarshal: func(b []byte) (string, error) { panic("unused because Read fails") }, - }) - if err == nil { - t.Fatal("expected non-nil error") - } - if !errors.Is(err, fakeErr) { - t.Errorf("wanted errors.Is(%v, fakeErr)=true", err) - } -} - -func TestReloader_ReloadError(t *testing.T) { - fakeErr := errors.New("fake error") - shouldError := false - - ctx := context.Background() - r, err := newUnstarted[string](ctx, ReloadOpts[string]{ - Logf: t.Logf, - Read: func(context.Context) ([]byte, error) { - return []byte("hello"), nil - }, - Unmarshal: func(b []byte) (string, error) { - if shouldError { - return "", fakeErr - } - return string(b), nil - }, - }) - if err != nil { - t.Fatal(err) - } - if got := r.store.Load(); got != "hello" { - t.Fatalf("got value %q, want \"hello\"", got) - } - - shouldError = true - - if err := r.updateOnce(); err == nil { - t.Errorf("expected error from updateOnce") - } - if got := r.store.Load(); got != "hello" { - t.Fatalf("got value %q, want \"hello\"", got) - } -} - -func TestReloader_Run(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var ncalls atomic.Int64 - load, err := New[string](ctx, ReloadOpts[string]{ - Logf: tstest.WhileTestRunningLogger(t), - Interval: 10 * time.Millisecond, - Read: func(context.Context) ([]byte, error) { - return []byte("hello"), nil - }, - Unmarshal: func(b []byte) (string, error) { - callNum := ncalls.Add(1) - if callNum == 3 { - cancel() - } - return fmt.Sprintf("call %d: %s", callNum, b), nil - }, - }) - if err != nil { - t.Fatal(err) - } - want := "call 1: hello" - if got := load(); got != want { - t.Fatalf("got value %q, want %q", got, want) - } - - // Wait for the periodic refresh to cancel our context - select { - case <-ctx.Done(): - case <-time.After(10 * time.Second): - t.Fatal("test timed out") - } - - // Depending on how goroutines get scheduled, we can either read call 2 - // (if we woke up before the run goroutine stores call 3), or call 3 - // (if we woke up after the run goroutine stores the next value). Check - // for both. - want1, want2 := "call 2: hello", "call 3: hello" - if got := load(); got != want1 && got != want2 { - t.Fatalf("got value %q, want %q or %q", got, want1, want2) - } -} - -func TestFromJSONFile(t *testing.T) { - type testStruct struct { - Value string - Number int - } - fpath := filepath.Join(t.TempDir(), "test.json") - if err := os.WriteFile(fpath, []byte(`{"Value": "hello", "Number": 1234}`), 0600); err != nil { - t.Fatal(err) - } - - ctx := context.Background() - r, err := newUnstarted(ctx, FromJSONFile[*testStruct](fpath)) - if err != nil { - t.Fatal(err) - } - - got := r.store.Load() - want := &testStruct{Value: "hello", Number: 1234} - if !reflect.DeepEqual(got, want) { - t.Errorf("got %+v, want %+v", got, want) - } -} diff --git a/util/ringbuffer/ringbuffer_test.go b/util/ringbuffer/ringbuffer_test.go deleted file mode 100644 index e10096bfbd771..0000000000000 --- a/util/ringbuffer/ringbuffer_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package ringbuffer - -import ( - "reflect" - "testing" -) - -func TestRingBuffer(t *testing.T) { - const numItems = 10 - rb := New[int](numItems) - - for i := range numItems - 1 { - rb.Add(i) - } - - t.Run("NotFull", func(t *testing.T) { - if ll := rb.Len(); ll != numItems-1 { - t.Fatalf("got len %d; want %d", ll, numItems-1) - } - all := rb.GetAll() - want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8} - if !reflect.DeepEqual(all, want) { - t.Fatalf("items mismatch\ngot: %v\nwant %v", all, want) - } - }) - - t.Run("Full", func(t *testing.T) { - // Append items to evict something - rb.Add(98) - rb.Add(99) - - if ll := rb.Len(); ll != numItems { - t.Fatalf("got len %d; want %d", ll, numItems) - } - all := rb.GetAll() - want := []int{1, 2, 3, 4, 5, 6, 7, 8, 98, 99} - if !reflect.DeepEqual(all, want) { - t.Fatalf("items mismatch\ngot: %v\nwant %v", all, want) - } - }) - - t.Run("Clear", func(t *testing.T) { - rb.Clear() - if ll := rb.Len(); ll != 0 { - t.Fatalf("got len %d; want 0", ll) - } - all := rb.GetAll() - if len(all) != 0 { - t.Fatalf("got non-empty list; want empty") - } - }) -} diff --git a/util/set/set_test.go b/util/set/set_test.go deleted file mode 100644 index 85913ad24a216..0000000000000 --- a/util/set/set_test.go +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package set - -import ( - "encoding/json" - "slices" - "testing" -) - -func TestSet(t *testing.T) { - s := Set[int]{} - s.Add(1) - s.Add(2) - if !s.Contains(1) { - t.Error("missing 1") - } - if !s.Contains(2) { - t.Error("missing 2") - } - if s.Contains(3) { - t.Error("shouldn't have 3") - } - if s.Len() != 2 { - t.Errorf("wrong len %d; want 2", s.Len()) - } - - more := []int{3, 4} - s.AddSlice(more) - if !s.Contains(3) { - t.Error("missing 3") - } - if !s.Contains(4) { - t.Error("missing 4") - } - if s.Contains(5) { - t.Error("shouldn't have 5") - } - if s.Len() != 4 { - t.Errorf("wrong len %d; want 4", s.Len()) - } - - es := s.Slice() - if len(es) != 4 { - t.Errorf("slice has wrong len %d; want 4", len(es)) - } - for _, e := range []int{1, 2, 3, 4} { - if !slices.Contains(es, e) { - t.Errorf("slice missing %d (%#v)", e, es) - } - } -} - -func TestSetOf(t *testing.T) { - s := Of(1, 2, 3, 4, 4, 1) - if s.Len() != 4 { - t.Errorf("wrong len %d; want 4", s.Len()) - } - for _, n := range []int{1, 2, 3, 4} { - if !s.Contains(n) { - t.Errorf("should contain %d", n) - } - } -} - -func TestEqual(t *testing.T) { - type test struct { - name string - a Set[int] - b Set[int] - expected bool - } - tests := []test{ - { - "equal", - Of(1, 2, 3, 4), - Of(1, 2, 3, 4), - true, - }, - { - "not equal", - Of(1, 2, 3, 4), - Of(1, 2, 3, 5), - false, - }, - { - "different lengths", - Of(1, 2, 3, 4, 5), - Of(1, 2, 3, 5), - false, - }, - } - - for _, tt := range tests { - if tt.a.Equal(tt.b) != tt.expected { - t.Errorf("%s: failed", tt.name) - } - } -} - -func TestClone(t *testing.T) { - s := Of(1, 2, 3, 4, 4, 1) - if s.Len() != 4 { - t.Errorf("wrong len %d; want 4", s.Len()) - } - s2 := s.Clone() - if !s.Equal(s2) { - t.Error("clone not equal to original") - } - s.Add(100) - if s.Equal(s2) { - t.Error("clone is not distinct from original") - } -} - -func TestSetJSONRoundTrip(t *testing.T) { - tests := []struct { - desc string - strings Set[string] - ints Set[int] - }{ - {"empty", make(Set[string]), make(Set[int])}, - {"nil", nil, nil}, - {"one-item", Of("one"), Of(1)}, - {"multiple-items", Of("one", "two", "three"), Of(1, 2, 3)}, - } - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - t.Run("strings", func(t *testing.T) { - buf, err := json.Marshal(tt.strings) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - t.Logf("marshaled: %s", buf) - var s Set[string] - if err := json.Unmarshal(buf, &s); err != nil { - t.Fatalf("json.Unmarshal: %v", err) - } - if !s.Equal(tt.strings) { - t.Errorf("set changed after JSON marshal/unmarshal, before: %v, after: %v", tt.strings, s) - } - }) - t.Run("ints", func(t *testing.T) { - buf, err := json.Marshal(tt.ints) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - t.Logf("marshaled: %s", buf) - var s Set[int] - if err := json.Unmarshal(buf, &s); err != nil { - t.Fatalf("json.Unmarshal: %v", err) - } - if !s.Equal(tt.ints) { - t.Errorf("set changed after JSON marshal/unmarshal, before: %v, after: %v", tt.ints, s) - } - }) - }) - } -} - -func TestMake(t *testing.T) { - var s Set[int] - s.Make() - s.Add(1) - if !s.Contains(1) { - t.Error("missing 1") - } -} diff --git a/util/set/slice.go b/util/set/slice.go index 2fc65b82d1c6e..968dfacaa6bd9 100644 --- a/util/set/slice.go +++ b/util/set/slice.go @@ -6,7 +6,7 @@ package set import ( "slices" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/types/views" ) // Slice is a set of elements tracked in a slice of unique elements. diff --git a/util/set/slice_test.go b/util/set/slice_test.go deleted file mode 100644 index 9134c296292d3..0000000000000 --- a/util/set/slice_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package set - -import ( - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestSliceSet(t *testing.T) { - c := qt.New(t) - - var ss Slice[int] - c.Check(len(ss.slice), qt.Equals, 0) - ss.Add(1) - c.Check(len(ss.slice), qt.Equals, 1) - c.Check(len(ss.set), qt.Equals, 0) - c.Check(ss.Contains(1), qt.Equals, true) - c.Check(ss.Contains(2), qt.Equals, false) - - ss.Add(1) - c.Check(len(ss.slice), qt.Equals, 1) - c.Check(len(ss.set), qt.Equals, 0) - - ss.Add(2) - ss.Add(3) - ss.Add(4) - ss.Add(5) - ss.Add(6) - ss.Add(7) - ss.Add(8) - c.Check(len(ss.slice), qt.Equals, 8) - c.Check(len(ss.set), qt.Equals, 0) - - ss.Add(9) - c.Check(len(ss.slice), qt.Equals, 9) - c.Check(len(ss.set), qt.Equals, 9) - - ss.Remove(4) - c.Check(len(ss.slice), qt.Equals, 8) - c.Check(len(ss.set), qt.Equals, 8) - c.Assert(ss.Contains(4), qt.IsFalse) - - // Ensure that the order of insertion is maintained - c.Assert(ss.Slice().AsSlice(), qt.DeepEquals, []int{1, 2, 3, 5, 6, 7, 8, 9}) - ss.Add(4) - c.Check(len(ss.slice), qt.Equals, 9) - c.Check(len(ss.set), qt.Equals, 9) - c.Assert(ss.Contains(4), qt.IsTrue) - c.Assert(ss.Slice().AsSlice(), qt.DeepEquals, []int{1, 2, 3, 5, 6, 7, 8, 9, 4}) - - ss.Add(1, 234, 556) - c.Assert(ss.Slice().AsSlice(), qt.DeepEquals, []int{1, 2, 3, 5, 6, 7, 8, 9, 4, 234, 556}) -} diff --git a/util/singleflight/singleflight_test.go b/util/singleflight/singleflight_test.go deleted file mode 100644 index 031922736fab6..0000000000000 --- a/util/singleflight/singleflight_test.go +++ /dev/null @@ -1,476 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Copyright 2013 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package singleflight - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "os/exec" - "runtime" - "runtime/debug" - "strings" - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestDo(t *testing.T) { - var g Group[string, any] - v, err, _ := g.Do("key", func() (interface{}, error) { - return "bar", nil - }) - if got, want := fmt.Sprintf("%v (%T)", v, v), "bar (string)"; got != want { - t.Errorf("Do = %v; want %v", got, want) - } - if err != nil { - t.Errorf("Do error = %v", err) - } -} - -func TestDoErr(t *testing.T) { - var g Group[string, any] - someErr := errors.New("Some error") - v, err, _ := g.Do("key", func() (interface{}, error) { - return nil, someErr - }) - if err != someErr { - t.Errorf("Do error = %v; want someErr %v", err, someErr) - } - if v != nil { - t.Errorf("unexpected non-nil value %#v", v) - } -} - -func TestDoDupSuppress(t *testing.T) { - var g Group[string, any] - var wg1, wg2 sync.WaitGroup - c := make(chan string, 1) - var calls int32 - fn := func() (interface{}, error) { - if atomic.AddInt32(&calls, 1) == 1 { - // First invocation. - wg1.Done() - } - v := <-c - c <- v // pump; make available for any future calls - - time.Sleep(10 * time.Millisecond) // let more goroutines enter Do - - return v, nil - } - - const n = 10 - wg1.Add(1) - for range n { - wg1.Add(1) - wg2.Add(1) - go func() { - defer wg2.Done() - wg1.Done() - v, err, _ := g.Do("key", fn) - if err != nil { - t.Errorf("Do error: %v", err) - return - } - if s, _ := v.(string); s != "bar" { - t.Errorf("Do = %T %v; want %q", v, v, "bar") - } - }() - } - wg1.Wait() - // At least one goroutine is in fn now and all of them have at - // least reached the line before the Do. - c <- "bar" - wg2.Wait() - if got := atomic.LoadInt32(&calls); got <= 0 || got >= n { - t.Errorf("number of calls = %d; want over 0 and less than %d", got, n) - } -} - -// Test that singleflight behaves correctly after Forget called. -// See https://github.com/golang/go/issues/31420 -func TestForget(t *testing.T) { - var g Group[string, any] - - var ( - firstStarted = make(chan struct{}) - unblockFirst = make(chan struct{}) - firstFinished = make(chan struct{}) - ) - - go func() { - g.Do("key", func() (i interface{}, e error) { - close(firstStarted) - <-unblockFirst - close(firstFinished) - return - }) - }() - <-firstStarted - g.Forget("key") - - unblockSecond := make(chan struct{}) - secondResult := g.DoChan("key", func() (i interface{}, e error) { - <-unblockSecond - return 2, nil - }) - - close(unblockFirst) - <-firstFinished - - thirdResult := g.DoChan("key", func() (i interface{}, e error) { - return 3, nil - }) - - close(unblockSecond) - <-secondResult - r := <-thirdResult - if r.Val != 2 { - t.Errorf("We should receive result produced by second call, expected: 2, got %d", r.Val) - } -} - -func TestDoChan(t *testing.T) { - var g Group[string, any] - ch := g.DoChan("key", func() (interface{}, error) { - return "bar", nil - }) - - res := <-ch - v := res.Val - err := res.Err - if got, want := fmt.Sprintf("%v (%T)", v, v), "bar (string)"; got != want { - t.Errorf("Do = %v; want %v", got, want) - } - if err != nil { - t.Errorf("Do error = %v", err) - } -} - -// Test singleflight behaves correctly after Do panic. -// See https://github.com/golang/go/issues/41133 -func TestPanicDo(t *testing.T) { - var g Group[string, any] - fn := func() (interface{}, error) { - panic("invalid memory address or nil pointer dereference") - } - - const n = 5 - waited := int32(n) - panicCount := int32(0) - done := make(chan struct{}) - for range n { - go func() { - defer func() { - if err := recover(); err != nil { - t.Logf("Got panic: %v\n%s", err, debug.Stack()) - atomic.AddInt32(&panicCount, 1) - } - - if atomic.AddInt32(&waited, -1) == 0 { - close(done) - } - }() - - g.Do("key", fn) - }() - } - - select { - case <-done: - if panicCount != n { - t.Errorf("Expect %d panic, but got %d", n, panicCount) - } - case <-time.After(time.Second): - t.Fatalf("Do hangs") - } -} - -func TestGoexitDo(t *testing.T) { - var g Group[string, any] - fn := func() (interface{}, error) { - runtime.Goexit() - return nil, nil - } - - const n = 5 - waited := int32(n) - done := make(chan struct{}) - for range n { - go func() { - var err error - defer func() { - if err != nil { - t.Errorf("Error should be nil, but got: %v", err) - } - if atomic.AddInt32(&waited, -1) == 0 { - close(done) - } - }() - _, err, _ = g.Do("key", fn) - }() - } - - select { - case <-done: - case <-time.After(time.Second): - t.Fatalf("Do hangs") - } -} - -func TestPanicDoChan(t *testing.T) { - if runtime.GOOS == "js" { - t.Skipf("js does not support exec") - } - - if os.Getenv("TEST_PANIC_DOCHAN") != "" { - defer func() { - recover() - }() - - g := new(Group[string, any]) - ch := g.DoChan("", func() (interface{}, error) { - panic("Panicking in DoChan") - }) - <-ch - t.Fatalf("DoChan unexpectedly returned") - } - - t.Parallel() - - cmd := exec.Command(os.Args[0], "-test.run="+t.Name(), "-test.v") - cmd.Env = append(os.Environ(), "TEST_PANIC_DOCHAN=1") - out := new(bytes.Buffer) - cmd.Stdout = out - cmd.Stderr = out - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - - err := cmd.Wait() - t.Logf("%s:\n%s", strings.Join(cmd.Args, " "), out) - if err == nil { - t.Errorf("Test subprocess passed; want a crash due to panic in DoChan") - } - if bytes.Contains(out.Bytes(), []byte("DoChan unexpectedly")) { - t.Errorf("Test subprocess failed with an unexpected failure mode.") - } - if !bytes.Contains(out.Bytes(), []byte("Panicking in DoChan")) { - t.Errorf("Test subprocess failed, but the crash isn't caused by panicking in DoChan") - } -} - -func TestPanicDoSharedByDoChan(t *testing.T) { - if runtime.GOOS == "js" { - t.Skipf("js does not support exec") - } - - if os.Getenv("TEST_PANIC_DOCHAN") != "" { - blocked := make(chan struct{}) - unblock := make(chan struct{}) - - g := new(Group[string, any]) - go func() { - defer func() { - recover() - }() - g.Do("", func() (interface{}, error) { - close(blocked) - <-unblock - panic("Panicking in Do") - }) - }() - - <-blocked - ch := g.DoChan("", func() (interface{}, error) { - panic("DoChan unexpectedly executed callback") - }) - close(unblock) - <-ch - t.Fatalf("DoChan unexpectedly returned") - } - - t.Parallel() - - cmd := exec.Command(os.Args[0], "-test.run="+t.Name(), "-test.v") - cmd.Env = append(os.Environ(), "TEST_PANIC_DOCHAN=1") - out := new(bytes.Buffer) - cmd.Stdout = out - cmd.Stderr = out - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - - err := cmd.Wait() - t.Logf("%s:\n%s", strings.Join(cmd.Args, " "), out) - if err == nil { - t.Errorf("Test subprocess passed; want a crash due to panic in Do shared by DoChan") - } - if bytes.Contains(out.Bytes(), []byte("DoChan unexpectedly")) { - t.Errorf("Test subprocess failed with an unexpected failure mode.") - } - if !bytes.Contains(out.Bytes(), []byte("Panicking in Do")) { - t.Errorf("Test subprocess failed, but the crash isn't caused by panicking in Do") - } -} - -func TestDoChanContext(t *testing.T) { - t.Run("Basic", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var g Group[string, int] - ch := g.DoChanContext(ctx, "key", func(_ context.Context) (int, error) { - return 1, nil - }) - ret := <-ch - assertOKResult(t, ret, 1) - }) - - t.Run("DoesNotPropagateValues", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - key := new(int) - const value = "hello world" - - ctx = context.WithValue(ctx, key, value) - - var g Group[string, int] - ch := g.DoChanContext(ctx, "foobar", func(ctx context.Context) (int, error) { - if _, ok := ctx.Value(key).(string); ok { - t.Error("expected no value, but was present in context") - } - return 1, nil - }) - ret := <-ch - assertOKResult(t, ret, 1) - }) - - t.Run("NoCancelWhenWaiters", func(t *testing.T) { - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - trigger := make(chan struct{}) - - ctx1, cancel1 := context.WithCancel(context.Background()) - defer cancel1() - ctx2, cancel2 := context.WithCancel(context.Background()) - defer cancel2() - - fn := func(ctx context.Context) (int, error) { - select { - case <-ctx.Done(): - return 0, ctx.Err() - case <-trigger: - return 1234, nil - } - } - - // Create two waiters, then cancel the first before we trigger - // the function to return a value. This shouldn't result in a - // context canceled error. - var g Group[string, int] - ch1 := g.DoChanContext(ctx1, "key", fn) - ch2 := g.DoChanContext(ctx2, "key", fn) - - cancel1() - - // The first channel, now that it's canceled, should return a - // context canceled error. - select { - case res := <-ch1: - if !errors.Is(res.Err, context.Canceled) { - t.Errorf("unexpected error; got %v, want context.Canceled", res.Err) - } - case <-testCtx.Done(): - t.Fatal("test timed out") - } - - // Actually return - close(trigger) - res := <-ch2 - assertOKResult(t, res, 1234) - }) - - t.Run("AllCancel", func(t *testing.T) { - for _, n := range []int{1, 2, 10, 20} { - t.Run(fmt.Sprintf("NumWaiters=%d", n), func(t *testing.T) { - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - trigger := make(chan struct{}) - defer close(trigger) - - fn := func(ctx context.Context) (int, error) { - select { - case <-ctx.Done(): - return 0, ctx.Err() - case <-trigger: - t.Error("unexpected trigger; want all callers to cancel") - return 0, errors.New("unexpected trigger") - } - } - - // Launch N goroutines that all wait on the same key. - var ( - g Group[string, int] - chs []<-chan Result[int] - cancels []context.CancelFunc - ) - for i := range n { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - cancels = append(cancels, cancel) - - ch := g.DoChanContext(ctx, "key", fn) - chs = append(chs, ch) - - // Every third goroutine should cancel - // immediately, which better tests the - // cancel logic. - if i%3 == 0 { - cancel() - } - } - - // Now that everything is waiting, cancel all the contexts. - for _, cancel := range cancels { - cancel() - } - - // Wait for a result from each channel. They - // should all return an error showing a context - // cancel. - for _, ch := range chs { - select { - case res := <-ch: - if !errors.Is(res.Err, context.Canceled) { - t.Errorf("unexpected error; got %v, want context.Canceled", res.Err) - } - case <-testCtx.Done(): - t.Fatal("test timed out") - } - } - }) - } - }) -} - -func assertOKResult[V comparable](t testing.TB, res Result[V], want V) { - if res.Err != nil { - t.Fatalf("unexpected error: %v", res.Err) - } - if res.Val != want { - t.Fatalf("unexpected value; got %v, want %v", res.Val, want) - } -} diff --git a/util/slicesx/slicesx_test.go b/util/slicesx/slicesx_test.go deleted file mode 100644 index 597b22b8335fe..0000000000000 --- a/util/slicesx/slicesx_test.go +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package slicesx - -import ( - "reflect" - "slices" - "testing" - - qt "github.com/frankban/quicktest" -) - -func TestInterleave(t *testing.T) { - testCases := []struct { - name string - a, b []int - want []int - }{ - {name: "equal", a: []int{1, 3, 5}, b: []int{2, 4, 6}, want: []int{1, 2, 3, 4, 5, 6}}, - {name: "short_b", a: []int{1, 3, 5}, b: []int{2, 4}, want: []int{1, 2, 3, 4, 5}}, - {name: "short_a", a: []int{1, 3}, b: []int{2, 4, 6}, want: []int{1, 2, 3, 4, 6}}, - {name: "len_1", a: []int{1}, b: []int{2, 4, 6}, want: []int{1, 2, 4, 6}}, - {name: "nil_a", a: nil, b: []int{2, 4, 6}, want: []int{2, 4, 6}}, - {name: "nil_all", a: nil, b: nil, want: nil}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - merged := Interleave(tc.a, tc.b) - if !reflect.DeepEqual(merged, tc.want) { - t.Errorf("got %v; want %v", merged, tc.want) - } - }) - } -} - -func BenchmarkInterleave(b *testing.B) { - b.ReportAllocs() - b.ResetTimer() - for range b.N { - Interleave( - []int{1, 2, 3}, - []int{9, 8, 7}, - ) - } -} - -func TestShuffle(t *testing.T) { - var sl []int - for i := range 100 { - sl = append(sl, i) - } - - var wasShuffled bool - for try := 0; try < 10; try++ { - shuffled := slices.Clone(sl) - Shuffle(shuffled) - if !reflect.DeepEqual(shuffled, sl) { - wasShuffled = true - break - } - } - - if !wasShuffled { - t.Errorf("expected shuffle after 10 tries") - } -} - -func TestPartition(t *testing.T) { - var sl []int - for i := 1; i <= 10; i++ { - sl = append(sl, i) - } - - evens, odds := Partition(sl, func(elem int) bool { - return elem%2 == 0 - }) - - wantEvens := []int{2, 4, 6, 8, 10} - wantOdds := []int{1, 3, 5, 7, 9} - if !reflect.DeepEqual(evens, wantEvens) { - t.Errorf("evens: got %v, want %v", evens, wantEvens) - } - if !reflect.DeepEqual(odds, wantOdds) { - t.Errorf("odds: got %v, want %v", odds, wantOdds) - } -} - -func TestEqualSameNil(t *testing.T) { - c := qt.New(t) - c.Check(EqualSameNil([]string{"a"}, []string{"a"}), qt.Equals, true) - c.Check(EqualSameNil([]string{"a"}, []string{"b"}), qt.Equals, false) - c.Check(EqualSameNil([]string{"a"}, []string{}), qt.Equals, false) - c.Check(EqualSameNil([]string{}, []string{}), qt.Equals, true) - c.Check(EqualSameNil(nil, []string{}), qt.Equals, false) - c.Check(EqualSameNil([]string{}, nil), qt.Equals, false) - c.Check(EqualSameNil[[]string](nil, nil), qt.Equals, true) -} - -func TestFilter(t *testing.T) { - var sl []int - for i := 1; i <= 10; i++ { - sl = append(sl, i) - } - - evens := Filter(nil, sl, func(elem int) bool { - return elem%2 == 0 - }) - - want := []int{2, 4, 6, 8, 10} - if !reflect.DeepEqual(evens, want) { - t.Errorf("evens: got %v, want %v", evens, want) - } -} - -func TestFilterNoAllocations(t *testing.T) { - var sl []int - for i := 1; i <= 10; i++ { - sl = append(sl, i) - } - - want := []int{2, 4, 6, 8, 10} - allocs := testing.AllocsPerRun(1000, func() { - src := slices.Clone(sl) - evens := Filter(src[:0], src, func(elem int) bool { - return elem%2 == 0 - }) - if !slices.Equal(evens, want) { - t.Errorf("evens: got %v, want %v", evens, want) - } - }) - - // 1 alloc for 'src', nothing else - if allocs != 1 { - t.Fatalf("got %.4f allocs, want 1", allocs) - } -} - -func TestAppendMatching(t *testing.T) { - v := []string{"one", "two", "three", "four"} - got := AppendMatching(v[:0], v, func(s string) bool { return len(s) > 3 }) - - want := []string{"three", "four"} - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v; want %v", got, want) - } - - wantOrigMem := []string{"three", "four", "three", "four"} - if !reflect.DeepEqual(v, wantOrigMem) { - t.Errorf("got %v; want %v", v, wantOrigMem) - } -} - -func TestCutPrefix(t *testing.T) { - tests := []struct { - name string - s, prefix []int - after []int - found bool - }{ - {"has-prefix", []int{1, 2, 3}, []int{1}, []int{2, 3}, true}, - {"exact-prefix", []int{1, 2, 3}, []int{1, 2, 3}, []int{}, true}, - {"blank-prefix", []int{1, 2, 3}, []int{}, []int{1, 2, 3}, true}, - {"no-prefix", []int{1, 2, 3}, []int{42}, []int{1, 2, 3}, false}, - {"blank-slice", []int{}, []int{42}, []int{}, false}, - {"blank-all", []int{}, []int{}, []int{}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if after, found := CutPrefix(tt.s, tt.prefix); !slices.Equal(after, tt.after) || found != tt.found { - t.Errorf("CutPrefix(%v, %v) = %v, %v; want %v, %v", tt.s, tt.prefix, after, found, tt.after, tt.found) - } - }) - } -} - -func TestCutSuffix(t *testing.T) { - tests := []struct { - name string - s, suffix []int - before []int - found bool - }{ - {"has-suffix", []int{1, 2, 3}, []int{3}, []int{1, 2}, true}, - {"exact-suffix", []int{1, 2, 3}, []int{1, 2, 3}, []int{}, true}, - {"blank-suffix", []int{1, 2, 3}, []int{}, []int{1, 2, 3}, true}, - {"no-suffix", []int{1, 2, 3}, []int{42}, []int{1, 2, 3}, false}, - {"blank-slice", []int{}, []int{42}, []int{}, false}, - {"blank-all", []int{}, []int{}, []int{}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if before, found := CutSuffix(tt.s, tt.suffix); !slices.Equal(before, tt.before) || found != tt.found { - t.Errorf("CutSuffix(%v, %v) = %v, %v; want %v, %v", tt.s, tt.suffix, before, found, tt.before, tt.found) - } - }) - } -} - -func TestFirstLastEqual(t *testing.T) { - tests := []struct { - name string - in string - v byte - f func([]byte, byte) bool - want bool - }{ - {"first-empty", "", 'f', FirstEqual[byte], false}, - {"first-true", "foo", 'f', FirstEqual[byte], true}, - {"first-false", "foo", 'b', FirstEqual[byte], false}, - {"last-empty", "", 'f', LastEqual[byte], false}, - {"last-true", "bar", 'r', LastEqual[byte], true}, - {"last-false", "bar", 'o', LastEqual[byte], false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.f([]byte(tt.in), tt.v); got != tt.want { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } - -} diff --git a/util/syspolicy/handler.go b/util/syspolicy/handler.go index f511f0a562e8b..d46d888c5d441 100644 --- a/util/syspolicy/handler.go +++ b/util/syspolicy/handler.go @@ -4,10 +4,10 @@ package syspolicy import ( - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/rsop" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/syspolicy/source" + "github.com/sagernet/tailscale/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/syspolicy/rsop" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/syspolicy/source" ) // TODO(nickkhyl): delete this file once other repos are updated. diff --git a/util/syspolicy/internal/internal.go b/util/syspolicy/internal/internal.go index 8f28896259abf..506fe08865cc9 100644 --- a/util/syspolicy/internal/internal.go +++ b/util/syspolicy/internal/internal.go @@ -9,8 +9,8 @@ import ( "bytes" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/lazy" - "tailscale.com/version" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/version" ) // Init facilitates deferred invocation of initializers. diff --git a/util/syspolicy/internal/loggerx/logger.go b/util/syspolicy/internal/loggerx/logger.go index c29a5f0845cd6..90cfb07497b38 100644 --- a/util/syspolicy/internal/loggerx/logger.go +++ b/util/syspolicy/internal/loggerx/logger.go @@ -8,9 +8,9 @@ import ( "log" "sync/atomic" - "tailscale.com/types/lazy" - "tailscale.com/types/logger" - "tailscale.com/util/syspolicy/internal" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/syspolicy/internal" ) const ( diff --git a/util/syspolicy/internal/loggerx/logger_test.go b/util/syspolicy/internal/loggerx/logger_test.go deleted file mode 100644 index 9735b5d30c20b..0000000000000 --- a/util/syspolicy/internal/loggerx/logger_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package loggerx - -import ( - "fmt" - "io" - "strings" - "testing" - - "tailscale.com/types/logger" -) - -func TestDebugLogging(t *testing.T) { - var normal, verbose strings.Builder - SetForTest(t, logfTo(&normal), logfTo(&verbose)) - - checkOutput := func(wantNormal, wantVerbose string) { - t.Helper() - if gotNormal := normal.String(); gotNormal != wantNormal { - t.Errorf("Unexpected normal output: got %q; want %q", gotNormal, wantNormal) - } - if gotVerbose := verbose.String(); gotVerbose != wantVerbose { - t.Errorf("Unexpected verbose output: got %q; want %q", gotVerbose, wantVerbose) - } - normal.Reset() - verbose.Reset() - } - - Errorf("This is an error message: %v", 42) - checkOutput("This is an error message: 42", "") - Verbosef("This is a verbose message: %v", 17) - checkOutput("", "This is a verbose message: 17") - - SetDebugLoggingEnabled(true) - Errorf("This is an error message: %v", 42) - checkOutput("This is an error message: 42", "") - Verbosef("This is a verbose message: %v", 17) - checkOutput("This is a verbose message: 17", "") - - SetDebugLoggingEnabled(false) - Errorf("This is an error message: %v", 42) - checkOutput("This is an error message: 42", "") - Verbosef("This is a verbose message: %v", 17) - checkOutput("", "This is a verbose message: 17") -} - -func logfTo(w io.Writer) logger.Logf { - return func(format string, args ...any) { - fmt.Fprintf(w, format, args...) - } -} diff --git a/util/syspolicy/internal/metrics/metrics.go b/util/syspolicy/internal/metrics/metrics.go index 0a2aa1192fc53..3c7e35aa02003 100644 --- a/util/syspolicy/internal/metrics/metrics.go +++ b/util/syspolicy/internal/metrics/metrics.go @@ -8,17 +8,16 @@ import ( "strings" "sync" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/slicesx" + "github.com/sagernet/tailscale/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/syspolicy/internal/loggerx" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/testenv" xmaps "golang.org/x/exp/maps" - - "tailscale.com/syncs" - "tailscale.com/types/lazy" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/slicesx" - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/internal/loggerx" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/testenv" ) var lazyReportMetrics lazy.SyncValue[bool] // used as a test hook diff --git a/util/syspolicy/internal/metrics/metrics_test.go b/util/syspolicy/internal/metrics/metrics_test.go deleted file mode 100644 index 07be4773c9fcb..0000000000000 --- a/util/syspolicy/internal/metrics/metrics_test.go +++ /dev/null @@ -1,423 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package metrics - -import ( - "errors" - "testing" - - "tailscale.com/types/lazy" - "tailscale.com/util/clientmetric" - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/setting" -) - -func TestSettingMetricNames(t *testing.T) { - tests := []struct { - name string - key setting.Key - scope setting.Scope - suffix string - typ clientmetric.Type - osOverride string - wantMetricName string - }{ - { - name: "windows-device-no-suffix", - key: "AdminConsole", - scope: setting.DeviceSetting, - suffix: "", - typ: clientmetric.TypeCounter, - osOverride: "windows", - wantMetricName: "windows_syspolicy_AdminConsole", - }, - { - name: "windows-user-no-suffix", - key: "AdminConsole", - scope: setting.UserSetting, - suffix: "", - typ: clientmetric.TypeCounter, - osOverride: "windows", - wantMetricName: "windows_syspolicy_AdminConsole_user", - }, - { - name: "windows-profile-no-suffix", - key: "AdminConsole", - scope: setting.ProfileSetting, - suffix: "", - typ: clientmetric.TypeCounter, - osOverride: "windows", - wantMetricName: "windows_syspolicy_AdminConsole_profile", - }, - { - name: "windows-profile-err", - key: "AdminConsole", - scope: setting.ProfileSetting, - suffix: "error", - typ: clientmetric.TypeCounter, - osOverride: "windows", - wantMetricName: "windows_syspolicy_AdminConsole_profile_error", - }, - { - name: "android-device-no-suffix", - key: "AdminConsole", - scope: setting.DeviceSetting, - suffix: "", - typ: clientmetric.TypeCounter, - osOverride: "android", - wantMetricName: "android_syspolicy_AdminConsole", - }, - { - name: "key-path", - key: "category/subcategory/setting", - scope: setting.DeviceSetting, - suffix: "", - typ: clientmetric.TypeCounter, - osOverride: "fakeos", - wantMetricName: "fakeos_syspolicy_category_subcategory_setting", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - internal.OSForTesting.SetForTest(t, tt.osOverride, nil) - metric, ok := newSettingMetric(tt.key, tt.scope, tt.suffix, tt.typ).(*funcMetric) - if !ok { - t.Fatal("metric is not a funcMetric") - } - if metric.name != tt.wantMetricName { - t.Errorf("got %q, want %q", metric.name, tt.wantMetricName) - } - }) - } -} - -func TestScopeMetrics(t *testing.T) { - tests := []struct { - name string - scope setting.Scope - osOverride string - wantHasAnyName string - wantNumErroredName string - wantHasAnyType clientmetric.Type - wantNumErroredType clientmetric.Type - }{ - { - name: "windows-device", - scope: setting.DeviceSetting, - osOverride: "windows", - wantHasAnyName: "windows_syspolicy_any", - wantHasAnyType: clientmetric.TypeGauge, - wantNumErroredName: "windows_syspolicy_errors", - wantNumErroredType: clientmetric.TypeCounter, - }, - { - name: "windows-profile", - scope: setting.ProfileSetting, - osOverride: "windows", - wantHasAnyName: "windows_syspolicy_profile_any", - wantHasAnyType: clientmetric.TypeGauge, - wantNumErroredName: "windows_syspolicy_profile_errors", - wantNumErroredType: clientmetric.TypeCounter, - }, - { - name: "windows-user", - scope: setting.UserSetting, - osOverride: "windows", - wantHasAnyName: "windows_syspolicy_user_any", - wantHasAnyType: clientmetric.TypeGauge, - wantNumErroredName: "windows_syspolicy_user_errors", - wantNumErroredType: clientmetric.TypeCounter, - }, - { - name: "android-device", - scope: setting.DeviceSetting, - osOverride: "android", - wantHasAnyName: "android_syspolicy_any", - wantHasAnyType: clientmetric.TypeGauge, - wantNumErroredName: "android_syspolicy_errors", - wantNumErroredType: clientmetric.TypeCounter, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - internal.OSForTesting.SetForTest(t, tt.osOverride, nil) - metrics := newScopeMetrics(tt.scope) - hasAny, ok := metrics.hasAny.(*funcMetric) - if !ok { - t.Fatal("hasAny is not a funcMetric") - } - numErrored, ok := metrics.numErrored.(*funcMetric) - if !ok { - t.Fatal("numErrored is not a funcMetric") - } - if hasAny.name != tt.wantHasAnyName { - t.Errorf("hasAny.Name: got %q, want %q", hasAny.name, tt.wantHasAnyName) - } - if hasAny.typ != tt.wantHasAnyType { - t.Errorf("hasAny.Type: got %q, want %q", hasAny.typ, tt.wantHasAnyType) - } - if numErrored.name != tt.wantNumErroredName { - t.Errorf("numErrored.Name: got %q, want %q", numErrored.name, tt.wantNumErroredName) - } - if numErrored.typ != tt.wantNumErroredType { - t.Errorf("hasAny.Type: got %q, want %q", numErrored.typ, tt.wantNumErroredType) - } - }) - } -} - -type testSettingDetails struct { - definition *setting.Definition - origin *setting.Origin - value any - err error -} - -func TestReportMetrics(t *testing.T) { - tests := []struct { - name string - osOverride string - useMetrics bool - settings []testSettingDetails - wantMetrics []TestState - wantResetMetrics []TestState - }{ - { - name: "none", - osOverride: "windows", - settings: []testSettingDetails{}, - wantMetrics: []TestState{}, - }, - { - name: "single-value", - osOverride: "windows", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 42, - }, - }, - wantMetrics: []TestState{ - {"windows_syspolicy_any", 1}, - {"windows_syspolicy_TestSetting01", 1}, - }, - wantResetMetrics: []TestState{ - {"windows_syspolicy_any", 0}, - {"windows_syspolicy_TestSetting01", 0}, - }, - }, - { - name: "single-error", - osOverride: "windows", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting02", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - err: errors.New("bang!"), - }, - }, - wantMetrics: []TestState{ - {"windows_syspolicy_errors", 1}, - {"windows_syspolicy_TestSetting02_error", 1}, - }, - wantResetMetrics: []TestState{ - {"windows_syspolicy_errors", 1}, - {"windows_syspolicy_TestSetting02_error", 0}, - }, - }, - { - name: "value-and-error", - osOverride: "windows", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 42, - }, - { - definition: setting.NewDefinition("TestSetting02", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - err: errors.New("bang!"), - }, - }, - - wantMetrics: []TestState{ - {"windows_syspolicy_any", 1}, - {"windows_syspolicy_errors", 1}, - {"windows_syspolicy_TestSetting01", 1}, - {"windows_syspolicy_TestSetting02_error", 1}, - }, - wantResetMetrics: []TestState{ - {"windows_syspolicy_any", 0}, - {"windows_syspolicy_errors", 1}, - {"windows_syspolicy_TestSetting01", 0}, - {"windows_syspolicy_TestSetting02_error", 0}, - }, - }, - { - name: "two-values", - osOverride: "windows", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 42, - }, - { - definition: setting.NewDefinition("TestSetting02", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 17, - }, - }, - wantMetrics: []TestState{ - {"windows_syspolicy_any", 1}, - {"windows_syspolicy_TestSetting01", 1}, - {"windows_syspolicy_TestSetting02", 1}, - }, - wantResetMetrics: []TestState{ - {"windows_syspolicy_any", 0}, - {"windows_syspolicy_TestSetting01", 0}, - {"windows_syspolicy_TestSetting02", 0}, - }, - }, - { - name: "two-errors", - osOverride: "windows", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - err: errors.New("bang!"), - }, - { - definition: setting.NewDefinition("TestSetting02", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - err: errors.New("bang!"), - }, - }, - wantMetrics: []TestState{ - {"windows_syspolicy_errors", 2}, - {"windows_syspolicy_TestSetting01_error", 1}, - {"windows_syspolicy_TestSetting02_error", 1}, - }, - wantResetMetrics: []TestState{ - {"windows_syspolicy_errors", 2}, - {"windows_syspolicy_TestSetting01_error", 0}, - {"windows_syspolicy_TestSetting02_error", 0}, - }, - }, - { - name: "multi-scope", - osOverride: "windows", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.ProfileSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 42, - }, - { - definition: setting.NewDefinition("TestSetting02", setting.ProfileSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.CurrentProfileScope), - err: errors.New("bang!"), - }, - { - definition: setting.NewDefinition("TestSetting03", setting.UserSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.CurrentUserScope), - value: 17, - }, - }, - wantMetrics: []TestState{ - {"windows_syspolicy_any", 1}, - {"windows_syspolicy_profile_errors", 1}, - {"windows_syspolicy_user_any", 1}, - {"windows_syspolicy_TestSetting01", 1}, - {"windows_syspolicy_TestSetting02_profile_error", 1}, - {"windows_syspolicy_TestSetting03_user", 1}, - }, - wantResetMetrics: []TestState{ - {"windows_syspolicy_any", 0}, - {"windows_syspolicy_profile_errors", 1}, - {"windows_syspolicy_user_any", 0}, - {"windows_syspolicy_TestSetting01", 0}, - {"windows_syspolicy_TestSetting02_profile_error", 0}, - {"windows_syspolicy_TestSetting03_user", 0}, - }, - }, - { - name: "report-metrics-on-android", - osOverride: "android", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 42, - }, - }, - wantMetrics: []TestState{ - {"android_syspolicy_any", 1}, - {"android_syspolicy_TestSetting01", 1}, - }, - wantResetMetrics: []TestState{ - {"android_syspolicy_any", 0}, - {"android_syspolicy_TestSetting01", 0}, - }, - }, - { - name: "do-not-report-metrics-on-macos", - osOverride: "macos", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 42, - }, - }, - - wantMetrics: []TestState{}, // none reported - }, - { - name: "do-not-report-metrics-on-ios", - osOverride: "ios", - settings: []testSettingDetails{ - { - definition: setting.NewDefinition("TestSetting01", setting.DeviceSetting, setting.IntegerValue), - origin: setting.NewOrigin(setting.DeviceScope), - value: 42, - }, - }, - - wantMetrics: []TestState{}, // none reported - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Reset the lazy value so it'll be re-evaluated with the osOverride. - lazyReportMetrics = lazy.SyncValue[bool]{} - t.Cleanup(func() { - // Also reset it during the cleanup. - lazyReportMetrics = lazy.SyncValue[bool]{} - }) - internal.OSForTesting.SetForTest(t, tt.osOverride, nil) - - h := NewTestHandler(t) - SetHooksForTest(t, h.AddMetric, h.SetMetric) - - for _, s := range tt.settings { - if s.err != nil { - ReportError(s.origin, s.definition, s.err) - } else { - ReportConfigured(s.origin, s.definition, s.value) - } - } - h.MustEqual(tt.wantMetrics...) - - for _, s := range tt.settings { - Reset(s.origin) - ReportNotConfigured(s.origin, s.definition) - } - h.MustEqual(tt.wantResetMetrics...) - }) - } -} diff --git a/util/syspolicy/internal/metrics/test_handler.go b/util/syspolicy/internal/metrics/test_handler.go index f9e4846092be3..72823588b4a67 100644 --- a/util/syspolicy/internal/metrics/test_handler.go +++ b/util/syspolicy/internal/metrics/test_handler.go @@ -6,9 +6,9 @@ package metrics import ( "strings" - "tailscale.com/util/clientmetric" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/syspolicy/internal" ) // TestState represents a metric name and its expected value. diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go index bb9a5d6cc5934..3f38c2740a104 100644 --- a/util/syspolicy/policy_keys.go +++ b/util/syspolicy/policy_keys.go @@ -4,10 +4,10 @@ package syspolicy import ( - "tailscale.com/types/lazy" - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/testenv" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/testenv" ) // Key is a string that uniquely identifies a policy and must remain unchanged diff --git a/util/syspolicy/policy_keys_test.go b/util/syspolicy/policy_keys_test.go deleted file mode 100644 index 4d3260f3e0e60..0000000000000 --- a/util/syspolicy/policy_keys_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package syspolicy - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "go/types" - "os" - "reflect" - "strconv" - "testing" - - "tailscale.com/util/syspolicy/setting" -) - -func TestKnownKeysRegistered(t *testing.T) { - keyConsts, err := listStringConsts[Key]("policy_keys.go") - if err != nil { - t.Fatalf("listStringConsts failed: %v", err) - } - - m, err := setting.DefinitionMapOf(implicitDefinitions) - if err != nil { - t.Fatalf("definitionMapOf failed: %v", err) - } - - for _, key := range keyConsts { - t.Run(string(key), func(t *testing.T) { - d := m[key] - if d == nil { - t.Fatalf("%q was not registered", key) - } - if d.Key() != key { - t.Fatalf("d.Key got: %s, want %s", d.Key(), key) - } - }) - } -} - -func TestNotAWellKnownSetting(t *testing.T) { - d, err := WellKnownSettingDefinition("TestSettingDoesNotExist") - if d != nil || err == nil { - t.Fatalf("got %v, %v; want nil, %v", d, err, ErrNoSuchKey) - } -} - -func listStringConsts[T ~string](filename string) (map[string]T, error) { - fset := token.NewFileSet() - src, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - f, err := parser.ParseFile(fset, filename, src, 0) - if err != nil { - return nil, err - } - - consts := make(map[string]T) - typeName := reflect.TypeFor[T]().Name() - for _, d := range f.Decls { - g, ok := d.(*ast.GenDecl) - if !ok || g.Tok != token.CONST { - continue - } - - for _, s := range g.Specs { - vs, ok := s.(*ast.ValueSpec) - if !ok || len(vs.Names) != len(vs.Values) { - continue - } - if typ, ok := vs.Type.(*ast.Ident); !ok || typ.Name != typeName { - continue - } - - for i, n := range vs.Names { - lit, ok := vs.Values[i].(*ast.BasicLit) - if !ok { - return nil, fmt.Errorf("unexpected string literal: %v = %v", n.Name, types.ExprString(vs.Values[i])) - } - val, err := strconv.Unquote(lit.Value) - if err != nil { - return nil, fmt.Errorf("unexpected string literal: %v = %v", n.Name, lit.Value) - } - consts[n.Name] = T(val) - } - } - } - - return consts, nil -} diff --git a/util/syspolicy/rsop/change_callbacks.go b/util/syspolicy/rsop/change_callbacks.go index b962f30c008c1..2eadc9e2f08c7 100644 --- a/util/syspolicy/rsop/change_callbacks.go +++ b/util/syspolicy/rsop/change_callbacks.go @@ -9,9 +9,9 @@ import ( "sync" "time" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy/internal/loggerx" - "tailscale.com/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/syspolicy/internal/loggerx" + "github.com/sagernet/tailscale/util/syspolicy/setting" ) // Change represents a change from the Old to the New value of type T. diff --git a/util/syspolicy/rsop/resultant_policy.go b/util/syspolicy/rsop/resultant_policy.go index 019b8f602f86d..8625727635ff8 100644 --- a/util/syspolicy/rsop/resultant_policy.go +++ b/util/syspolicy/rsop/resultant_policy.go @@ -11,10 +11,9 @@ import ( "sync/atomic" "time" - "tailscale.com/util/syspolicy/internal/loggerx" - "tailscale.com/util/syspolicy/setting" - - "tailscale.com/util/syspolicy/source" + "github.com/sagernet/tailscale/util/syspolicy/internal/loggerx" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/syspolicy/source" ) // ErrPolicyClosed is returned by [Policy.Reload], [Policy.addSource], diff --git a/util/syspolicy/rsop/resultant_policy_test.go b/util/syspolicy/rsop/resultant_policy_test.go deleted file mode 100644 index b2408c7f71519..0000000000000 --- a/util/syspolicy/rsop/resultant_policy_test.go +++ /dev/null @@ -1,986 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package rsop - -import ( - "errors" - "slices" - "sort" - "strconv" - "sync" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "tailscale.com/tstest" - "tailscale.com/util/syspolicy/setting" - - "tailscale.com/util/syspolicy/source" -) - -func TestGetEffectivePolicyNoSource(t *testing.T) { - tests := []struct { - name string - scope setting.PolicyScope - }{ - { - name: "DevicePolicy", - scope: setting.DeviceScope, - }, - { - name: "CurrentProfilePolicy", - scope: setting.CurrentProfileScope, - }, - { - name: "CurrentUserPolicy", - scope: setting.CurrentUserScope, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var policy *Policy - t.Cleanup(func() { - if policy != nil { - policy.Close() - <-policy.Done() - } - }) - - // Make sure we don't create any goroutines. - // We intentionally call ResourceCheck after t.Cleanup, so that when the test exits, - // the resource check runs before the test cleanup closes the policy. - // This helps to report any unexpectedly created goroutines. - // The goal is to ensure that using the syspolicy package, and particularly - // the rsop sub-package, is not wasteful and does not create unnecessary goroutines - // on platforms without registered policy sources. - tstest.ResourceCheck(t) - - policy, err := PolicyFor(tt.scope) - if err != nil { - t.Fatalf("Failed to get effective policy for %v: %v", tt.scope, err) - } - - if got := policy.Get(); got.Len() != 0 { - t.Errorf("Snapshot: got %v; want empty", got) - } - - if got, err := policy.Reload(); err != nil { - t.Errorf("Reload failed: %v", err) - } else if got.Len() != 0 { - t.Errorf("Snapshot: got %v; want empty", got) - } - }) - } -} - -func TestRegisterSourceAndGetEffectivePolicy(t *testing.T) { - type sourceConfig struct { - name string - scope setting.PolicyScope - settingKey setting.Key - settingValue string - wantEffective bool - } - tests := []struct { - name string - scope setting.PolicyScope - initialSources []sourceConfig - additionalSources []sourceConfig - wantSnapshot *setting.Snapshot - }{ - { - name: "DevicePolicy/NoSources", - scope: setting.DeviceScope, - wantSnapshot: setting.NewSnapshot(nil, setting.DeviceScope), - }, - { - name: "UserScope/NoSources", - scope: setting.CurrentUserScope, - wantSnapshot: setting.NewSnapshot(nil, setting.CurrentUserScope), - }, - { - name: "DevicePolicy/OneInitialSource", - scope: setting.DeviceScope, - initialSources: []sourceConfig{ - { - name: "TestSourceA", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueA", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("TestValueA", nil, setting.NewNamedOrigin("TestSourceA", setting.DeviceScope)), - }, setting.NewNamedOrigin("TestSourceA", setting.DeviceScope)), - }, - { - name: "DevicePolicy/OneAdditionalSource", - scope: setting.DeviceScope, - additionalSources: []sourceConfig{ - { - name: "TestSourceA", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueA", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("TestValueA", nil, setting.NewNamedOrigin("TestSourceA", setting.DeviceScope)), - }, setting.NewNamedOrigin("TestSourceA", setting.DeviceScope)), - }, - { - name: "DevicePolicy/ManyInitialSources/NoConflicts", - scope: setting.DeviceScope, - initialSources: []sourceConfig{ - { - name: "TestSourceA", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueA", - wantEffective: true, - }, - { - name: "TestSourceB", - scope: setting.DeviceScope, - settingKey: "TestKeyB", - settingValue: "TestValueB", - wantEffective: true, - }, - { - name: "TestSourceC", - scope: setting.DeviceScope, - settingKey: "TestKeyC", - settingValue: "TestValueC", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("TestValueA", nil, setting.NewNamedOrigin("TestSourceA", setting.DeviceScope)), - "TestKeyB": setting.RawItemWith("TestValueB", nil, setting.NewNamedOrigin("TestSourceB", setting.DeviceScope)), - "TestKeyC": setting.RawItemWith("TestValueC", nil, setting.NewNamedOrigin("TestSourceC", setting.DeviceScope)), - }, setting.DeviceScope), - }, - { - name: "DevicePolicy/ManyInitialSources/Conflicts", - scope: setting.DeviceScope, - initialSources: []sourceConfig{ - { - name: "TestSourceA", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueA", - wantEffective: true, - }, - { - name: "TestSourceB", - scope: setting.DeviceScope, - settingKey: "TestKeyB", - settingValue: "TestValueB", - wantEffective: true, - }, - { - name: "TestSourceC", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueC", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("TestValueC", nil, setting.NewNamedOrigin("TestSourceC", setting.DeviceScope)), - "TestKeyB": setting.RawItemWith("TestValueB", nil, setting.NewNamedOrigin("TestSourceB", setting.DeviceScope)), - }, setting.DeviceScope), - }, - { - name: "DevicePolicy/MixedSources/Conflicts", - scope: setting.DeviceScope, - initialSources: []sourceConfig{ - { - name: "TestSourceA", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueA", - wantEffective: true, - }, - { - name: "TestSourceB", - scope: setting.DeviceScope, - settingKey: "TestKeyB", - settingValue: "TestValueB", - wantEffective: true, - }, - { - name: "TestSourceC", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueC", - wantEffective: true, - }, - }, - additionalSources: []sourceConfig{ - { - name: "TestSourceD", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueD", - wantEffective: true, - }, - { - name: "TestSourceE", - scope: setting.DeviceScope, - settingKey: "TestKeyC", - settingValue: "TestValueE", - wantEffective: true, - }, - { - name: "TestSourceF", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "TestValueF", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("TestValueF", nil, setting.NewNamedOrigin("TestSourceF", setting.DeviceScope)), - "TestKeyB": setting.RawItemWith("TestValueB", nil, setting.NewNamedOrigin("TestSourceB", setting.DeviceScope)), - "TestKeyC": setting.RawItemWith("TestValueE", nil, setting.NewNamedOrigin("TestSourceE", setting.DeviceScope)), - }, setting.DeviceScope), - }, - { - name: "UserScope/Init-DeviceSource", - scope: setting.CurrentUserScope, - initialSources: []sourceConfig{ - { - name: "TestSourceDevice", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "DeviceValue", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("DeviceValue", nil, setting.NewNamedOrigin("TestSourceDevice", setting.DeviceScope)), - }, setting.CurrentUserScope, setting.NewNamedOrigin("TestSourceDevice", setting.DeviceScope)), - }, - { - name: "UserScope/Init-DeviceSource/Add-UserSource", - scope: setting.CurrentUserScope, - initialSources: []sourceConfig{ - { - name: "TestSourceDevice", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "DeviceValue", - wantEffective: true, - }, - }, - additionalSources: []sourceConfig{ - { - name: "TestSourceUser", - scope: setting.CurrentUserScope, - settingKey: "TestKeyB", - settingValue: "UserValue", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("DeviceValue", nil, setting.NewNamedOrigin("TestSourceDevice", setting.DeviceScope)), - "TestKeyB": setting.RawItemWith("UserValue", nil, setting.NewNamedOrigin("TestSourceUser", setting.CurrentUserScope)), - }, setting.CurrentUserScope), - }, - { - name: "UserScope/Init-DeviceSource/Add-UserSource-and-ProfileSource", - scope: setting.CurrentUserScope, - initialSources: []sourceConfig{ - { - name: "TestSourceDevice", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "DeviceValue", - wantEffective: true, - }, - }, - additionalSources: []sourceConfig{ - { - name: "TestSourceProfile", - scope: setting.CurrentProfileScope, - settingKey: "TestKeyB", - settingValue: "ProfileValue", - wantEffective: true, - }, - { - name: "TestSourceUser", - scope: setting.CurrentUserScope, - settingKey: "TestKeyB", - settingValue: "UserValue", - wantEffective: true, - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("DeviceValue", nil, setting.NewNamedOrigin("TestSourceDevice", setting.DeviceScope)), - "TestKeyB": setting.RawItemWith("ProfileValue", nil, setting.NewNamedOrigin("TestSourceProfile", setting.CurrentProfileScope)), - }, setting.CurrentUserScope), - }, - { - name: "DevicePolicy/User-Source-does-not-apply", - scope: setting.DeviceScope, - initialSources: []sourceConfig{ - { - name: "TestSourceDevice", - scope: setting.DeviceScope, - settingKey: "TestKeyA", - settingValue: "DeviceValue", - wantEffective: true, - }, - }, - additionalSources: []sourceConfig{ - { - name: "TestSourceUser", - scope: setting.CurrentUserScope, - settingKey: "TestKeyA", - settingValue: "UserValue", - wantEffective: false, // Registering a user source should have no impact on the device policy. - }, - }, - wantSnapshot: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "TestKeyA": setting.RawItemWith("DeviceValue", nil, setting.NewNamedOrigin("TestSourceDevice", setting.DeviceScope)), - }, setting.NewNamedOrigin("TestSourceDevice", setting.DeviceScope)), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Register all settings that we use in this test. - var definitions []*setting.Definition - for _, source := range slices.Concat(tt.initialSources, tt.additionalSources) { - definitions = append(definitions, setting.NewDefinition(source.settingKey, tt.scope.Kind(), setting.StringValue)) - } - if err := setting.SetDefinitionsForTest(t, definitions...); err != nil { - t.Fatalf("SetDefinitionsForTest failed: %v", err) - } - - // Add the initial policy sources. - var wantSources []*source.Source - for _, s := range tt.initialSources { - store := source.NewTestStoreOf(t, source.TestSettingOf(s.settingKey, s.settingValue)) - source := source.NewSource(s.name, s.scope, store) - if err := registerSource(source); err != nil { - t.Fatalf("Failed to register policy source: %v", source) - } - if s.wantEffective { - wantSources = append(wantSources, source) - } - t.Cleanup(func() { unregisterSource(source) }) - } - - // Retrieve the effective policy. - policy, err := policyForTest(t, tt.scope) - if err != nil { - t.Fatalf("Failed to get effective policy for %v: %v", tt.scope, err) - } - - checkPolicySources(t, policy, wantSources) - - // Add additional setting sources. - for _, s := range tt.additionalSources { - store := source.NewTestStoreOf(t, source.TestSettingOf(s.settingKey, s.settingValue)) - source := source.NewSource(s.name, s.scope, store) - if err := registerSource(source); err != nil { - t.Fatalf("Failed to register additional policy source: %v", source) - } - if s.wantEffective { - wantSources = append(wantSources, source) - } - t.Cleanup(func() { unregisterSource(source) }) - } - - checkPolicySources(t, policy, wantSources) - - // Verify the final effective settings snapshots. - if got := policy.Get(); !got.Equal(tt.wantSnapshot) { - t.Errorf("Snapshot: got %v; want %v", got, tt.wantSnapshot) - } - }) - } -} - -func TestPolicyFor(t *testing.T) { - tests := []struct { - name string - scopeA, scopeB setting.PolicyScope - closePolicy bool // indicates whether to close policyA before retrieving policyB - wantSame bool // specifies whether policyA and policyB should reference the same [Policy] instance - }{ - { - name: "Device/Device", - scopeA: setting.DeviceScope, - scopeB: setting.DeviceScope, - wantSame: true, - }, - { - name: "Device/CurrentProfile", - scopeA: setting.DeviceScope, - scopeB: setting.CurrentProfileScope, - wantSame: false, - }, - { - name: "Device/CurrentUser", - scopeA: setting.DeviceScope, - scopeB: setting.CurrentUserScope, - wantSame: false, - }, - { - name: "CurrentProfile/CurrentProfile", - scopeA: setting.CurrentProfileScope, - scopeB: setting.CurrentProfileScope, - wantSame: true, - }, - { - name: "CurrentProfile/CurrentUser", - scopeA: setting.CurrentProfileScope, - scopeB: setting.CurrentUserScope, - wantSame: false, - }, - { - name: "CurrentUser/CurrentUser", - scopeA: setting.CurrentUserScope, - scopeB: setting.CurrentUserScope, - wantSame: true, - }, - { - name: "UserA/UserA", - scopeA: setting.UserScopeOf("UserA"), - scopeB: setting.UserScopeOf("UserA"), - wantSame: true, - }, - { - name: "UserA/UserB", - scopeA: setting.UserScopeOf("UserA"), - scopeB: setting.UserScopeOf("UserB"), - wantSame: false, - }, - { - name: "New-after-close", - scopeA: setting.DeviceScope, - scopeB: setting.DeviceScope, - closePolicy: true, - wantSame: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - policyA, err := policyForTest(t, tt.scopeA) - if err != nil { - t.Fatalf("Failed to get effective policy for %v: %v", tt.scopeA, err) - } - - if tt.closePolicy { - policyA.Close() - } - - policyB, err := policyForTest(t, tt.scopeB) - if err != nil { - t.Fatalf("Failed to get effective policy for %v: %v", tt.scopeB, err) - } - - if gotSame := policyA == policyB; gotSame != tt.wantSame { - t.Fatalf("Got same: %v; want same %v", gotSame, tt.wantSame) - } - }) - } -} - -func TestPolicyChangeHasChanged(t *testing.T) { - tests := []struct { - name string - old, new map[setting.Key]setting.RawItem - wantChanged []setting.Key - wantUnchanged []setting.Key - }{ - { - name: "String-Settings", - old: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf("Old"), - "UnchangedSetting": setting.RawItemOf("Value"), - }, - new: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf("New"), - "UnchangedSetting": setting.RawItemOf("Value"), - }, - wantChanged: []setting.Key{"ChangedSetting"}, - wantUnchanged: []setting.Key{"UnchangedSetting"}, - }, - { - name: "UInt64-Settings", - old: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf(uint64(0)), - "UnchangedSetting": setting.RawItemOf(uint64(42)), - }, - new: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf(uint64(1)), - "UnchangedSetting": setting.RawItemOf(uint64(42)), - }, - wantChanged: []setting.Key{"ChangedSetting"}, - wantUnchanged: []setting.Key{"UnchangedSetting"}, - }, - { - name: "StringSlice-Settings", - old: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf([]string{"Chicago"}), - "UnchangedSetting": setting.RawItemOf([]string{"String1", "String2"}), - }, - new: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf([]string{"New York"}), - "UnchangedSetting": setting.RawItemOf([]string{"String1", "String2"}), - }, - wantChanged: []setting.Key{"ChangedSetting"}, - wantUnchanged: []setting.Key{"UnchangedSetting"}, - }, - { - name: "Int8-Settings", // We don't have actual int8 settings, but this should still work. - old: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf(int8(0)), - "UnchangedSetting": setting.RawItemOf(int8(42)), - }, - new: map[setting.Key]setting.RawItem{ - "ChangedSetting": setting.RawItemOf(int8(1)), - "UnchangedSetting": setting.RawItemOf(int8(42)), - }, - wantChanged: []setting.Key{"ChangedSetting"}, - wantUnchanged: []setting.Key{"UnchangedSetting"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - old := setting.NewSnapshot(tt.old) - new := setting.NewSnapshot(tt.new) - change := PolicyChange{Change[*setting.Snapshot]{old, new}} - for _, wantChanged := range tt.wantChanged { - if !change.HasChanged(wantChanged) { - t.Errorf("%q changed: got false; want true", wantChanged) - } - } - for _, wantUnchanged := range tt.wantUnchanged { - if change.HasChanged(wantUnchanged) { - t.Errorf("%q unchanged: got true; want false", wantUnchanged) - } - } - }) - } -} - -func TestChangePolicySetting(t *testing.T) { - setForTest(t, &policyReloadMinDelay, 100*time.Millisecond) - setForTest(t, &policyReloadMaxDelay, 500*time.Millisecond) - - // Register policy settings used in this test. - settingA := setting.NewDefinition("TestSettingA", setting.DeviceSetting, setting.StringValue) - settingB := setting.NewDefinition("TestSettingB", setting.DeviceSetting, setting.StringValue) - if err := setting.SetDefinitionsForTest(t, settingA, settingB); err != nil { - t.Fatalf("SetDefinitionsForTest failed: %v", err) - } - - // Register a test policy store and create a effective policy that reads the policy settings from it. - store := source.NewTestStoreOf[string](t) - if _, err := RegisterStoreForTest(t, "TestSource", setting.DeviceScope, store); err != nil { - t.Fatalf("Failed to register policy store: %v", err) - } - policy, err := policyForTest(t, setting.DeviceScope) - if err != nil { - t.Fatalf("Failed to get effective policy: %v", err) - } - - // The policy setting is not configured yet. - if _, ok := policy.Get().GetSetting(settingA.Key()); ok { - t.Fatalf("Policy setting %q unexpectedly exists", settingA.Key()) - } - - // Subscribe to the policy change callback... - policyChanged := make(chan *PolicyChange) - unregister := policy.RegisterChangeCallback(func(pc *PolicyChange) { policyChanged <- pc }) - t.Cleanup(unregister) - - // ...make the change, and measure the time between initiating the change - // and receiving the callback. - start := time.Now() - const wantValueA = "TestValueA" - store.SetStrings(source.TestSettingOf(settingA.Key(), wantValueA)) - change := <-policyChanged - gotDelay := time.Since(start) - - // Ensure there is at least a [policyReloadMinDelay] delay between - // a change and the policy reload along with the callback invocation. - // This prevents reloading policy settings too frequently - // when multiple settings change within a short period of time. - if gotDelay < policyReloadMinDelay { - t.Errorf("Delay: got %v; want >= %v", gotDelay, policyReloadMinDelay) - } - - // Verify that the [PolicyChange] passed to the policy change callback - // contains the correct information regarding the policy setting changes. - if !change.HasChanged(settingA.Key()) { - t.Errorf("Policy setting %q has not changed", settingA.Key()) - } - if change.HasChanged(settingB.Key()) { - t.Errorf("Policy setting %q was unexpectedly changed", settingB.Key()) - } - if _, ok := change.Old().GetSetting(settingA.Key()); ok { - t.Fatalf("Policy setting %q unexpectedly exists", settingA.Key()) - } - if gotValue := change.New().Get(settingA.Key()); gotValue != wantValueA { - t.Errorf("Policy setting %q: got %q; want %q", settingA.Key(), gotValue, wantValueA) - } - - // And also verify that the current (most recent) [setting.Snapshot] - // includes the change we just made. - if gotValue := policy.Get().Get(settingA.Key()); gotValue != wantValueA { - t.Errorf("Policy setting %q: got %q; want %q", settingA.Key(), gotValue, wantValueA) - } - - // Now, let's change another policy setting value N times. - const N = 10 - wantValueB := strconv.Itoa(N) - start = time.Now() - for i := range N { - store.SetStrings(source.TestSettingOf(settingB.Key(), strconv.Itoa(i+1))) - } - - // The callback should be invoked only once, even though the policy setting - // has changed N times. - change = <-policyChanged - gotDelay = time.Since(start) - gotCallbacks := 1 -drain: - for { - select { - case <-policyChanged: - gotCallbacks++ - case <-time.After(policyReloadMaxDelay): - break drain - } - } - if wantCallbacks := 1; gotCallbacks > wantCallbacks { - t.Errorf("Callbacks: got %d; want %d", gotCallbacks, wantCallbacks) - } - - // Additionally, the policy change callback should be received no sooner - // than [policyReloadMinDelay] and no later than [policyReloadMaxDelay]. - if gotDelay < policyReloadMinDelay || gotDelay > policyReloadMaxDelay { - t.Errorf("Delay: got %v; want >= %v && <= %v", gotDelay, policyReloadMinDelay, policyReloadMaxDelay) - } - - // Verify that the [PolicyChange] received via the callback - // contains the final policy setting value. - if !change.HasChanged(settingB.Key()) { - t.Errorf("Policy setting %q has not changed", settingB.Key()) - } - if change.HasChanged(settingA.Key()) { - t.Errorf("Policy setting %q was unexpectedly changed", settingA.Key()) - } - if _, ok := change.Old().GetSetting(settingB.Key()); ok { - t.Fatalf("Policy setting %q unexpectedly exists", settingB.Key()) - } - if gotValue := change.New().Get(settingB.Key()); gotValue != wantValueB { - t.Errorf("Policy setting %q: got %q; want %q", settingB.Key(), gotValue, wantValueB) - } - - // Lastly, if a policy store issues a change notification, but the effective policy - // remains unchanged, the [Policy] should ignore it without invoking the change callbacks. - store.NotifyPolicyChanged() - select { - case <-policyChanged: - t.Fatal("Unexpected policy changed notification") - case <-time.After(policyReloadMaxDelay): - } -} - -func TestClosePolicySource(t *testing.T) { - testSetting := setting.NewDefinition("TestSetting", setting.DeviceSetting, setting.StringValue) - if err := setting.SetDefinitionsForTest(t, testSetting); err != nil { - t.Fatalf("SetDefinitionsForTest failed: %v", err) - } - - wantSettingValue := "TestValue" - store := source.NewTestStoreOf(t, source.TestSettingOf(testSetting.Key(), wantSettingValue)) - if _, err := RegisterStoreForTest(t, "TestSource", setting.DeviceScope, store); err != nil { - t.Fatalf("Failed to register policy store: %v", err) - } - policy, err := policyForTest(t, setting.DeviceScope) - if err != nil { - t.Fatalf("Failed to get effective policy: %v", err) - } - - initialSnapshot, err := policy.Reload() - if err != nil { - t.Fatalf("Failed to reload policy: %v", err) - } - if gotSettingValue, err := initialSnapshot.GetErr(testSetting.Key()); err != nil { - t.Fatalf("Failed to get %q setting value: %v", testSetting.Key(), err) - } else if gotSettingValue != wantSettingValue { - t.Fatalf("Setting %q: got %q; want %q", testSetting.Key(), gotSettingValue, wantSettingValue) - } - - store.Close() - - // Closing a policy source abruptly without removing it first should invalidate and close the policy. - <-policy.Done() - if policy.IsValid() { - t.Fatal("The policy was not properly closed") - } - - // The resulting policy snapshot should remain valid and unchanged. - finalSnapshot := policy.Get() - if !finalSnapshot.Equal(initialSnapshot) { - t.Fatal("Policy snapshot has changed") - } - if gotSettingValue, err := finalSnapshot.GetErr(testSetting.Key()); err != nil { - t.Fatalf("Failed to get final %q setting value: %v", testSetting.Key(), err) - } else if gotSettingValue != wantSettingValue { - t.Fatalf("Setting %q: got %q; want %q", testSetting.Key(), gotSettingValue, wantSettingValue) - } - - // However, any further requests to reload the policy should fail. - if _, err := policy.Reload(); err == nil || !errors.Is(err, ErrPolicyClosed) { - t.Fatalf("Reload: gotErr: %v; wantErr: %v", err, ErrPolicyClosed) - } -} - -func TestRemovePolicySource(t *testing.T) { - // Register policy settings used in this test. - settingA := setting.NewDefinition("TestSettingA", setting.DeviceSetting, setting.StringValue) - settingB := setting.NewDefinition("TestSettingB", setting.DeviceSetting, setting.StringValue) - if err := setting.SetDefinitionsForTest(t, settingA, settingB); err != nil { - t.Fatalf("SetDefinitionsForTest failed: %v", err) - } - - // Register two policy stores. - storeA := source.NewTestStoreOf(t, source.TestSettingOf(settingA.Key(), "A")) - storeRegA, err := RegisterStoreForTest(t, "TestSourceA", setting.DeviceScope, storeA) - if err != nil { - t.Fatalf("Failed to register policy store A: %v", err) - } - storeB := source.NewTestStoreOf(t, source.TestSettingOf(settingB.Key(), "B")) - storeRegB, err := RegisterStoreForTest(t, "TestSourceB", setting.DeviceScope, storeB) - if err != nil { - t.Fatalf("Failed to register policy store A: %v", err) - } - - // Create a effective [Policy] that reads policy settings from the two stores. - policy, err := policyForTest(t, setting.DeviceScope) - if err != nil { - t.Fatalf("Failed to get effective policy: %v", err) - } - - // Verify that the [Policy] uses both stores and includes policy settings from each. - if gotSources, wantSources := len(policy.sources), 2; gotSources != wantSources { - t.Fatalf("Policy Sources: got %v; want %v", gotSources, wantSources) - } - if got, want := policy.Get().Get(settingA.Key()), "A"; got != want { - t.Fatalf("Setting %q: got %q; want %q", settingA.Key(), got, want) - } - if got, want := policy.Get().Get(settingB.Key()), "B"; got != want { - t.Fatalf("Setting %q: got %q; want %q", settingB.Key(), got, want) - } - - // Unregister Store A and verify that the effective policy remains valid. - // It should no longer use the removed store or include any policy settings from it. - if err := storeRegA.Unregister(); err != nil { - t.Fatalf("Failed to unregister Store A: %v", err) - } - if !policy.IsValid() { - t.Fatalf("Policy was unexpectedly closed") - } - if gotSources, wantSources := len(policy.sources), 1; gotSources != wantSources { - t.Fatalf("Policy Sources: got %v; want %v", gotSources, wantSources) - } - if got, want := policy.Get().Get(settingA.Key()), any(nil); got != want { - t.Fatalf("Setting %q: got %q; want %q", settingA.Key(), got, want) - } - if got, want := policy.Get().Get(settingB.Key()), "B"; got != want { - t.Fatalf("Setting %q: got %q; want %q", settingB.Key(), got, want) - } - - // Unregister Store B and verify that the effective policy is still valid. - // However, it should be empty since there are no associated policy sources. - if err := storeRegB.Unregister(); err != nil { - t.Fatalf("Failed to unregister Store B: %v", err) - } - if !policy.IsValid() { - t.Fatalf("Policy was unexpectedly closed") - } - if gotSources, wantSources := len(policy.sources), 0; gotSources != wantSources { - t.Fatalf("Policy Sources: got %v; want %v", gotSources, wantSources) - } - if got := policy.Get(); got.Len() != 0 { - t.Fatalf("Settings: got %v; want {Empty}", got) - } -} - -func TestReplacePolicySource(t *testing.T) { - setForTest(t, &policyReloadMinDelay, 100*time.Millisecond) - setForTest(t, &policyReloadMaxDelay, 500*time.Millisecond) - - // Register policy settings used in this test. - testSetting := setting.NewDefinition("TestSettingA", setting.DeviceSetting, setting.StringValue) - if err := setting.SetDefinitionsForTest(t, testSetting); err != nil { - t.Fatalf("SetDefinitionsForTest failed: %v", err) - } - - // Create two policy stores. - initialStore := source.NewTestStoreOf(t, source.TestSettingOf(testSetting.Key(), "InitialValue")) - newStore := source.NewTestStoreOf(t, source.TestSettingOf(testSetting.Key(), "NewValue")) - unchangedStore := source.NewTestStoreOf(t, source.TestSettingOf(testSetting.Key(), "NewValue")) - - // Register the initial store and create a effective [Policy] that reads policy settings from it. - reg, err := RegisterStoreForTest(t, "TestStore", setting.DeviceScope, initialStore) - if err != nil { - t.Fatalf("Failed to register the initial store: %v", err) - } - policy, err := policyForTest(t, setting.DeviceScope) - if err != nil { - t.Fatalf("Failed to get effective policy: %v", err) - } - - // Verify that the test setting has its initial value. - if got, want := policy.Get().Get(testSetting.Key()), "InitialValue"; got != want { - t.Fatalf("Setting %q: got %q; want %q", testSetting.Key(), got, want) - } - - // Subscribe to the policy change callback. - policyChanged := make(chan *PolicyChange, 1) - unregister := policy.RegisterChangeCallback(func(pc *PolicyChange) { policyChanged <- pc }) - t.Cleanup(unregister) - - // Now, let's replace the initial store with the new store. - reg, err = reg.ReplaceStore(newStore) - if err != nil { - t.Fatalf("Failed to replace the policy store: %v", err) - } - t.Cleanup(func() { reg.Unregister() }) - - // We should receive a policy change notification as the setting value has changed. - <-policyChanged - - // Verify that the test setting has the new value. - if got, want := policy.Get().Get(testSetting.Key()), "NewValue"; got != want { - t.Fatalf("Setting %q: got %q; want %q", testSetting.Key(), got, want) - } - - // Replacing a policy store with an identical one containing the same - // values for the same settings should not be considered a policy change. - reg, err = reg.ReplaceStore(unchangedStore) - if err != nil { - t.Fatalf("Failed to replace the policy store: %v", err) - } - t.Cleanup(func() { reg.Unregister() }) - - select { - case <-policyChanged: - t.Fatal("Unexpected policy changed notification") - default: - <-time.After(policyReloadMaxDelay) - } -} - -func TestAddClosedPolicySource(t *testing.T) { - store := source.NewTestStoreOf[string](t) - if _, err := RegisterStoreForTest(t, "TestSource", setting.DeviceScope, store); err != nil { - t.Fatalf("Failed to register policy store: %v", err) - } - store.Close() - - _, err := policyForTest(t, setting.DeviceScope) - if err == nil || !errors.Is(err, source.ErrStoreClosed) { - t.Fatalf("got: %v; want: %v", err, source.ErrStoreClosed) - } -} - -func TestClosePolicyMoreThanOnce(t *testing.T) { - tests := []struct { - name string - numSources int - }{ - { - name: "NoSources", - numSources: 0, - }, - { - name: "OneSource", - numSources: 1, - }, - { - name: "ManySources", - numSources: 10, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for i := range tt.numSources { - store := source.NewTestStoreOf[string](t) - if _, err := RegisterStoreForTest(t, "TestSource #"+strconv.Itoa(i), setting.DeviceScope, store); err != nil { - t.Fatalf("Failed to register policy store: %v", err) - } - } - - policy, err := policyForTest(t, setting.DeviceScope) - if err != nil { - t.Fatalf("failed to get effective policy: %v", err) - } - - const N = 10000 - var wg sync.WaitGroup - for range N { - wg.Add(1) - go func() { - wg.Done() - policy.Close() - <-policy.Done() - }() - } - wg.Wait() - }) - } -} - -func checkPolicySources(tb testing.TB, gotPolicy *Policy, wantSources []*source.Source) { - tb.Helper() - sort.SliceStable(wantSources, func(i, j int) bool { - return wantSources[i].Compare(wantSources[j]) < 0 - }) - gotSources := make([]*source.Source, len(gotPolicy.sources)) - for i := range gotPolicy.sources { - gotSources[i] = gotPolicy.sources[i].Source - } - type sourceSummary struct{ Name, Scope string } - toSourceSummary := cmp.Transformer("source", func(s *source.Source) sourceSummary { return sourceSummary{s.Name(), s.Scope().String()} }) - if diff := cmp.Diff(wantSources, gotSources, toSourceSummary, cmpopts.EquateEmpty()); diff != "" { - tb.Errorf("Policy Sources mismatch: %v", diff) - } -} - -// policyForTest is like [PolicyFor], but it deletes the policy -// when tb and all its subtests complete. -func policyForTest(tb testing.TB, target setting.PolicyScope) (*Policy, error) { - tb.Helper() - - policy, err := PolicyFor(target) - if err != nil { - return nil, err - } - tb.Cleanup(func() { - policy.Close() - <-policy.Done() - deletePolicy(policy) - }) - return policy, nil -} - -func setForTest[T any](tb testing.TB, target *T, newValue T) { - oldValue := *target - tb.Cleanup(func() { *target = oldValue }) - *target = newValue -} diff --git a/util/syspolicy/rsop/rsop.go b/util/syspolicy/rsop/rsop.go index 429b9b10121b3..3ba135c5a6c5d 100644 --- a/util/syspolicy/rsop/rsop.go +++ b/util/syspolicy/rsop/rsop.go @@ -12,11 +12,11 @@ import ( "slices" "sync" - "tailscale.com/syncs" - "tailscale.com/util/slicesx" - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/syspolicy/source" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/util/slicesx" + "github.com/sagernet/tailscale/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/syspolicy/source" ) var ( diff --git a/util/syspolicy/rsop/store_registration.go b/util/syspolicy/rsop/store_registration.go index 09c83e98804ca..e99712c666f76 100644 --- a/util/syspolicy/rsop/store_registration.go +++ b/util/syspolicy/rsop/store_registration.go @@ -8,9 +8,9 @@ import ( "sync" "sync/atomic" - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/syspolicy/source" + "github.com/sagernet/tailscale/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/syspolicy/source" ) // ErrAlreadyConsumed is the error returned when [StoreRegistration.ReplaceStore] diff --git a/util/syspolicy/setting/errors.go b/util/syspolicy/setting/errors.go index 38dc6a88c7f1d..6a9fdaaf1b95e 100644 --- a/util/syspolicy/setting/errors.go +++ b/util/syspolicy/setting/errors.go @@ -6,7 +6,7 @@ package setting import ( "errors" - "tailscale.com/types/ptr" + "github.com/sagernet/tailscale/types/ptr" ) var ( diff --git a/util/syspolicy/setting/policy_scope.go b/util/syspolicy/setting/policy_scope.go index c2039fdda15b8..7169ee1c6ded1 100644 --- a/util/syspolicy/setting/policy_scope.go +++ b/util/syspolicy/setting/policy_scope.go @@ -7,8 +7,8 @@ import ( "fmt" "strings" - "tailscale.com/types/lazy" - "tailscale.com/util/syspolicy/internal" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/util/syspolicy/internal" ) var ( diff --git a/util/syspolicy/setting/policy_scope_test.go b/util/syspolicy/setting/policy_scope_test.go deleted file mode 100644 index e1b6cf7ea0a78..0000000000000 --- a/util/syspolicy/setting/policy_scope_test.go +++ /dev/null @@ -1,565 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package setting - -import ( - "reflect" - "testing" - - jsonv2 "github.com/go-json-experiment/json" -) - -func TestPolicyScopeIsApplicableSetting(t *testing.T) { - tests := []struct { - name string - scope PolicyScope - setting *Definition - wantApplicable bool - }{ - { - name: "DeviceScope/DeviceSetting", - scope: DeviceScope, - setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue), - wantApplicable: true, - }, - { - name: "DeviceScope/ProfileSetting", - scope: DeviceScope, - setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue), - wantApplicable: false, - }, - { - name: "DeviceScope/UserSetting", - scope: DeviceScope, - setting: NewDefinition("TestSetting", UserSetting, IntegerValue), - wantApplicable: false, - }, - { - name: "ProfileScope/DeviceSetting", - scope: CurrentProfileScope, - setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue), - wantApplicable: true, - }, - { - name: "ProfileScope/ProfileSetting", - scope: CurrentProfileScope, - setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue), - wantApplicable: true, - }, - { - name: "ProfileScope/UserSetting", - scope: CurrentProfileScope, - setting: NewDefinition("TestSetting", UserSetting, IntegerValue), - wantApplicable: false, - }, - { - name: "UserScope/DeviceSetting", - scope: CurrentUserScope, - setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue), - wantApplicable: true, - }, - { - name: "UserScope/ProfileSetting", - scope: CurrentUserScope, - setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue), - wantApplicable: true, - }, - { - name: "UserScope/UserSetting", - scope: CurrentUserScope, - setting: NewDefinition("TestSetting", UserSetting, IntegerValue), - wantApplicable: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotApplicable := tt.scope.IsApplicableSetting(tt.setting) - if gotApplicable != tt.wantApplicable { - t.Fatalf("got %v, want %v", gotApplicable, tt.wantApplicable) - } - }) - } -} - -func TestPolicyScopeIsConfigurableSetting(t *testing.T) { - tests := []struct { - name string - scope PolicyScope - setting *Definition - wantConfigurable bool - }{ - { - name: "DeviceScope/DeviceSetting", - scope: DeviceScope, - setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue), - wantConfigurable: true, - }, - { - name: "DeviceScope/ProfileSetting", - scope: DeviceScope, - setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue), - wantConfigurable: true, - }, - { - name: "DeviceScope/UserSetting", - scope: DeviceScope, - setting: NewDefinition("TestSetting", UserSetting, IntegerValue), - wantConfigurable: true, - }, - { - name: "ProfileScope/DeviceSetting", - scope: CurrentProfileScope, - setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue), - wantConfigurable: false, - }, - { - name: "ProfileScope/ProfileSetting", - scope: CurrentProfileScope, - setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue), - wantConfigurable: true, - }, - { - name: "ProfileScope/UserSetting", - scope: CurrentProfileScope, - setting: NewDefinition("TestSetting", UserSetting, IntegerValue), - wantConfigurable: true, - }, - { - name: "UserScope/DeviceSetting", - scope: CurrentUserScope, - setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue), - wantConfigurable: false, - }, - { - name: "UserScope/ProfileSetting", - scope: CurrentUserScope, - setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue), - wantConfigurable: false, - }, - { - name: "UserScope/UserSetting", - scope: CurrentUserScope, - setting: NewDefinition("TestSetting", UserSetting, IntegerValue), - wantConfigurable: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotConfigurable := tt.scope.IsConfigurableSetting(tt.setting) - if gotConfigurable != tt.wantConfigurable { - t.Fatalf("got %v, want %v", gotConfigurable, tt.wantConfigurable) - } - }) - } -} - -func TestPolicyScopeContains(t *testing.T) { - tests := []struct { - name string - scopeA PolicyScope - scopeB PolicyScope - wantAContainsB bool - wantAStrictlyContainsB bool - }{ - { - name: "DeviceScope/DeviceScope", - scopeA: DeviceScope, - scopeB: DeviceScope, - wantAContainsB: true, - wantAStrictlyContainsB: false, - }, - { - name: "DeviceScope/CurrentProfileScope", - scopeA: DeviceScope, - scopeB: CurrentProfileScope, - wantAContainsB: true, - wantAStrictlyContainsB: true, - }, - { - name: "DeviceScope/UserScope", - scopeA: DeviceScope, - scopeB: CurrentUserScope, - wantAContainsB: true, - wantAStrictlyContainsB: true, - }, - { - name: "ProfileScope/DeviceScope", - scopeA: CurrentProfileScope, - scopeB: DeviceScope, - wantAContainsB: false, - wantAStrictlyContainsB: false, - }, - { - name: "ProfileScope/ProfileScope", - scopeA: CurrentProfileScope, - scopeB: CurrentProfileScope, - wantAContainsB: true, - wantAStrictlyContainsB: false, - }, - { - name: "ProfileScope/UserScope", - scopeA: CurrentProfileScope, - scopeB: CurrentUserScope, - wantAContainsB: true, - wantAStrictlyContainsB: true, - }, - { - name: "UserScope/DeviceScope", - scopeA: CurrentUserScope, - scopeB: DeviceScope, - wantAContainsB: false, - wantAStrictlyContainsB: false, - }, - { - name: "UserScope/ProfileScope", - scopeA: CurrentUserScope, - scopeB: CurrentProfileScope, - wantAContainsB: false, - wantAStrictlyContainsB: false, - }, - { - name: "UserScope/UserScope", - scopeA: CurrentUserScope, - scopeB: CurrentUserScope, - wantAContainsB: true, - wantAStrictlyContainsB: false, - }, - { - name: "UserScope(1234)/UserScope(1234)", - scopeA: UserScopeOf("1234"), - scopeB: UserScopeOf("1234"), - wantAContainsB: true, - wantAStrictlyContainsB: false, - }, - { - name: "UserScope(1234)/UserScope(5678)", - scopeA: UserScopeOf("1234"), - scopeB: UserScopeOf("5678"), - wantAContainsB: false, - wantAStrictlyContainsB: false, - }, - { - name: "ProfileScope(A)/UserScope(A/1234)", - scopeA: PolicyScope{kind: ProfileSetting, profileID: "A"}, - scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "A"}, - wantAContainsB: true, - wantAStrictlyContainsB: true, - }, - { - name: "ProfileScope(A)/UserScope(B/1234)", - scopeA: PolicyScope{kind: ProfileSetting, profileID: "A"}, - scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "B"}, - wantAContainsB: false, - wantAStrictlyContainsB: false, - }, - { - name: "UserScope(1234)/UserScope(A/1234)", - scopeA: PolicyScope{kind: UserSetting, userID: "1234"}, - scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "A"}, - wantAContainsB: true, - wantAStrictlyContainsB: true, - }, - { - name: "UserScope(1234)/UserScope(A/5678)", - scopeA: PolicyScope{kind: UserSetting, userID: "1234"}, - scopeB: PolicyScope{kind: UserSetting, userID: "5678", profileID: "A"}, - wantAContainsB: false, - wantAStrictlyContainsB: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotContains := tt.scopeA.Contains(tt.scopeB) - if gotContains != tt.wantAContainsB { - t.Fatalf("WithinOf: got %v, want %v", gotContains, tt.wantAContainsB) - } - - gotStrictlyContains := tt.scopeA.StrictlyContains(tt.scopeB) - if gotStrictlyContains != tt.wantAStrictlyContainsB { - t.Fatalf("StrictlyWithinOf: got %v, want %v", gotStrictlyContains, tt.wantAStrictlyContainsB) - } - }) - } -} - -func TestPolicyScopeMarshalUnmarshal(t *testing.T) { - tests := []struct { - name string - in any - wantJSON string - wantError bool - }{ - { - name: "null-scope", - in: &struct { - Scope PolicyScope - }{}, - wantJSON: `{"Scope":"Device"}`, - }, - { - name: "null-scope-omit-zero", - in: &struct { - Scope PolicyScope `json:",omitzero"` - }{}, - wantJSON: `{}`, - }, - { - name: "device-scope", - in: &struct { - Scope PolicyScope - }{DeviceScope}, - wantJSON: `{"Scope":"Device"}`, - }, - { - name: "current-profile-scope", - in: &struct { - Scope PolicyScope - }{CurrentProfileScope}, - wantJSON: `{"Scope":"Profile"}`, - }, - { - name: "current-user-scope", - in: &struct { - Scope PolicyScope - }{CurrentUserScope}, - wantJSON: `{"Scope":"User"}`, - }, - { - name: "specific-user-scope", - in: &struct { - Scope PolicyScope - }{UserScopeOf("_")}, - wantJSON: `{"Scope":"User(_)"}`, - }, - { - name: "specific-user-scope", - in: &struct { - Scope PolicyScope - }{UserScopeOf("S-1-5-21-3698941153-1525015703-2649197413-1001")}, - wantJSON: `{"Scope":"User(S-1-5-21-3698941153-1525015703-2649197413-1001)"}`, - }, - { - name: "specific-profile-scope", - in: &struct { - Scope PolicyScope - }{PolicyScope{kind: ProfileSetting, profileID: "1234"}}, - wantJSON: `{"Scope":"Profile(1234)"}`, - }, - { - name: "specific-profile-and-user-scope", - in: &struct { - Scope PolicyScope - }{PolicyScope{ - kind: UserSetting, - profileID: "1234", - userID: "S-1-5-21-3698941153-1525015703-2649197413-1001", - }}, - wantJSON: `{"Scope":"Profile(1234)/User(S-1-5-21-3698941153-1525015703-2649197413-1001)"}`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotJSON, err := jsonv2.Marshal(tt.in) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - if string(gotJSON) != tt.wantJSON { - t.Fatalf("Marshal got %s, want %s", gotJSON, tt.wantJSON) - } - wantBack := tt.in - gotBack := reflect.New(reflect.TypeOf(tt.in).Elem()).Interface() - err = jsonv2.Unmarshal(gotJSON, gotBack) - if err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - if !reflect.DeepEqual(gotBack, wantBack) { - t.Fatalf("Unmarshal got %+v, want %+v", gotBack, wantBack) - } - }) - } -} - -func TestPolicyScopeUnmarshalSpecial(t *testing.T) { - tests := []struct { - name string - json string - want any - wantError bool - }{ - { - name: "empty", - json: "{}", - want: &struct { - Scope PolicyScope - }{}, - }, - { - name: "too-many-scopes", - json: `{"Scope":"Device/Profile/User"}`, - wantError: true, - }, - { - name: "user/profile", // incorrect order - json: `{"Scope":"User/Profile"}`, - wantError: true, - }, - { - name: "profile-user-no-params", - json: `{"Scope":"Profile/User"}`, - want: &struct { - Scope PolicyScope - }{CurrentUserScope}, - }, - { - name: "unknown-scope", - json: `{"Scope":"Unknown"}`, - wantError: true, - }, - { - name: "unknown-scope/unknown-scope", - json: `{"Scope":"Unknown/Unknown"}`, - wantError: true, - }, - { - name: "device-scope/unknown-scope", - json: `{"Scope":"Device/Unknown"}`, - wantError: true, - }, - { - name: "unknown-scope/device-scope", - json: `{"Scope":"Unknown/Device"}`, - wantError: true, - }, - { - name: "slash", - json: `{"Scope":"/"}`, - wantError: true, - }, - { - name: "empty", - json: `{"Scope": ""`, - wantError: true, - }, - { - name: "no-closing-bracket", - json: `{"Scope": "user(1234"`, - wantError: true, - }, - { - name: "device-with-id", - json: `{"Scope": "device(123)"`, - wantError: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := &struct { - Scope PolicyScope - }{} - err := jsonv2.Unmarshal([]byte(tt.json), got) - if (err != nil) != tt.wantError { - t.Errorf("Marshal error: got %v, want %v", err, tt.wantError) - } - if err != nil { - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("Unmarshal got %+v, want %+v", got, tt.want) - } - }) - } - -} - -func TestExtractScopeAndParams(t *testing.T) { - tests := []struct { - name string - s string - scope string - params string - wantOk bool - }{ - { - name: "empty", - s: "", - wantOk: true, - }, - { - name: "scope-only", - s: "device", - scope: "device", - wantOk: true, - }, - { - name: "scope-with-params", - s: "user(1234)", - scope: "user", - params: "1234", - wantOk: true, - }, - { - name: "params-empty-scope", - s: "(1234)", - scope: "", - params: "1234", - wantOk: true, - }, - { - name: "params-with-brackets", - s: "test()())))())", - scope: "test", - params: ")())))()", - wantOk: true, - }, - { - name: "no-closing-bracket", - s: "user(1234", - scope: "", - params: "", - wantOk: false, - }, - { - name: "open-before-close", - s: ")user(1234", - scope: "", - params: "", - wantOk: false, - }, - { - name: "brackets-only", - s: ")(", - scope: "", - params: "", - wantOk: false, - }, - { - name: "closing-bracket", - s: ")", - scope: "", - params: "", - wantOk: false, - }, - { - name: "opening-bracket", - s: ")", - scope: "", - params: "", - wantOk: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - scope, params, ok := extractScopeAndParams(tt.s) - if ok != tt.wantOk { - t.Logf("OK: got %v; want %v", ok, tt.wantOk) - } - if scope != tt.scope { - t.Logf("Scope: got %q; want %q", scope, tt.scope) - } - if params != tt.params { - t.Logf("Params: got %v; want %v", params, tt.params) - } - }) - } -} diff --git a/util/syspolicy/setting/raw_item.go b/util/syspolicy/setting/raw_item.go index cf46e54b76217..a0da8c902702b 100644 --- a/util/syspolicy/setting/raw_item.go +++ b/util/syspolicy/setting/raw_item.go @@ -9,8 +9,8 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/opt" - "tailscale.com/types/structs" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/structs" ) // RawItem contains a raw policy setting value as read from a policy store, or an diff --git a/util/syspolicy/setting/raw_item_test.go b/util/syspolicy/setting/raw_item_test.go deleted file mode 100644 index 05562d78c41f3..0000000000000 --- a/util/syspolicy/setting/raw_item_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package setting - -import ( - "math" - "reflect" - "strconv" - "testing" - - jsonv2 "github.com/go-json-experiment/json" -) - -func TestMarshalUnmarshalRawValue(t *testing.T) { - tests := []struct { - name string - json string - want RawValue - wantErr bool - }{ - { - name: "Bool/True", - json: `true`, - want: RawValueOf(true), - }, - { - name: "Bool/False", - json: `false`, - want: RawValueOf(false), - }, - { - name: "String/Empty", - json: `""`, - want: RawValueOf(""), - }, - { - name: "String/NonEmpty", - json: `"Test"`, - want: RawValueOf("Test"), - }, - { - name: "StringSlice/Null", - json: `null`, - want: RawValueOf([]string(nil)), - }, - { - name: "StringSlice/Empty", - json: `[]`, - want: RawValueOf([]string{}), - }, - { - name: "StringSlice/NonEmpty", - json: `["A", "B", "C"]`, - want: RawValueOf([]string{"A", "B", "C"}), - }, - { - name: "StringSlice/NonStrings", - json: `[1, 2, 3]`, - wantErr: true, - }, - { - name: "Number/Integer/0", - json: `0`, - want: RawValueOf(uint64(0)), - }, - { - name: "Number/Integer/1", - json: `1`, - want: RawValueOf(uint64(1)), - }, - { - name: "Number/Integer/MaxUInt64", - json: strconv.FormatUint(math.MaxUint64, 10), - want: RawValueOf(uint64(math.MaxUint64)), - }, - { - name: "Number/Integer/Negative", - json: `-1`, - wantErr: true, - }, - { - name: "Object", - json: `{}`, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var got RawValue - gotErr := jsonv2.Unmarshal([]byte(tt.json), &got) - if (gotErr != nil) != tt.wantErr { - t.Fatalf("Error: got %v; want %v", gotErr, tt.wantErr) - } - - if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { - t.Fatalf("Value: got %v; want %v", got, tt.want) - } - }) - } -} diff --git a/util/syspolicy/setting/setting.go b/util/syspolicy/setting/setting.go index 70fb0a931e250..4577d058a9697 100644 --- a/util/syspolicy/setting/setting.go +++ b/util/syspolicy/setting/setting.go @@ -14,8 +14,8 @@ import ( "sync" "time" - "tailscale.com/types/lazy" - "tailscale.com/util/syspolicy/internal" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/util/syspolicy/internal" ) // Scope indicates the broadest scope at which a policy setting may apply, diff --git a/util/syspolicy/setting/setting_test.go b/util/syspolicy/setting/setting_test.go deleted file mode 100644 index 3cc08e7da3d8d..0000000000000 --- a/util/syspolicy/setting/setting_test.go +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package setting - -import ( - "slices" - "strings" - "testing" - - "tailscale.com/types/lazy" - "tailscale.com/types/ptr" - "tailscale.com/util/syspolicy/internal" -) - -func TestSettingDefinition(t *testing.T) { - tests := []struct { - name string - setting *Definition - osOverride string - wantKey Key - wantScope Scope - wantType Type - wantIsSupported bool - wantSupportedPlatforms PlatformList - wantString string - }{ - { - name: "Nil", - setting: nil, - wantKey: "", - wantScope: 0, - wantType: InvalidValue, - wantIsSupported: false, - wantString: "(nil)", - }, - { - name: "Device/Invalid", - setting: NewDefinition("TestDevicePolicySetting", DeviceSetting, InvalidValue), - wantKey: "TestDevicePolicySetting", - wantScope: DeviceSetting, - wantType: InvalidValue, - wantIsSupported: true, - wantString: `Device("TestDevicePolicySetting", Invalid)`, - }, - { - name: "Device/Integer", - setting: NewDefinition("TestDevicePolicySetting", DeviceSetting, IntegerValue), - wantKey: "TestDevicePolicySetting", - wantScope: DeviceSetting, - wantType: IntegerValue, - wantIsSupported: true, - wantString: `Device("TestDevicePolicySetting", Integer)`, - }, - { - name: "Profile/String", - setting: NewDefinition("TestProfilePolicySetting", ProfileSetting, StringValue), - wantKey: "TestProfilePolicySetting", - wantScope: ProfileSetting, - wantType: StringValue, - wantIsSupported: true, - wantString: `Profile("TestProfilePolicySetting", String)`, - }, - { - name: "Device/StringList", - setting: NewDefinition("AllowedSuggestedExitNodes", DeviceSetting, StringListValue), - wantKey: "AllowedSuggestedExitNodes", - wantScope: DeviceSetting, - wantType: StringListValue, - wantIsSupported: true, - wantString: `Device("AllowedSuggestedExitNodes", StringList)`, - }, - { - name: "Device/PreferenceOption", - setting: NewDefinition("AdvertiseExitNode", DeviceSetting, PreferenceOptionValue), - wantKey: "AdvertiseExitNode", - wantScope: DeviceSetting, - wantType: PreferenceOptionValue, - wantIsSupported: true, - wantString: `Device("AdvertiseExitNode", PreferenceOption)`, - }, - { - name: "User/Boolean", - setting: NewDefinition("TestUserPolicySetting", UserSetting, BooleanValue), - wantKey: "TestUserPolicySetting", - wantScope: UserSetting, - wantType: BooleanValue, - wantIsSupported: true, - wantString: `User("TestUserPolicySetting", Boolean)`, - }, - { - name: "User/Visibility", - setting: NewDefinition("AdminConsole", UserSetting, VisibilityValue), - wantKey: "AdminConsole", - wantScope: UserSetting, - wantType: VisibilityValue, - wantIsSupported: true, - wantString: `User("AdminConsole", Visibility)`, - }, - { - name: "User/Duration", - setting: NewDefinition("KeyExpirationNotice", UserSetting, DurationValue), - wantKey: "KeyExpirationNotice", - wantScope: UserSetting, - wantType: DurationValue, - wantIsSupported: true, - wantString: `User("KeyExpirationNotice", Duration)`, - }, - { - name: "SupportedSetting", - setting: NewDefinition("DesktopPolicySetting", DeviceSetting, StringValue, "macos", "windows"), - osOverride: "windows", - wantKey: "DesktopPolicySetting", - wantScope: DeviceSetting, - wantType: StringValue, - wantIsSupported: true, - wantSupportedPlatforms: PlatformList{"macos", "windows"}, - wantString: `Device("DesktopPolicySetting", String)`, - }, - { - name: "UnsupportedSetting", - setting: NewDefinition("AndroidPolicySetting", DeviceSetting, StringValue, "android"), - osOverride: "macos", - wantKey: "AndroidPolicySetting", - wantScope: DeviceSetting, - wantType: StringValue, - wantIsSupported: false, - wantSupportedPlatforms: PlatformList{"android"}, - wantString: `Device("AndroidPolicySetting", String)`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.osOverride != "" { - internal.OSForTesting.SetForTest(t, tt.osOverride, nil) - } - if !tt.setting.Equal(tt.setting) { - t.Errorf("the setting should be equal to itself") - } - if tt.setting != nil && !tt.setting.Equal(ptr.To(*tt.setting)) { - t.Errorf("the setting should be equal to its shallow copy") - } - if gotKey := tt.setting.Key(); gotKey != tt.wantKey { - t.Errorf("Key: got %q, want %q", gotKey, tt.wantKey) - } - if gotScope := tt.setting.Scope(); gotScope != tt.wantScope { - t.Errorf("Scope: got %v, want %v", gotScope, tt.wantScope) - } - if gotType := tt.setting.Type(); gotType != tt.wantType { - t.Errorf("Type: got %v, want %v", gotType, tt.wantType) - } - if gotIsSupported := tt.setting.IsSupported(); gotIsSupported != tt.wantIsSupported { - t.Errorf("IsSupported: got %v, want %v", gotIsSupported, tt.wantIsSupported) - } - if gotSupportedPlatforms := tt.setting.SupportedPlatforms(); !slices.Equal(gotSupportedPlatforms, tt.wantSupportedPlatforms) { - t.Errorf("SupportedPlatforms: got %v, want %v", gotSupportedPlatforms, tt.wantSupportedPlatforms) - } - if gotString := tt.setting.String(); gotString != tt.wantString { - t.Errorf("String: got %v, want %v", gotString, tt.wantString) - } - }) - } -} - -func TestRegisterSettingDefinition(t *testing.T) { - const testPolicySettingKey Key = "TestPolicySetting" - tests := []struct { - name string - key Key - wantEq *Definition - wantErr error - }{ - { - name: "GetRegistered", - key: "TestPolicySetting", - wantEq: NewDefinition(testPolicySettingKey, DeviceSetting, StringValue), - }, - { - name: "GetNonRegistered", - key: "OtherPolicySetting", - wantEq: nil, - wantErr: ErrNoSuchKey, - }, - } - - resetSettingDefinitions(t) - Register(testPolicySettingKey, DeviceSetting, StringValue) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, gotErr := DefinitionOf(tt.key) - if gotErr != tt.wantErr { - t.Errorf("gotErr %v, wantErr %v", gotErr, tt.wantErr) - } - if !got.Equal(tt.wantEq) { - t.Errorf("got %v, want %v", got, tt.wantEq) - } - }) - } -} - -func TestRegisterAfterUsePanics(t *testing.T) { - resetSettingDefinitions(t) - - Register("TestPolicySetting", DeviceSetting, StringValue) - DefinitionOf("TestPolicySetting") - - func() { - defer func() { - if gotPanic, wantPanic := recover(), "policy definitions are already in use"; gotPanic != wantPanic { - t.Errorf("gotPanic: %q, wantPanic: %q", gotPanic, wantPanic) - } - }() - - Register("TestPolicySetting", DeviceSetting, StringValue) - }() -} - -func TestRegisterDuplicateSettings(t *testing.T) { - - tests := []struct { - name string - settings []*Definition - wantEq *Definition - wantErrStr string - }{ - { - name: "NoConflict/Exact", - settings: []*Definition{ - NewDefinition("TestPolicySetting", DeviceSetting, StringValue), - NewDefinition("TestPolicySetting", DeviceSetting, StringValue), - }, - wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue), - }, - { - name: "NoConflict/MergeOS-First", - settings: []*Definition{ - NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "android", "macos"), - NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms - }, - wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms - }, - { - name: "NoConflict/MergeOS-Second", - settings: []*Definition{ - NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms - NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "android", "macos"), - }, - wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms - }, - { - name: "NoConflict/MergeOS-Both", - settings: []*Definition{ - NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "macos"), - NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "windows"), - }, - wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "macos", "windows"), - }, - { - name: "Conflict/Scope", - settings: []*Definition{ - NewDefinition("TestPolicySetting", DeviceSetting, StringValue), - NewDefinition("TestPolicySetting", UserSetting, StringValue), - }, - wantEq: nil, - wantErrStr: `duplicate policy definition: "TestPolicySetting"`, - }, - { - name: "Conflict/Type", - settings: []*Definition{ - NewDefinition("TestPolicySetting", UserSetting, StringValue), - NewDefinition("TestPolicySetting", UserSetting, IntegerValue), - }, - wantEq: nil, - wantErrStr: `duplicate policy definition: "TestPolicySetting"`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resetSettingDefinitions(t) - for _, s := range tt.settings { - Register(s.Key(), s.Scope(), s.Type(), s.SupportedPlatforms()...) - } - got, err := DefinitionOf("TestPolicySetting") - var gotErrStr string - if err != nil { - gotErrStr = err.Error() - } - if gotErrStr != tt.wantErrStr { - t.Fatalf("ErrStr: got %q, want %q", gotErrStr, tt.wantErrStr) - } - if !got.Equal(tt.wantEq) { - t.Errorf("Definition got %v, want %v", got, tt.wantEq) - } - if !slices.Equal(got.SupportedPlatforms(), tt.wantEq.SupportedPlatforms()) { - t.Errorf("SupportedPlatforms got %v, want %v", got.SupportedPlatforms(), tt.wantEq.SupportedPlatforms()) - } - }) - } -} - -func TestListSettingDefinitions(t *testing.T) { - definitions := []*Definition{ - NewDefinition("TestDevicePolicySetting", DeviceSetting, IntegerValue), - NewDefinition("TestProfilePolicySetting", ProfileSetting, StringValue), - NewDefinition("TestUserPolicySetting", UserSetting, BooleanValue), - NewDefinition("TestStringListPolicySetting", DeviceSetting, StringListValue), - } - if err := SetDefinitionsForTest(t, definitions...); err != nil { - t.Fatalf("SetDefinitionsForTest failed: %v", err) - } - - cmp := func(l, r *Definition) int { - return strings.Compare(string(l.Key()), string(r.Key())) - } - want := append([]*Definition{}, definitions...) - slices.SortFunc(want, cmp) - - got, err := Definitions() - if err != nil { - t.Fatalf("Definitions failed: %v", err) - } - slices.SortFunc(got, cmp) - - if !slices.Equal(got, want) { - t.Errorf("got %v, want %v", got, want) - } -} - -func resetSettingDefinitions(t *testing.T) { - t.Cleanup(func() { - definitionsMu.Lock() - definitionsList = nil - definitions = lazy.SyncValue[DefinitionMap]{} - definitionsUsed = false - definitionsMu.Unlock() - }) - - definitionsMu.Lock() - definitionsList = nil - definitions = lazy.SyncValue[DefinitionMap]{} - definitionsUsed = false - definitionsMu.Unlock() -} diff --git a/util/syspolicy/setting/snapshot.go b/util/syspolicy/setting/snapshot.go index 0af2bae0f480a..06ac0b137cd73 100644 --- a/util/syspolicy/setting/snapshot.go +++ b/util/syspolicy/setting/snapshot.go @@ -12,8 +12,8 @@ import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" + "github.com/sagernet/tailscale/util/deephash" xmaps "golang.org/x/exp/maps" - "tailscale.com/util/deephash" ) // Snapshot is an immutable collection of ([Key], [RawItem]) pairs, representing diff --git a/util/syspolicy/setting/snapshot_test.go b/util/syspolicy/setting/snapshot_test.go deleted file mode 100644 index d41b362f06976..0000000000000 --- a/util/syspolicy/setting/snapshot_test.go +++ /dev/null @@ -1,569 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package setting - -import ( - "cmp" - "encoding/json" - "testing" - "time" - - jsonv2 "github.com/go-json-experiment/json" - "tailscale.com/util/syspolicy/internal" -) - -func TestMergeSnapshots(t *testing.T) { - tests := []struct { - name string - s1, s2 *Snapshot - want *Snapshot - }{ - { - name: "both-nil", - s1: nil, - s2: nil, - want: NewSnapshot(map[Key]RawItem{}), - }, - { - name: "both-empty", - s1: NewSnapshot(map[Key]RawItem{}), - s2: NewSnapshot(map[Key]RawItem{}), - want: NewSnapshot(map[Key]RawItem{}), - }, - { - name: "first-nil", - s1: nil, - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }), - }, - { - name: "first-empty", - s1: NewSnapshot(map[Key]RawItem{}), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - }, - { - name: "second-nil", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }), - s2: nil, - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }), - }, - { - name: "second-empty", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - s2: NewSnapshot(map[Key]RawItem{}), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - }, - { - name: "no-conflicts", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - s2: NewSnapshot(map[Key]RawItem{ - "Setting4": RawItemOf(2 * time.Hour), - "Setting5": RawItemOf(VisibleByPolicy), - "Setting6": RawItemOf(ShowChoiceByPolicy), - }), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - "Setting4": RawItemOf(2 * time.Hour), - "Setting5": RawItemOf(VisibleByPolicy), - "Setting6": RawItemOf(ShowChoiceByPolicy), - }), - }, - { - name: "with-conflicts", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(456), - "Setting3": RawItemOf(false), - "Setting4": RawItemOf(2 * time.Hour), - }), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(456), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - "Setting4": RawItemOf(2 * time.Hour), - }), - }, - { - name: "with-scope-first-wins", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }, DeviceScope), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(456), - "Setting3": RawItemOf(false), - "Setting4": RawItemOf(2 * time.Hour), - }, CurrentUserScope), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - "Setting4": RawItemOf(2 * time.Hour), - }, CurrentUserScope), - }, - { - name: "with-scope-second-wins", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }, CurrentUserScope), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(456), - "Setting3": RawItemOf(false), - "Setting4": RawItemOf(2 * time.Hour), - }, DeviceScope), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(456), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - "Setting4": RawItemOf(2 * time.Hour), - }, CurrentUserScope), - }, - { - name: "with-scope-both-empty", - s1: NewSnapshot(map[Key]RawItem{}, CurrentUserScope), - s2: NewSnapshot(map[Key]RawItem{}, DeviceScope), - want: NewSnapshot(map[Key]RawItem{}, CurrentUserScope), - }, - { - name: "with-scope-first-empty", - s1: NewSnapshot(map[Key]RawItem{}, CurrentUserScope), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true)}, DeviceScope, NewNamedOrigin("TestPolicy", DeviceScope)), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }, CurrentUserScope, NewNamedOrigin("TestPolicy", DeviceScope)), - }, - { - name: "with-scope-second-empty", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }, CurrentUserScope), - s2: NewSnapshot(map[Key]RawItem{}), - want: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }, CurrentUserScope), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := MergeSnapshots(tt.s1, tt.s2) - if !got.Equal(tt.want) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestSnapshotEqual(t *testing.T) { - tests := []struct { - name string - s1, s2 *Snapshot - wantEqual bool - wantEqualItems bool - }{ - { - name: "nil-nil", - s1: nil, - s2: nil, - wantEqual: true, - wantEqualItems: true, - }, - { - name: "nil-empty", - s1: nil, - s2: NewSnapshot(map[Key]RawItem{}), - wantEqual: true, - wantEqualItems: true, - }, - { - name: "empty-nil", - s1: NewSnapshot(map[Key]RawItem{}), - s2: nil, - wantEqual: true, - wantEqualItems: true, - }, - { - name: "empty-empty", - s1: NewSnapshot(map[Key]RawItem{}), - s2: NewSnapshot(map[Key]RawItem{}), - wantEqual: true, - wantEqualItems: true, - }, - { - name: "first-nil", - s1: nil, - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - wantEqual: false, - wantEqualItems: false, - }, - { - name: "first-empty", - s1: NewSnapshot(map[Key]RawItem{}), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - wantEqual: false, - wantEqualItems: false, - }, - { - name: "second-nil", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(true), - }), - s2: nil, - wantEqual: false, - wantEqualItems: false, - }, - { - name: "second-empty", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - s2: NewSnapshot(map[Key]RawItem{}), - wantEqual: false, - wantEqualItems: false, - }, - { - name: "same-items-same-order-no-scope", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }), - wantEqual: true, - wantEqualItems: true, - }, - { - name: "same-items-same-order-same-scope", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }, DeviceScope), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }, DeviceScope), - wantEqual: true, - wantEqualItems: true, - }, - { - name: "same-items-different-order-same-scope", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }, DeviceScope), - s2: NewSnapshot(map[Key]RawItem{ - "Setting3": RawItemOf(false), - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - }, DeviceScope), - wantEqual: true, - wantEqualItems: true, - }, - { - name: "same-items-same-order-different-scope", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }, DeviceScope), - s2: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }, CurrentUserScope), - wantEqual: false, - wantEqualItems: true, - }, - { - name: "different-items-same-scope", - s1: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(123), - "Setting2": RawItemOf("String"), - "Setting3": RawItemOf(false), - }, DeviceScope), - s2: NewSnapshot(map[Key]RawItem{ - "Setting4": RawItemOf(2 * time.Hour), - "Setting5": RawItemOf(VisibleByPolicy), - "Setting6": RawItemOf(ShowChoiceByPolicy), - }, DeviceScope), - wantEqual: false, - wantEqualItems: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if gotEqual := tt.s1.Equal(tt.s2); gotEqual != tt.wantEqual { - t.Errorf("WantEqual: got %v, want %v", gotEqual, tt.wantEqual) - } - if gotEqualItems := tt.s1.EqualItems(tt.s2); gotEqualItems != tt.wantEqualItems { - t.Errorf("WantEqualItems: got %v, want %v", gotEqualItems, tt.wantEqualItems) - } - }) - } -} - -func TestSnapshotString(t *testing.T) { - tests := []struct { - name string - snapshot *Snapshot - wantString string - }{ - { - name: "nil", - snapshot: nil, - wantString: "{Empty}", - }, - { - name: "empty", - snapshot: NewSnapshot(nil), - wantString: "{Empty}", - }, - { - name: "empty-with-scope", - snapshot: NewSnapshot(nil, DeviceScope), - wantString: "{Empty, Device}", - }, - { - name: "empty-with-origin", - snapshot: NewSnapshot(nil, NewNamedOrigin("Test Policy", DeviceScope)), - wantString: "{Empty, Test Policy (Device)}", - }, - { - name: "non-empty", - snapshot: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemOf(2 * time.Hour), - "Setting2": RawItemOf(VisibleByPolicy), - "Setting3": RawItemOf(ShowChoiceByPolicy), - }, NewNamedOrigin("Test Policy", DeviceScope)), - wantString: `{Test Policy (Device)} -Setting1 = 2h0m0s -Setting2 = show -Setting3 = user-decides`, - }, - { - name: "non-empty-with-item-origin", - snapshot: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemWith(42, nil, NewNamedOrigin("Test Policy", DeviceScope)), - }), - wantString: `Setting1 = 42 - {Test Policy (Device)}`, - }, - { - name: "non-empty-with-item-error", - snapshot: NewSnapshot(map[Key]RawItem{ - "Setting1": RawItemWith(nil, NewErrorText("bang!"), nil), - }), - wantString: `Setting1 = Error{"bang!"}`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if gotString := tt.snapshot.String(); gotString != tt.wantString { - t.Errorf("got %v\nwant %v", gotString, tt.wantString) - } - }) - } -} - -func TestMarshalUnmarshalSnapshot(t *testing.T) { - tests := []struct { - name string - snapshot *Snapshot - wantJSON string - wantBack *Snapshot - }{ - { - name: "Nil", - snapshot: (*Snapshot)(nil), - wantJSON: "null", - wantBack: NewSnapshot(nil), - }, - { - name: "Zero", - snapshot: &Snapshot{}, - wantJSON: "{}", - }, - { - name: "Bool/True", - snapshot: NewSnapshot(map[Key]RawItem{"BoolPolicy": RawItemOf(true)}), - wantJSON: `{"Settings": {"BoolPolicy": {"Value": true}}}`, - }, - { - name: "Bool/False", - snapshot: NewSnapshot(map[Key]RawItem{"BoolPolicy": RawItemOf(false)}), - wantJSON: `{"Settings": {"BoolPolicy": {"Value": false}}}`, - }, - { - name: "String/Non-Empty", - snapshot: NewSnapshot(map[Key]RawItem{"StringPolicy": RawItemOf("StringValue")}), - wantJSON: `{"Settings": {"StringPolicy": {"Value": "StringValue"}}}`, - }, - { - name: "String/Empty", - snapshot: NewSnapshot(map[Key]RawItem{"StringPolicy": RawItemOf("")}), - wantJSON: `{"Settings": {"StringPolicy": {"Value": ""}}}`, - }, - { - name: "Integer/NonZero", - snapshot: NewSnapshot(map[Key]RawItem{"IntPolicy": RawItemOf(uint64(42))}), - wantJSON: `{"Settings": {"IntPolicy": {"Value": 42}}}`, - }, - { - name: "Integer/Zero", - snapshot: NewSnapshot(map[Key]RawItem{"IntPolicy": RawItemOf(uint64(0))}), - wantJSON: `{"Settings": {"IntPolicy": {"Value": 0}}}`, - }, - { - name: "String-List", - snapshot: NewSnapshot(map[Key]RawItem{"ListPolicy": RawItemOf([]string{"Value1", "Value2"})}), - wantJSON: `{"Settings": {"ListPolicy": {"Value": ["Value1", "Value2"]}}}`, - }, - { - name: "Empty/With-Summary", - snapshot: NewSnapshot( - map[Key]RawItem{}, - SummaryWith(CurrentUserScope, NewNamedOrigin("TestSource", DeviceScope)), - ), - wantJSON: `{"Summary": {"Origin": {"Name": "TestSource", "Scope": "Device"}, "Scope": "User"}}`, - }, - { - name: "Setting/With-Summary", - snapshot: NewSnapshot( - map[Key]RawItem{"PolicySetting": RawItemOf(uint64(42))}, - SummaryWith(CurrentUserScope, NewNamedOrigin("TestSource", DeviceScope)), - ), - wantJSON: `{ - "Summary": {"Origin": {"Name": "TestSource", "Scope": "Device"}, "Scope": "User"}, - "Settings": {"PolicySetting": {"Value": 42}} - }`, - }, - { - name: "Settings/With-Origins", - snapshot: NewSnapshot( - map[Key]RawItem{ - "SettingA": RawItemWith(uint64(42), nil, NewNamedOrigin("SourceA", DeviceScope)), - "SettingB": RawItemWith("B", nil, NewNamedOrigin("SourceB", CurrentProfileScope)), - "SettingC": RawItemWith(true, nil, NewNamedOrigin("SourceC", CurrentUserScope)), - }, - ), - wantJSON: `{ - "Settings": { - "SettingA": {"Value": 42, "Origin": {"Name": "SourceA", "Scope": "Device"}}, - "SettingB": {"Value": "B", "Origin": {"Name": "SourceB", "Scope": "Profile"}}, - "SettingC": {"Value": true, "Origin": {"Name": "SourceC", "Scope": "User"}} - } - }`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - doTest := func(t *testing.T, useJSONv2 bool) { - var gotJSON []byte - var err error - if useJSONv2 { - gotJSON, err = jsonv2.Marshal(tt.snapshot) - } else { - gotJSON, err = json.Marshal(tt.snapshot) - } - if err != nil { - t.Fatal(err) - } - - if got, want, equal := internal.EqualJSONForTest(t, gotJSON, []byte(tt.wantJSON)); !equal { - t.Errorf("JSON: got %s; want %s", got, want) - } - - gotBack := &Snapshot{} - if useJSONv2 { - err = jsonv2.Unmarshal(gotJSON, &gotBack) - } else { - err = json.Unmarshal(gotJSON, &gotBack) - } - if err != nil { - t.Fatal(err) - } - - if wantBack := cmp.Or(tt.wantBack, tt.snapshot); !gotBack.Equal(wantBack) { - t.Errorf("Snapshot: got %+v; want %+v", gotBack, wantBack) - } - } - - t.Run("json", func(t *testing.T) { doTest(t, false) }) - t.Run("jsonv2", func(t *testing.T) { doTest(t, true) }) - }) - } -} diff --git a/util/syspolicy/setting/summary.go b/util/syspolicy/setting/summary.go index 5ff20e0aa2752..b6b9be2b879cf 100644 --- a/util/syspolicy/setting/summary.go +++ b/util/syspolicy/setting/summary.go @@ -6,7 +6,7 @@ package setting import ( jsonv2 "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" - "tailscale.com/types/opt" + "github.com/sagernet/tailscale/types/opt" ) // Summary is an immutable [PolicyScope] and [Origin]. diff --git a/util/syspolicy/source/env_policy_store.go b/util/syspolicy/source/env_policy_store.go index 299132b4e11b3..4a9b086c719bc 100644 --- a/util/syspolicy/source/env_policy_store.go +++ b/util/syspolicy/source/env_policy_store.go @@ -11,7 +11,7 @@ import ( "strings" "unicode/utf8" - "tailscale.com/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/syspolicy/setting" ) var lookupEnv = os.LookupEnv // test hook diff --git a/util/syspolicy/source/env_policy_store_test.go b/util/syspolicy/source/env_policy_store_test.go deleted file mode 100644 index 9eacf6378b450..0000000000000 --- a/util/syspolicy/source/env_policy_store_test.go +++ /dev/null @@ -1,359 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package source - -import ( - "cmp" - "errors" - "math" - "reflect" - "strconv" - "testing" - - "tailscale.com/util/syspolicy/setting" -) - -func TestKeyToEnvVarName(t *testing.T) { - tests := []struct { - name string - key setting.Key - want string // suffix after "TS_DEBUGSYSPOLICY_" - wantErr error - }{ - { - name: "empty", - key: "", - wantErr: errEmptyKey, - }, - { - name: "lowercase", - key: "tailnet", - want: "TAILNET", - }, - { - name: "CamelCase", - key: "AuthKey", - want: "AUTH_KEY", - }, - { - name: "LongerCamelCase", - key: "ManagedByOrganizationName", - want: "MANAGED_BY_ORGANIZATION_NAME", - }, - { - name: "UPPERCASE", - key: "UPPERCASE", - want: "UPPERCASE", - }, - { - name: "WithAbbrev/Front", - key: "DNSServer", - want: "DNS_SERVER", - }, - { - name: "WithAbbrev/Middle", - key: "ExitNodeAllowLANAccess", - want: "EXIT_NODE_ALLOW_LAN_ACCESS", - }, - { - name: "WithAbbrev/Back", - key: "ExitNodeID", - want: "EXIT_NODE_ID", - }, - { - name: "WithDigits/Single/Front", - key: "0TestKey", - want: "0_TEST_KEY", - }, - { - name: "WithDigits/Multi/Front", - key: "64TestKey", - want: "64_TEST_KEY", - }, - { - name: "WithDigits/Single/Middle", - key: "Test0Key", - want: "TEST_0_KEY", - }, - { - name: "WithDigits/Multi/Middle", - key: "Test64Key", - want: "TEST_64_KEY", - }, - { - name: "WithDigits/Single/Back", - key: "TestKey0", - want: "TEST_KEY_0", - }, - { - name: "WithDigits/Multi/Back", - key: "TestKey64", - want: "TEST_KEY_64", - }, - { - name: "WithDigits/Multi/Back", - key: "TestKey64", - want: "TEST_KEY_64", - }, - { - name: "WithPathSeparators/Single", - key: "Key/Subkey", - want: "KEY_SUBKEY", - }, - { - name: "WithPathSeparators/Multi", - key: "Root/Level1/Level2", - want: "ROOT_LEVEL_1_LEVEL_2", - }, - { - name: "Mixed", - key: "Network/DNSServer/IPAddress", - want: "NETWORK_DNS_SERVER_IP_ADDRESS", - }, - { - name: "Non-Alphanumeric/NonASCII/1", - key: "ж", - wantErr: errInvalidKey, - }, - { - name: "Non-Alphanumeric/NonASCII/2", - key: "KeyжName", - wantErr: errInvalidKey, - }, - { - name: "Non-Alphanumeric/Space", - key: "Key Name", - wantErr: errInvalidKey, - }, - { - name: "Non-Alphanumeric/Punct", - key: "Key!Name", - wantErr: errInvalidKey, - }, - { - name: "Non-Alphanumeric/Backslash", - key: `Key\Name`, - wantErr: errInvalidKey, - }, - } - for _, tt := range tests { - t.Run(cmp.Or(tt.name, string(tt.key)), func(t *testing.T) { - got, err := keyToEnvVarName(tt.key) - checkError(t, err, tt.wantErr, true) - - want := tt.want - if want != "" { - want = "TS_DEBUGSYSPOLICY_" + want - } - if got != want { - t.Fatalf("got %q; want %q", got, want) - } - }) - } -} - -func TestEnvPolicyStore(t *testing.T) { - blankEnv := func(string) (string, bool) { return "", false } - makeEnv := func(wantName, value string) func(string) (string, bool) { - wantName = "TS_DEBUGSYSPOLICY_" + wantName - return func(gotName string) (string, bool) { - if gotName != wantName { - return "", false - } - return value, true - } - } - tests := []struct { - name string - key setting.Key - lookup func(string) (string, bool) - want any - wantErr error - }{ - { - name: "NotConfigured/String", - key: "AuthKey", - lookup: blankEnv, - wantErr: setting.ErrNotConfigured, - want: "", - }, - { - name: "Configured/String/Empty", - key: "AuthKey", - lookup: makeEnv("AUTH_KEY", ""), - want: "", - }, - { - name: "Configured/String/NonEmpty", - key: "AuthKey", - lookup: makeEnv("AUTH_KEY", "ABC123"), - want: "ABC123", - }, - { - name: "NotConfigured/UInt64", - key: "IntegerSetting", - lookup: blankEnv, - wantErr: setting.ErrNotConfigured, - want: uint64(0), - }, - { - name: "Configured/UInt64/Empty", - key: "IntegerSetting", - lookup: makeEnv("INTEGER_SETTING", ""), - wantErr: setting.ErrNotConfigured, - want: uint64(0), - }, - { - name: "Configured/UInt64/Zero", - key: "IntegerSetting", - lookup: makeEnv("INTEGER_SETTING", "0"), - want: uint64(0), - }, - { - name: "Configured/UInt64/NonZero", - key: "IntegerSetting", - lookup: makeEnv("INTEGER_SETTING", "12345"), - want: uint64(12345), - }, - { - name: "Configured/UInt64/MaxUInt64", - key: "IntegerSetting", - lookup: makeEnv("INTEGER_SETTING", strconv.FormatUint(math.MaxUint64, 10)), - want: uint64(math.MaxUint64), - }, - { - name: "Configured/UInt64/Negative", - key: "IntegerSetting", - lookup: makeEnv("INTEGER_SETTING", "-1"), - wantErr: setting.ErrTypeMismatch, - want: uint64(0), - }, - { - name: "Configured/UInt64/Hex", - key: "IntegerSetting", - lookup: makeEnv("INTEGER_SETTING", "0xDEADBEEF"), - want: uint64(0xDEADBEEF), - }, - { - name: "NotConfigured/Bool", - key: "LogSCMInteractions", - lookup: blankEnv, - wantErr: setting.ErrNotConfigured, - want: false, - }, - { - name: "Configured/Bool/Empty", - key: "LogSCMInteractions", - lookup: makeEnv("LOG_SCM_INTERACTIONS", ""), - wantErr: setting.ErrNotConfigured, - want: false, - }, - { - name: "Configured/Bool/True", - key: "LogSCMInteractions", - lookup: makeEnv("LOG_SCM_INTERACTIONS", "true"), - want: true, - }, - { - name: "Configured/Bool/False", - key: "LogSCMInteractions", - lookup: makeEnv("LOG_SCM_INTERACTIONS", "False"), - want: false, - }, - { - name: "Configured/Bool/1", - key: "LogSCMInteractions", - lookup: makeEnv("LOG_SCM_INTERACTIONS", "1"), - want: true, - }, - { - name: "Configured/Bool/0", - key: "LogSCMInteractions", - lookup: makeEnv("LOG_SCM_INTERACTIONS", "0"), - want: false, - }, - { - name: "Configured/Bool/Invalid", - key: "IntegerSetting", - lookup: makeEnv("INTEGER_SETTING", "NotABool"), - wantErr: setting.ErrTypeMismatch, - want: false, - }, - { - name: "NotConfigured/StringArray", - key: "AllowedSuggestedExitNodes", - lookup: blankEnv, - wantErr: setting.ErrNotConfigured, - want: []string(nil), - }, - { - name: "Configured/StringArray/Empty", - key: "AllowedSuggestedExitNodes", - lookup: makeEnv("ALLOWED_SUGGESTED_EXIT_NODES", ""), - want: []string(nil), - }, - { - name: "Configured/StringArray/Spaces", - key: "AllowedSuggestedExitNodes", - lookup: makeEnv("ALLOWED_SUGGESTED_EXIT_NODES", " \t "), - want: []string{}, - }, - { - name: "Configured/StringArray/Single", - key: "AllowedSuggestedExitNodes", - lookup: makeEnv("ALLOWED_SUGGESTED_EXIT_NODES", "NodeA"), - want: []string{"NodeA"}, - }, - { - name: "Configured/StringArray/Multi", - key: "AllowedSuggestedExitNodes", - lookup: makeEnv("ALLOWED_SUGGESTED_EXIT_NODES", "NodeA,NodeB,NodeC"), - want: []string{"NodeA", "NodeB", "NodeC"}, - }, - { - name: "Configured/StringArray/WithBlank", - key: "AllowedSuggestedExitNodes", - lookup: makeEnv("ALLOWED_SUGGESTED_EXIT_NODES", "NodeA,\t,, ,NodeB"), - want: []string{"NodeA", "NodeB"}, - }, - } - for _, tt := range tests { - t.Run(cmp.Or(tt.name, string(tt.key)), func(t *testing.T) { - oldLookupEnv := lookupEnv - t.Cleanup(func() { lookupEnv = oldLookupEnv }) - lookupEnv = tt.lookup - - var got any - var err error - var store EnvPolicyStore - switch tt.want.(type) { - case string: - got, err = store.ReadString(tt.key) - case uint64: - got, err = store.ReadUInt64(tt.key) - case bool: - got, err = store.ReadBoolean(tt.key) - case []string: - got, err = store.ReadStringArray(tt.key) - } - checkError(t, err, tt.wantErr, false) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } -} - -func checkError(tb testing.TB, got, want error, fatal bool) { - tb.Helper() - f := tb.Errorf - if fatal { - f = tb.Fatalf - } - if (want == nil && got != nil) || - (want != nil && got == nil) || - (want != nil && got != nil && !errors.Is(got, want) && want.Error() != got.Error()) { - f("gotErr: %v; wantErr: %v", got, want) - } -} diff --git a/util/syspolicy/source/policy_reader.go b/util/syspolicy/source/policy_reader.go index a1bd3147ea85e..10acee6462c97 100644 --- a/util/syspolicy/source/policy_reader.go +++ b/util/syspolicy/source/policy_reader.go @@ -12,11 +12,11 @@ import ( "sync" "time" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy/internal/loggerx" - "tailscale.com/util/syspolicy/internal/metrics" - "tailscale.com/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/syspolicy/internal/loggerx" + "github.com/sagernet/tailscale/util/syspolicy/internal/metrics" + "github.com/sagernet/tailscale/util/syspolicy/setting" ) // Reader reads all configured policy settings from a given [Store]. diff --git a/util/syspolicy/source/policy_reader_test.go b/util/syspolicy/source/policy_reader_test.go deleted file mode 100644 index 57676e67da614..0000000000000 --- a/util/syspolicy/source/policy_reader_test.go +++ /dev/null @@ -1,291 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package source - -import ( - "cmp" - "testing" - "time" - - "tailscale.com/util/must" - "tailscale.com/util/syspolicy/setting" -) - -func TestReaderLifecycle(t *testing.T) { - tests := []struct { - name string - origin *setting.Origin - definitions []*setting.Definition - wantReads []TestExpectedReads - initStrings []TestSetting[string] - initUInt64s []TestSetting[uint64] - initWant *setting.Snapshot - addStrings []TestSetting[string] - addStringLists []TestSetting[[]string] - newWant *setting.Snapshot - }{ - { - name: "read-all-settings-once", - origin: setting.NewNamedOrigin("Test", setting.DeviceScope), - definitions: []*setting.Definition{ - setting.NewDefinition("StringValue", setting.DeviceSetting, setting.StringValue), - setting.NewDefinition("IntegerValue", setting.DeviceSetting, setting.IntegerValue), - setting.NewDefinition("BooleanValue", setting.DeviceSetting, setting.BooleanValue), - setting.NewDefinition("StringListValue", setting.DeviceSetting, setting.StringListValue), - setting.NewDefinition("DurationValue", setting.DeviceSetting, setting.DurationValue), - setting.NewDefinition("PreferenceOptionValue", setting.DeviceSetting, setting.PreferenceOptionValue), - setting.NewDefinition("VisibilityValue", setting.DeviceSetting, setting.VisibilityValue), - }, - wantReads: []TestExpectedReads{ - {Key: "StringValue", Type: setting.StringValue, NumTimes: 1}, - {Key: "IntegerValue", Type: setting.IntegerValue, NumTimes: 1}, - {Key: "BooleanValue", Type: setting.BooleanValue, NumTimes: 1}, - {Key: "StringListValue", Type: setting.StringListValue, NumTimes: 1}, - {Key: "DurationValue", Type: setting.StringValue, NumTimes: 1}, // duration is string from the [Store]'s perspective - {Key: "PreferenceOptionValue", Type: setting.StringValue, NumTimes: 1}, // and so are [setting.PreferenceOption]s - {Key: "VisibilityValue", Type: setting.StringValue, NumTimes: 1}, // and [setting.Visibility] - }, - initWant: setting.NewSnapshot(nil, setting.NewNamedOrigin("Test", setting.DeviceScope)), - }, - { - name: "re-read-all-settings-when-the-policy-changes", - origin: setting.NewNamedOrigin("Test", setting.DeviceScope), - definitions: []*setting.Definition{ - setting.NewDefinition("StringValue", setting.DeviceSetting, setting.StringValue), - setting.NewDefinition("IntegerValue", setting.DeviceSetting, setting.IntegerValue), - setting.NewDefinition("BooleanValue", setting.DeviceSetting, setting.BooleanValue), - setting.NewDefinition("StringListValue", setting.DeviceSetting, setting.StringListValue), - setting.NewDefinition("DurationValue", setting.DeviceSetting, setting.DurationValue), - setting.NewDefinition("PreferenceOptionValue", setting.DeviceSetting, setting.PreferenceOptionValue), - setting.NewDefinition("VisibilityValue", setting.DeviceSetting, setting.VisibilityValue), - }, - wantReads: []TestExpectedReads{ - {Key: "StringValue", Type: setting.StringValue, NumTimes: 1}, - {Key: "IntegerValue", Type: setting.IntegerValue, NumTimes: 1}, - {Key: "BooleanValue", Type: setting.BooleanValue, NumTimes: 1}, - {Key: "StringListValue", Type: setting.StringListValue, NumTimes: 1}, - {Key: "DurationValue", Type: setting.StringValue, NumTimes: 1}, // duration is string from the [Store]'s perspective - {Key: "PreferenceOptionValue", Type: setting.StringValue, NumTimes: 1}, // and so are [setting.PreferenceOption]s - {Key: "VisibilityValue", Type: setting.StringValue, NumTimes: 1}, // and [setting.Visibility] - }, - initWant: setting.NewSnapshot(nil, setting.NewNamedOrigin("Test", setting.DeviceScope)), - addStrings: []TestSetting[string]{TestSettingOf("StringValue", "S1")}, - addStringLists: []TestSetting[[]string]{TestSettingOf("StringListValue", []string{"S1", "S2", "S3"})}, - newWant: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "StringValue": setting.RawItemWith("S1", nil, setting.NewNamedOrigin("Test", setting.DeviceScope)), - "StringListValue": setting.RawItemWith([]string{"S1", "S2", "S3"}, nil, setting.NewNamedOrigin("Test", setting.DeviceScope)), - }, setting.NewNamedOrigin("Test", setting.DeviceScope)), - }, - { - name: "read-settings-if-in-scope/device", - origin: setting.NewNamedOrigin("Test", setting.DeviceScope), - definitions: []*setting.Definition{ - setting.NewDefinition("DeviceSetting", setting.DeviceSetting, setting.StringValue), - setting.NewDefinition("ProfileSetting", setting.ProfileSetting, setting.IntegerValue), - setting.NewDefinition("UserSetting", setting.UserSetting, setting.BooleanValue), - }, - wantReads: []TestExpectedReads{ - {Key: "DeviceSetting", Type: setting.StringValue, NumTimes: 1}, - {Key: "ProfileSetting", Type: setting.IntegerValue, NumTimes: 1}, - {Key: "UserSetting", Type: setting.BooleanValue, NumTimes: 1}, - }, - }, - { - name: "read-settings-if-in-scope/profile", - origin: setting.NewNamedOrigin("Test", setting.CurrentProfileScope), - definitions: []*setting.Definition{ - setting.NewDefinition("DeviceSetting", setting.DeviceSetting, setting.StringValue), - setting.NewDefinition("ProfileSetting", setting.ProfileSetting, setting.IntegerValue), - setting.NewDefinition("UserSetting", setting.UserSetting, setting.BooleanValue), - }, - wantReads: []TestExpectedReads{ - // Device settings cannot be configured at the profile scope and should not be read. - {Key: "ProfileSetting", Type: setting.IntegerValue, NumTimes: 1}, - {Key: "UserSetting", Type: setting.BooleanValue, NumTimes: 1}, - }, - }, - { - name: "read-settings-if-in-scope/user", - origin: setting.NewNamedOrigin("Test", setting.CurrentUserScope), - definitions: []*setting.Definition{ - setting.NewDefinition("DeviceSetting", setting.DeviceSetting, setting.StringValue), - setting.NewDefinition("ProfileSetting", setting.ProfileSetting, setting.IntegerValue), - setting.NewDefinition("UserSetting", setting.UserSetting, setting.BooleanValue), - }, - wantReads: []TestExpectedReads{ - // Device and profile settings cannot be configured at the profile scope and should not be read. - {Key: "UserSetting", Type: setting.BooleanValue, NumTimes: 1}, - }, - }, - { - name: "read-stringy-settings", - origin: setting.NewNamedOrigin("Test", setting.DeviceScope), - definitions: []*setting.Definition{ - setting.NewDefinition("DurationValue", setting.DeviceSetting, setting.DurationValue), - setting.NewDefinition("PreferenceOptionValue", setting.DeviceSetting, setting.PreferenceOptionValue), - setting.NewDefinition("VisibilityValue", setting.DeviceSetting, setting.VisibilityValue), - }, - wantReads: []TestExpectedReads{ - {Key: "DurationValue", Type: setting.StringValue, NumTimes: 1}, // duration is string from the [Store]'s perspective - {Key: "PreferenceOptionValue", Type: setting.StringValue, NumTimes: 1}, // and so are [setting.PreferenceOption]s - {Key: "VisibilityValue", Type: setting.StringValue, NumTimes: 1}, // and [setting.Visibility] - }, - initStrings: []TestSetting[string]{ - TestSettingOf("DurationValue", "2h30m"), - TestSettingOf("PreferenceOptionValue", "always"), - TestSettingOf("VisibilityValue", "show"), - }, - initWant: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "DurationValue": setting.RawItemWith(must.Get(time.ParseDuration("2h30m")), nil, setting.NewNamedOrigin("Test", setting.DeviceScope)), - "PreferenceOptionValue": setting.RawItemWith(setting.AlwaysByPolicy, nil, setting.NewNamedOrigin("Test", setting.DeviceScope)), - "VisibilityValue": setting.RawItemWith(setting.VisibleByPolicy, nil, setting.NewNamedOrigin("Test", setting.DeviceScope)), - }, setting.NewNamedOrigin("Test", setting.DeviceScope)), - }, - { - name: "read-erroneous-stringy-settings", - origin: setting.NewNamedOrigin("Test", setting.CurrentUserScope), - definitions: []*setting.Definition{ - setting.NewDefinition("DurationValue1", setting.UserSetting, setting.DurationValue), - setting.NewDefinition("DurationValue2", setting.UserSetting, setting.DurationValue), - setting.NewDefinition("PreferenceOptionValue", setting.UserSetting, setting.PreferenceOptionValue), - setting.NewDefinition("VisibilityValue", setting.UserSetting, setting.VisibilityValue), - }, - wantReads: []TestExpectedReads{ - {Key: "DurationValue1", Type: setting.StringValue, NumTimes: 1}, // duration is string from the [Store]'s perspective - {Key: "DurationValue2", Type: setting.StringValue, NumTimes: 1}, // duration is string from the [Store]'s perspective - {Key: "PreferenceOptionValue", Type: setting.StringValue, NumTimes: 1}, // and so are [setting.PreferenceOption]s - {Key: "VisibilityValue", Type: setting.StringValue, NumTimes: 1}, // and [setting.Visibility] - }, - initStrings: []TestSetting[string]{ - TestSettingOf("DurationValue1", "soon"), - TestSettingWithError[string]("DurationValue2", setting.NewErrorText("bang!")), - TestSettingOf("PreferenceOptionValue", "sometimes"), - }, - initUInt64s: []TestSetting[uint64]{ - TestSettingOf[uint64]("VisibilityValue", 42), // type mismatch - }, - initWant: setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "DurationValue1": setting.RawItemWith(nil, setting.NewErrorText("time: invalid duration \"soon\""), setting.NewNamedOrigin("Test", setting.CurrentUserScope)), - "DurationValue2": setting.RawItemWith(nil, setting.NewErrorText("bang!"), setting.NewNamedOrigin("Test", setting.CurrentUserScope)), - "PreferenceOptionValue": setting.RawItemWith(setting.ShowChoiceByPolicy, nil, setting.NewNamedOrigin("Test", setting.CurrentUserScope)), - "VisibilityValue": setting.RawItemWith(setting.VisibleByPolicy, setting.NewErrorText("type mismatch in ReadString: got uint64"), setting.NewNamedOrigin("Test", setting.CurrentUserScope)), - }, setting.NewNamedOrigin("Test", setting.CurrentUserScope)), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - setting.SetDefinitionsForTest(t, tt.definitions...) - store := NewTestStore(t) - store.SetStrings(tt.initStrings...) - store.SetUInt64s(tt.initUInt64s...) - - reader, err := newReader(store, tt.origin) - if err != nil { - t.Fatalf("newReader failed: %v", err) - } - - if got := reader.GetSettings(); tt.initWant != nil && !got.Equal(tt.initWant) { - t.Errorf("Settings do not match: got %v, want %v", got, tt.initWant) - } - if tt.wantReads != nil { - store.ReadsMustEqual(tt.wantReads...) - } - - // Should not result in new reads as there were no changes. - N := 100 - for range N { - reader.GetSettings() - } - if tt.wantReads != nil { - store.ReadsMustEqual(tt.wantReads...) - } - store.ResetCounters() - - got, err := reader.ReadSettings() - if err != nil { - t.Fatalf("ReadSettings failed: %v", err) - } - - if tt.initWant != nil && !got.Equal(tt.initWant) { - t.Errorf("Settings do not match: got %v, want %v", got, tt.initWant) - } - - if tt.wantReads != nil { - store.ReadsMustEqual(tt.wantReads...) - } - store.ResetCounters() - - if len(tt.addStrings) != 0 || len(tt.addStringLists) != 0 { - store.SetStrings(tt.addStrings...) - store.SetStringLists(tt.addStringLists...) - - // As the settings have changed, GetSettings needs to re-read them. - if got, want := reader.GetSettings(), cmp.Or(tt.newWant, tt.initWant); !got.Equal(want) { - t.Errorf("New Settings do not match: got %v, want %v", got, want) - } - if tt.wantReads != nil { - store.ReadsMustEqual(tt.wantReads...) - } - } - - select { - case <-reader.Done(): - t.Fatalf("the reader is closed") - default: - } - - store.Close() - - <-reader.Done() - }) - } -} - -func TestReadingSession(t *testing.T) { - setting.SetDefinitionsForTest(t, setting.NewDefinition("StringValue", setting.DeviceSetting, setting.StringValue)) - store := NewTestStore(t) - - origin := setting.NewOrigin(setting.DeviceScope) - reader, err := newReader(store, origin) - if err != nil { - t.Fatalf("newReader failed: %v", err) - } - session, err := reader.OpenSession() - if err != nil { - t.Fatalf("failed to open a reading session: %v", err) - } - t.Cleanup(session.Close) - - if got, want := session.GetSettings(), setting.NewSnapshot(nil, origin); !got.Equal(want) { - t.Errorf("Settings do not match: got %v, want %v", got, want) - } - - select { - case _, ok := <-session.PolicyChanged(): - if ok { - t.Fatalf("the policy changed notification was sent prematurely") - } else { - t.Fatalf("the session was closed prematurely") - } - default: - } - - store.SetStrings(TestSettingOf("StringValue", "S1")) - _, ok := <-session.PolicyChanged() - if !ok { - t.Fatalf("the session was closed prematurely") - } - - want := setting.NewSnapshot(map[setting.Key]setting.RawItem{ - "StringValue": setting.RawItemWith("S1", nil, origin), - }, origin) - if got := session.GetSettings(); !got.Equal(want) { - t.Errorf("Settings do not match: got %v, want %v", got, want) - } - - store.Close() - if _, ok = <-session.PolicyChanged(); ok { - t.Fatalf("the session must be closed") - } -} diff --git a/util/syspolicy/source/policy_source.go b/util/syspolicy/source/policy_source.go index 7f2821b596e62..683ce51c8d766 100644 --- a/util/syspolicy/source/policy_source.go +++ b/util/syspolicy/source/policy_source.go @@ -12,8 +12,8 @@ import ( "fmt" "io" - "tailscale.com/types/lazy" - "tailscale.com/util/syspolicy/setting" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/util/syspolicy/setting" ) // ErrStoreClosed is an error returned when attempting to use a [Store] after it diff --git a/util/syspolicy/source/policy_store_windows.go b/util/syspolicy/source/policy_store_windows.go index 86e2254e0a381..7fc348cd8a626 100644 --- a/util/syspolicy/source/policy_store_windows.go +++ b/util/syspolicy/source/policy_store_windows.go @@ -9,11 +9,11 @@ import ( "strings" "sync" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/winutil/gp" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/winutil/gp" ) const ( diff --git a/util/syspolicy/source/policy_store_windows_test.go b/util/syspolicy/source/policy_store_windows_test.go deleted file mode 100644 index 33f85dc0b2b7e..0000000000000 --- a/util/syspolicy/source/policy_store_windows_test.go +++ /dev/null @@ -1,398 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package source - -import ( - "errors" - "fmt" - "reflect" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/registry" - "tailscale.com/tstest" - "tailscale.com/util/cibuild" - "tailscale.com/util/mak" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/gp" -) - -// subkeyStrings is a test type indicating that a string slice should be written -// to the registry as multiple REG_SZ values under the setting's key, -// rather than as a single REG_MULTI_SZ value under the group key. -// This is the same format as ADMX use for string lists. -type subkeyStrings []string - -type testPolicyValue struct { - name setting.Key - value any -} - -func TestLockUnlockPolicyStore(t *testing.T) { - // Make sure we don't leak goroutines - tstest.ResourceCheck(t) - - store, err := NewMachinePlatformPolicyStore() - if err != nil { - t.Fatalf("NewMachinePolicyStore failed: %v", err) - } - - t.Run("One-Goroutine", func(t *testing.T) { - if err := store.Lock(); err != nil { - t.Errorf("store.Lock(): got %v; want nil", err) - return - } - if v, err := store.ReadString("NonExistingPolicySetting"); err == nil || !errors.Is(err, setting.ErrNotConfigured) { - t.Errorf(`ReadString: got %v, %v; want "", %v`, v, err, setting.ErrNotConfigured) - } - store.Unlock() - }) - - // Lock the store N times from different goroutines. - const N = 100 - var unlocked atomic.Int32 - t.Run("N-Goroutines", func(t *testing.T) { - var wg sync.WaitGroup - wg.Add(N) - for range N { - go func() { - if err := store.Lock(); err != nil { - t.Errorf("store.Lock(): got %v; want nil", err) - return - } - if v, err := store.ReadString("NonExistingPolicySetting"); err == nil || !errors.Is(err, setting.ErrNotConfigured) { - t.Errorf(`ReadString: got %v, %v; want "", %v`, v, err, setting.ErrNotConfigured) - } - wg.Done() - time.Sleep(10 * time.Millisecond) - unlocked.Add(1) - store.Unlock() - }() - } - - // Wait until the store is locked N times. - wg.Wait() - }) - - // Close the store. The call should wait for all held locks to be released. - if err := store.Close(); err != nil { - t.Fatalf("(*PolicyStore).Close failed: %v", err) - } - if locked := unlocked.Load(); locked != N { - t.Errorf("locked.Load(): got %v; want %v", locked, N) - } - - // Any further attempts to lock it should fail. - if err = store.Lock(); err == nil || !errors.Is(err, ErrStoreClosed) { - t.Errorf("store.Lock(): got %v; want %v", err, ErrStoreClosed) - } -} - -func TestReadPolicyStore(t *testing.T) { - if !winutil.IsCurrentProcessElevated() { - t.Skipf("test requires running as elevated user") - } - tests := []struct { - name setting.Key - newValue any - legacyValue any - want any - }{ - {name: "LegacyPolicy", legacyValue: "LegacyValue", want: "LegacyValue"}, - {name: "StringPolicy", legacyValue: "LegacyValue", newValue: "Value", want: "Value"}, - {name: "StringPolicy_Empty", legacyValue: "LegacyValue", newValue: "", want: ""}, - {name: "BoolPolicy_True", newValue: true, want: true}, - {name: "BoolPolicy_False", newValue: false, want: false}, - {name: "UIntPolicy_1", newValue: uint32(10), want: uint64(10)}, // uint32 values should be returned as uint64 - {name: "UIntPolicy_2", newValue: uint64(1 << 37), want: uint64(1 << 37)}, - {name: "StringListPolicy", newValue: []string{"Value1", "Value2"}, want: []string{"Value1", "Value2"}}, - {name: "StringListPolicy_Empty", newValue: []string{}, want: []string{}}, - {name: "StringListPolicy_SubKey", newValue: subkeyStrings{"Value1", "Value2"}, want: []string{"Value1", "Value2"}}, - {name: "StringListPolicy_SubKey_Empty", newValue: subkeyStrings{}, want: []string{}}, - } - - runTests := func(t *testing.T, userStore bool, token windows.Token) { - var hive registry.Key - if userStore { - hive = registry.CURRENT_USER - } else { - hive = registry.LOCAL_MACHINE - } - - // Write policy values to the registry. - newValues := make([]testPolicyValue, 0, len(tests)) - for _, tt := range tests { - if tt.newValue != nil { - newValues = append(newValues, testPolicyValue{name: tt.name, value: tt.newValue}) - } - } - policiesKeyName := softwareKeyName + `\` + tsPoliciesSubkey - cleanup, err := createTestPolicyValues(hive, policiesKeyName, newValues) - if err != nil { - t.Fatalf("createTestPolicyValues failed: %v", err) - } - t.Cleanup(cleanup) - - // Write legacy policy values to the registry. - legacyValues := make([]testPolicyValue, 0, len(tests)) - for _, tt := range tests { - if tt.legacyValue != nil { - legacyValues = append(legacyValues, testPolicyValue{name: tt.name, value: tt.legacyValue}) - } - } - legacyKeyName := softwareKeyName + `\` + tsIPNSubkey - cleanup, err = createTestPolicyValues(hive, legacyKeyName, legacyValues) - if err != nil { - t.Fatalf("createTestPolicyValues failed: %v", err) - } - t.Cleanup(cleanup) - - var store *PlatformPolicyStore - if userStore { - store, err = NewUserPlatformPolicyStore(token) - } else { - store, err = NewMachinePlatformPolicyStore() - } - if err != nil { - t.Fatalf("NewXPolicyStore failed: %v", err) - } - t.Cleanup(func() { - if err := store.Close(); err != nil { - t.Errorf("(*PolicyStore).Close failed: %v", err) - } - }) - - // testReadValues checks that [PolicyStore] returns the same values we wrote directly to the registry. - testReadValues := func(t *testing.T, withLocks bool) { - for _, tt := range tests { - t.Run(string(tt.name), func(t *testing.T) { - if userStore && tt.newValue == nil { - t.Skip("there is no legacy policies for users") - } - - t.Parallel() - - if withLocks { - if err := store.Lock(); err != nil { - t.Errorf("failed to acquire the lock: %v", err) - } - defer store.Unlock() - } - - var got any - var err error - switch tt.want.(type) { - case string: - got, err = store.ReadString(tt.name) - case uint64: - got, err = store.ReadUInt64(tt.name) - case bool: - got, err = store.ReadBoolean(tt.name) - case []string: - got, err = store.ReadStringArray(tt.name) - } - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } - } - t.Run("NoLock", func(t *testing.T) { - testReadValues(t, false) - }) - - t.Run("WithLock", func(t *testing.T) { - testReadValues(t, true) - }) - } - - t.Run("MachineStore", func(t *testing.T) { - runTests(t, false, 0) - }) - - t.Run("CurrentUserStore", func(t *testing.T) { - runTests(t, true, 0) - }) - - t.Run("UserStoreWithToken", func(t *testing.T) { - var token windows.Token - if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { - t.Fatalf("OpenProcessToken: %v", err) - } - defer token.Close() - runTests(t, true, token) - }) -} - -func TestPolicyStoreChangeNotifications(t *testing.T) { - if cibuild.On() { - t.Skipf("test requires running on a real Windows environment") - } - store, err := NewMachinePlatformPolicyStore() - if err != nil { - t.Fatalf("NewMachinePolicyStore failed: %v", err) - } - t.Cleanup(func() { - if err := store.Close(); err != nil { - t.Errorf("(*PolicyStore).Close failed: %v", err) - } - }) - - done := make(chan struct{}) - unregister, err := store.RegisterChangeCallback(func() { close(done) }) - if err != nil { - t.Fatalf("RegisterChangeCallback failed: %v", err) - } - t.Cleanup(unregister) - - // RefreshMachinePolicy is a non-blocking call. - if err := gp.RefreshMachinePolicy(true); err != nil { - t.Fatalf("RefreshMachinePolicy failed: %v", err) - } - - // We should receive a policy change notification when - // the Group Policy service completes policy processing. - // Otherwise, the test will eventually time out. - <-done -} - -func TestSplitSettingKey(t *testing.T) { - tests := []struct { - name string - key setting.Key - wantPath string - wantValue string - }{ - { - name: "empty", - key: "", - wantPath: ``, - wantValue: "", - }, - { - name: "explicit-empty-path", - key: "/ValueName", - wantPath: ``, - wantValue: "ValueName", - }, - { - name: "empty-value", - key: "Root/Sub/", - wantPath: `Root\Sub`, - wantValue: "", - }, - { - name: "with-path", - key: "Root/Sub/ValueName", - wantPath: `Root\Sub`, - wantValue: "ValueName", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotPath, gotValue := splitSettingKey(tt.key) - if gotPath != tt.wantPath { - t.Errorf("Path: got %q, want %q", gotPath, tt.wantPath) - } - if gotValue != tt.wantValue { - t.Errorf("Value: got %q, want %q", gotValue, tt.wantPath) - } - }) - } -} - -func createTestPolicyValues(hive registry.Key, keyName string, values []testPolicyValue) (cleanup func(), err error) { - key, existing, err := registry.CreateKey(hive, keyName, registry.ALL_ACCESS) - if err != nil { - return nil, err - } - var valuesToDelete map[string][]string - doCleanup := func() { - for path, values := range valuesToDelete { - if len(values) == 0 { - registry.DeleteKey(key, path) - continue - } - key, err := registry.OpenKey(key, path, windows.KEY_ALL_ACCESS) - if err != nil { - continue - } - defer key.Close() - for _, value := range values { - key.DeleteValue(value) - } - } - - key.Close() - if !existing { - registry.DeleteKey(hive, keyName) - } - } - defer func() { - if err != nil { - doCleanup() - } - }() - - for _, v := range values { - key, existing := key, existing - path, valueName := splitSettingKey(v.name) - if path != "" { - if key, existing, err = registry.CreateKey(key, valueName, windows.KEY_ALL_ACCESS); err != nil { - return nil, err - } - defer key.Close() - } - if values, ok := valuesToDelete[path]; len(values) > 0 || (!ok && existing) { - values = append(values, valueName) - mak.Set(&valuesToDelete, path, values) - } else if !ok { - mak.Set(&valuesToDelete, path, nil) - } - - switch value := v.value.(type) { - case string: - err = key.SetStringValue(valueName, value) - case uint32: - err = key.SetDWordValue(valueName, value) - case uint64: - err = key.SetQWordValue(valueName, value) - case bool: - if value { - err = key.SetDWordValue(valueName, 1) - } else { - err = key.SetDWordValue(valueName, 0) - } - case []string: - err = key.SetStringsValue(valueName, value) - case subkeyStrings: - key, _, err := registry.CreateKey(key, valueName, windows.KEY_ALL_ACCESS) - if err != nil { - return nil, err - } - defer key.Close() - mak.Set(&valuesToDelete, strings.Trim(path+`\`+valueName, `\`), nil) - for i, value := range value { - if err := key.SetStringValue(strconv.Itoa(i), value); err != nil { - return nil, err - } - } - default: - err = fmt.Errorf("unsupported value: %v (%T), name: %q", value, value, v.name) - } - if err != nil { - return nil, err - } - } - return doCleanup, nil -} diff --git a/util/syspolicy/source/test_store.go b/util/syspolicy/source/test_store.go index 1f19bbb4386b9..2ef2e4ce48f40 100644 --- a/util/syspolicy/source/test_store.go +++ b/util/syspolicy/source/test_store.go @@ -8,11 +8,11 @@ import ( "sync" "sync/atomic" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/syspolicy/setting" xmaps "golang.org/x/exp/maps" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/setting" ) var ( diff --git a/util/syspolicy/syspolicy.go b/util/syspolicy/syspolicy.go index d925731c38b3a..f142a25d89bdf 100644 --- a/util/syspolicy/syspolicy.go +++ b/util/syspolicy/syspolicy.go @@ -16,10 +16,10 @@ import ( "reflect" "time" - "tailscale.com/util/syspolicy/internal/loggerx" - "tailscale.com/util/syspolicy/rsop" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/syspolicy/source" + "github.com/sagernet/tailscale/util/syspolicy/internal/loggerx" + "github.com/sagernet/tailscale/util/syspolicy/rsop" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/syspolicy/source" ) var ( diff --git a/util/syspolicy/syspolicy_test.go b/util/syspolicy/syspolicy_test.go deleted file mode 100644 index a70a49d395c22..0000000000000 --- a/util/syspolicy/syspolicy_test.go +++ /dev/null @@ -1,660 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package syspolicy - -import ( - "errors" - "slices" - "testing" - "time" - - "tailscale.com/types/logger" - "tailscale.com/util/syspolicy/internal/loggerx" - "tailscale.com/util/syspolicy/internal/metrics" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/syspolicy/source" -) - -var someOtherError = errors.New("error other than not found") - -func TestGetString(t *testing.T) { - tests := []struct { - name string - key Key - handlerValue string - handlerError error - defaultValue string - wantValue string - wantError error - wantMetrics []metrics.TestState - }{ - { - name: "read existing value", - key: AdminConsoleVisibility, - handlerValue: "hide", - wantValue: "hide", - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_AdminConsole", Value: 1}, - }, - }, - { - name: "read non-existing value", - key: EnableServerMode, - handlerError: ErrNotConfigured, - wantError: nil, - }, - { - name: "read non-existing value, non-blank default", - key: EnableServerMode, - handlerError: ErrNotConfigured, - defaultValue: "test", - wantValue: "test", - wantError: nil, - }, - { - name: "reading value returns other error", - key: NetworkDevicesVisibility, - handlerError: someOtherError, - wantError: someOtherError, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_errors", Value: 1}, - {Name: "$os_syspolicy_NetworkDevices_error", Value: 1}, - }, - }, - } - - RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := metrics.NewTestHandler(t) - metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric) - - s := source.TestSetting[string]{ - Key: tt.key, - Value: tt.handlerValue, - Error: tt.handlerError, - } - registerSingleSettingStoreForTest(t, s) - - value, err := GetString(tt.key, tt.defaultValue) - if !errorsMatchForTest(err, tt.wantError) { - t.Errorf("err=%q, want %q", err, tt.wantError) - } - if value != tt.wantValue { - t.Errorf("value=%v, want %v", value, tt.wantValue) - } - wantMetrics := tt.wantMetrics - if !metrics.ShouldReport() { - // Check that metrics are not reported on platforms - // where they shouldn't be reported. - // As of 2024-09-04, syspolicy only reports metrics - // on Windows and Android. - wantMetrics = nil - } - h.MustEqual(wantMetrics...) - }) - } -} - -func TestGetUint64(t *testing.T) { - tests := []struct { - name string - key Key - handlerValue uint64 - handlerError error - defaultValue uint64 - wantValue uint64 - wantError error - }{ - { - name: "read existing value", - key: LogSCMInteractions, - handlerValue: 1, - wantValue: 1, - }, - { - name: "read non-existing value", - key: LogSCMInteractions, - handlerValue: 0, - handlerError: ErrNotConfigured, - wantValue: 0, - }, - { - name: "read non-existing value, non-zero default", - key: LogSCMInteractions, - defaultValue: 2, - handlerError: ErrNotConfigured, - wantValue: 2, - }, - { - name: "reading value returns other error", - key: FlushDNSOnSessionUnlock, - handlerError: someOtherError, - wantError: someOtherError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // None of the policy settings tested here are integers. - // In fact, we don't have any integer policies as of 2024-10-08. - // However, we can register each of them as an integer policy setting - // for the duration of the test, providing us with something to test against. - if err := setting.SetDefinitionsForTest(t, setting.NewDefinition(tt.key, setting.DeviceSetting, setting.IntegerValue)); err != nil { - t.Fatalf("SetDefinitionsForTest failed: %v", err) - } - - s := source.TestSetting[uint64]{ - Key: tt.key, - Value: tt.handlerValue, - Error: tt.handlerError, - } - registerSingleSettingStoreForTest(t, s) - - value, err := GetUint64(tt.key, tt.defaultValue) - if !errorsMatchForTest(err, tt.wantError) { - t.Errorf("err=%q, want %q", err, tt.wantError) - } - if value != tt.wantValue { - t.Errorf("value=%v, want %v", value, tt.wantValue) - } - }) - } -} - -func TestGetBoolean(t *testing.T) { - tests := []struct { - name string - key Key - handlerValue bool - handlerError error - defaultValue bool - wantValue bool - wantError error - wantMetrics []metrics.TestState - }{ - { - name: "read existing value", - key: FlushDNSOnSessionUnlock, - handlerValue: true, - wantValue: true, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_FlushDNSOnSessionUnlock", Value: 1}, - }, - }, - { - name: "read non-existing value", - key: LogSCMInteractions, - handlerValue: false, - handlerError: ErrNotConfigured, - wantValue: false, - }, - { - name: "reading value returns other error", - key: FlushDNSOnSessionUnlock, - handlerError: someOtherError, - wantError: someOtherError, // expect error... - defaultValue: true, - wantValue: true, // ...AND default value if the handler fails. - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_errors", Value: 1}, - {Name: "$os_syspolicy_FlushDNSOnSessionUnlock_error", Value: 1}, - }, - }, - } - - RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := metrics.NewTestHandler(t) - metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric) - - s := source.TestSetting[bool]{ - Key: tt.key, - Value: tt.handlerValue, - Error: tt.handlerError, - } - registerSingleSettingStoreForTest(t, s) - - value, err := GetBoolean(tt.key, tt.defaultValue) - if !errorsMatchForTest(err, tt.wantError) { - t.Errorf("err=%q, want %q", err, tt.wantError) - } - if value != tt.wantValue { - t.Errorf("value=%v, want %v", value, tt.wantValue) - } - wantMetrics := tt.wantMetrics - if !metrics.ShouldReport() { - // Check that metrics are not reported on platforms - // where they shouldn't be reported. - // As of 2024-09-04, syspolicy only reports metrics - // on Windows and Android. - wantMetrics = nil - } - h.MustEqual(wantMetrics...) - }) - } -} - -func TestGetPreferenceOption(t *testing.T) { - tests := []struct { - name string - key Key - handlerValue string - handlerError error - wantValue setting.PreferenceOption - wantError error - wantMetrics []metrics.TestState - }{ - { - name: "always by policy", - key: EnableIncomingConnections, - handlerValue: "always", - wantValue: setting.AlwaysByPolicy, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_AllowIncomingConnections", Value: 1}, - }, - }, - { - name: "never by policy", - key: EnableIncomingConnections, - handlerValue: "never", - wantValue: setting.NeverByPolicy, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_AllowIncomingConnections", Value: 1}, - }, - }, - { - name: "use default", - key: EnableIncomingConnections, - handlerValue: "", - wantValue: setting.ShowChoiceByPolicy, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_AllowIncomingConnections", Value: 1}, - }, - }, - { - name: "read non-existing value", - key: EnableIncomingConnections, - handlerError: ErrNotConfigured, - wantValue: setting.ShowChoiceByPolicy, - }, - { - name: "other error is returned", - key: EnableIncomingConnections, - handlerError: someOtherError, - wantValue: setting.ShowChoiceByPolicy, - wantError: someOtherError, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_errors", Value: 1}, - {Name: "$os_syspolicy_AllowIncomingConnections_error", Value: 1}, - }, - }, - } - - RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := metrics.NewTestHandler(t) - metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric) - - s := source.TestSetting[string]{ - Key: tt.key, - Value: tt.handlerValue, - Error: tt.handlerError, - } - registerSingleSettingStoreForTest(t, s) - - option, err := GetPreferenceOption(tt.key) - if !errorsMatchForTest(err, tt.wantError) { - t.Errorf("err=%q, want %q", err, tt.wantError) - } - if option != tt.wantValue { - t.Errorf("option=%v, want %v", option, tt.wantValue) - } - wantMetrics := tt.wantMetrics - if !metrics.ShouldReport() { - // Check that metrics are not reported on platforms - // where they shouldn't be reported. - // As of 2024-09-04, syspolicy only reports metrics - // on Windows and Android. - wantMetrics = nil - } - h.MustEqual(wantMetrics...) - }) - } -} - -func TestGetVisibility(t *testing.T) { - tests := []struct { - name string - key Key - handlerValue string - handlerError error - wantValue setting.Visibility - wantError error - wantMetrics []metrics.TestState - }{ - { - name: "hidden by policy", - key: AdminConsoleVisibility, - handlerValue: "hide", - wantValue: setting.HiddenByPolicy, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_AdminConsole", Value: 1}, - }, - }, - { - name: "visibility default", - key: AdminConsoleVisibility, - handlerValue: "show", - wantValue: setting.VisibleByPolicy, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_AdminConsole", Value: 1}, - }, - }, - { - name: "read non-existing value", - key: AdminConsoleVisibility, - handlerValue: "show", - handlerError: ErrNotConfigured, - wantValue: setting.VisibleByPolicy, - }, - { - name: "other error is returned", - key: AdminConsoleVisibility, - handlerValue: "show", - handlerError: someOtherError, - wantValue: setting.VisibleByPolicy, - wantError: someOtherError, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_errors", Value: 1}, - {Name: "$os_syspolicy_AdminConsole_error", Value: 1}, - }, - }, - } - - RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := metrics.NewTestHandler(t) - metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric) - - s := source.TestSetting[string]{ - Key: tt.key, - Value: tt.handlerValue, - Error: tt.handlerError, - } - registerSingleSettingStoreForTest(t, s) - - visibility, err := GetVisibility(tt.key) - if !errorsMatchForTest(err, tt.wantError) { - t.Errorf("err=%q, want %q", err, tt.wantError) - } - if visibility != tt.wantValue { - t.Errorf("visibility=%v, want %v", visibility, tt.wantValue) - } - wantMetrics := tt.wantMetrics - if !metrics.ShouldReport() { - // Check that metrics are not reported on platforms - // where they shouldn't be reported. - // As of 2024-09-04, syspolicy only reports metrics - // on Windows and Android. - wantMetrics = nil - } - h.MustEqual(wantMetrics...) - }) - } -} - -func TestGetDuration(t *testing.T) { - tests := []struct { - name string - key Key - handlerValue string - handlerError error - defaultValue time.Duration - wantValue time.Duration - wantError error - wantMetrics []metrics.TestState - }{ - { - name: "read existing value", - key: KeyExpirationNoticeTime, - handlerValue: "2h", - wantValue: 2 * time.Hour, - defaultValue: 24 * time.Hour, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_KeyExpirationNotice", Value: 1}, - }, - }, - { - name: "invalid duration value", - key: KeyExpirationNoticeTime, - handlerValue: "-20", - wantValue: 24 * time.Hour, - wantError: errors.New(`time: missing unit in duration "-20"`), - defaultValue: 24 * time.Hour, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_errors", Value: 1}, - {Name: "$os_syspolicy_KeyExpirationNotice_error", Value: 1}, - }, - }, - { - name: "read non-existing value", - key: KeyExpirationNoticeTime, - handlerError: ErrNotConfigured, - wantValue: 24 * time.Hour, - defaultValue: 24 * time.Hour, - }, - { - name: "read non-existing value different default", - key: KeyExpirationNoticeTime, - handlerError: ErrNotConfigured, - wantValue: 0 * time.Second, - defaultValue: 0 * time.Second, - }, - { - name: "other error is returned", - key: KeyExpirationNoticeTime, - handlerError: someOtherError, - wantValue: 24 * time.Hour, - wantError: someOtherError, - defaultValue: 24 * time.Hour, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_errors", Value: 1}, - {Name: "$os_syspolicy_KeyExpirationNotice_error", Value: 1}, - }, - }, - } - - RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := metrics.NewTestHandler(t) - metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric) - - s := source.TestSetting[string]{ - Key: tt.key, - Value: tt.handlerValue, - Error: tt.handlerError, - } - registerSingleSettingStoreForTest(t, s) - - duration, err := GetDuration(tt.key, tt.defaultValue) - if !errorsMatchForTest(err, tt.wantError) { - t.Errorf("err=%q, want %q", err, tt.wantError) - } - if duration != tt.wantValue { - t.Errorf("duration=%v, want %v", duration, tt.wantValue) - } - wantMetrics := tt.wantMetrics - if !metrics.ShouldReport() { - // Check that metrics are not reported on platforms - // where they shouldn't be reported. - // As of 2024-09-04, syspolicy only reports metrics - // on Windows and Android. - wantMetrics = nil - } - h.MustEqual(wantMetrics...) - }) - } -} - -func TestGetStringArray(t *testing.T) { - tests := []struct { - name string - key Key - handlerValue []string - handlerError error - defaultValue []string - wantValue []string - wantError error - wantMetrics []metrics.TestState - }{ - { - name: "read existing value", - key: AllowedSuggestedExitNodes, - handlerValue: []string{"foo", "bar"}, - wantValue: []string{"foo", "bar"}, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_any", Value: 1}, - {Name: "$os_syspolicy_AllowedSuggestedExitNodes", Value: 1}, - }, - }, - { - name: "read non-existing value", - key: AllowedSuggestedExitNodes, - handlerError: ErrNotConfigured, - wantError: nil, - }, - { - name: "read non-existing value, non nil default", - key: AllowedSuggestedExitNodes, - handlerError: ErrNotConfigured, - defaultValue: []string{"foo", "bar"}, - wantValue: []string{"foo", "bar"}, - wantError: nil, - }, - { - name: "reading value returns other error", - key: AllowedSuggestedExitNodes, - handlerError: someOtherError, - wantError: someOtherError, - wantMetrics: []metrics.TestState{ - {Name: "$os_syspolicy_errors", Value: 1}, - {Name: "$os_syspolicy_AllowedSuggestedExitNodes_error", Value: 1}, - }, - }, - } - - RegisterWellKnownSettingsForTest(t) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - h := metrics.NewTestHandler(t) - metrics.SetHooksForTest(t, h.AddMetric, h.SetMetric) - - s := source.TestSetting[[]string]{ - Key: tt.key, - Value: tt.handlerValue, - Error: tt.handlerError, - } - registerSingleSettingStoreForTest(t, s) - - value, err := GetStringArray(tt.key, tt.defaultValue) - if !errorsMatchForTest(err, tt.wantError) { - t.Errorf("err=%q, want %q", err, tt.wantError) - } - if !slices.Equal(tt.wantValue, value) { - t.Errorf("value=%v, want %v", value, tt.wantValue) - } - wantMetrics := tt.wantMetrics - if !metrics.ShouldReport() { - // Check that metrics are not reported on platforms - // where they shouldn't be reported. - // As of 2024-09-04, syspolicy only reports metrics - // on Windows and Android. - wantMetrics = nil - } - h.MustEqual(wantMetrics...) - }) - } -} - -func registerSingleSettingStoreForTest[T source.TestValueType](tb TB, s source.TestSetting[T]) { - policyStore := source.NewTestStoreOf(tb, s) - MustRegisterStoreForTest(tb, "TestStore", setting.DeviceScope, policyStore) -} - -func BenchmarkGetString(b *testing.B) { - loggerx.SetForTest(b, logger.Discard, logger.Discard) - RegisterWellKnownSettingsForTest(b) - - wantControlURL := "https://login.tailscale.com" - registerSingleSettingStoreForTest(b, source.TestSettingOf(ControlURL, wantControlURL)) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - gotControlURL, _ := GetString(ControlURL, "https://controlplane.tailscale.com") - if gotControlURL != wantControlURL { - b.Fatalf("got %v; want %v", gotControlURL, wantControlURL) - } - } -} - -func TestSelectControlURL(t *testing.T) { - tests := []struct { - reg, disk, want string - }{ - // Modern default case. - {"", "", "https://controlplane.tailscale.com"}, - - // For a user who installed prior to Dec 2020, with - // stuff in their registry. - {"https://login.tailscale.com", "", "https://login.tailscale.com"}, - - // Ignore pre-Dec'20 LoginURL from installer if prefs - // prefs overridden manually to an on-prem control - // server. - {"https://login.tailscale.com", "http://on-prem", "http://on-prem"}, - - // Something unknown explicitly set in the registry always wins. - {"http://explicit-reg", "", "http://explicit-reg"}, - {"http://explicit-reg", "http://on-prem", "http://explicit-reg"}, - {"http://explicit-reg", "https://login.tailscale.com", "http://explicit-reg"}, - {"http://explicit-reg", "https://controlplane.tailscale.com", "http://explicit-reg"}, - - // If nothing in the registry, disk wins. - {"", "http://on-prem", "http://on-prem"}, - } - for _, tt := range tests { - if got := SelectControlURL(tt.reg, tt.disk); got != tt.want { - t.Errorf("(reg %q, disk %q) = %q; want %q", tt.reg, tt.disk, got, tt.want) - } - } -} - -func errorsMatchForTest(got, want error) bool { - if got == nil && want == nil { - return true - } - if got == nil || want == nil { - return false - } - return errors.Is(got, want) || got.Error() == want.Error() -} diff --git a/util/syspolicy/syspolicy_windows.go b/util/syspolicy/syspolicy_windows.go index 9d57e249e55e3..14ad9d3289190 100644 --- a/util/syspolicy/syspolicy_windows.go +++ b/util/syspolicy/syspolicy_windows.go @@ -8,11 +8,11 @@ import ( "fmt" "os/user" - "tailscale.com/util/syspolicy/internal" - "tailscale.com/util/syspolicy/rsop" - "tailscale.com/util/syspolicy/setting" - "tailscale.com/util/syspolicy/source" - "tailscale.com/util/testenv" + "github.com/sagernet/tailscale/util/syspolicy/internal" + "github.com/sagernet/tailscale/util/syspolicy/rsop" + "github.com/sagernet/tailscale/util/syspolicy/setting" + "github.com/sagernet/tailscale/util/syspolicy/source" + "github.com/sagernet/tailscale/util/testenv" ) func init() { diff --git a/util/sysresources/sysresources_test.go b/util/sysresources/sysresources_test.go deleted file mode 100644 index 331ad913bfba1..0000000000000 --- a/util/sysresources/sysresources_test.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package sysresources - -import ( - "runtime" - "testing" -) - -func TestTotalMemory(t *testing.T) { - switch runtime.GOOS { - case "linux": - case "freebsd", "openbsd", "dragonfly", "netbsd": - case "darwin": - default: - t.Skipf("not supported on runtime.GOOS=%q yet", runtime.GOOS) - } - - mem := TotalMemory() - if mem == 0 { - t.Fatal("wanted TotalMemory > 0") - } - t.Logf("total memory: %v bytes", mem) -} diff --git a/util/testenv/testenv.go b/util/testenv/testenv.go index 12ada9003052b..64cff97a925d1 100644 --- a/util/testenv/testenv.go +++ b/util/testenv/testenv.go @@ -8,7 +8,7 @@ package testenv import ( "flag" - "tailscale.com/types/lazy" + "github.com/sagernet/tailscale/types/lazy" ) var lazyInTest lazy.SyncValue[bool] diff --git a/util/testenv/testenv_test.go b/util/testenv/testenv_test.go deleted file mode 100644 index 43c332b26a5a1..0000000000000 --- a/util/testenv/testenv_test.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package testenv - -import ( - "testing" - - "tailscale.com/tstest/deptest" -) - -func TestDeps(t *testing.T) { - deptest.DepChecker{ - BadDeps: map[string]string{ - "testing": "see pkg docs", - }, - }.Check(t) -} diff --git a/util/topk/topk_test.go b/util/topk/topk_test.go deleted file mode 100644 index d30342e90de7b..0000000000000 --- a/util/topk/topk_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package topk - -import ( - "encoding/binary" - "fmt" - "slices" - "testing" -) - -func TestCountMinSketch(t *testing.T) { - cms := NewCountMinSketch(4, 10) - items := []string{"foo", "bar", "baz", "asdf", "quux"} - for _, item := range items { - cms.Add([]byte(item)) - } - for _, item := range items { - count := cms.Get([]byte(item)) - if count < 1 { - t.Errorf("item %q should have count >= 1", item) - } else if count > 1 { - t.Logf("item %q has count > 1: %d", item, count) - } - } - - // Test that an item that's *not* in the set has a value lower than the - // total number of items we inserted (in the case that all items - // collided). - noItemCount := cms.Get([]byte("doesn't exist")) - if noItemCount > uint64(len(items)) { - t.Errorf("expected nonexistent item to have value < %d; got %d", len(items), noItemCount) - } -} - -func TestTopK(t *testing.T) { - // This is probabilistic, so we're going to try 10 times to get the - // "right" value; the likelihood that we fail on all attempts is - // vanishingly small since the number of hash buckets is drastically - // larger than the number of items we're inserting. - var ( - got []int - want = []int{5, 6, 7, 8, 9} - ) - for try := 0; try < 10; try++ { - topk := NewWithParams[int](5, func(in []byte, val int) []byte { - return binary.LittleEndian.AppendUint64(in, uint64(val)) - }, 4, 1000) - - // Add the first 10 integers with counts equal to 2x their value - for i := range 10 { - topk.AddN(i, uint64(i*2)) - } - - got = topk.Top() - t.Logf("top K items: %+v", got) - slices.Sort(got) - - if slices.Equal(got, want) { - // All good! - return - } - - // continue and retry or fail - } - - t.Errorf("top K mismatch\ngot: %v\nwant: %v", got, want) -} - -func TestPickParams(t *testing.T) { - hashes, buckets := PickParams( - 0.001, // 0.1% error rate - 0.001, // 0.1% chance of having an error, or 99.9% chance of not having an error - ) - t.Logf("hashes = %d, buckets = %d", hashes, buckets) -} - -func BenchmarkCountMinSketch(b *testing.B) { - cms := NewCountMinSketch(PickParams(0.001, 0.001)) - b.ResetTimer() - b.ReportAllocs() - - var enc [8]byte - for i := range b.N { - binary.LittleEndian.PutUint64(enc[:], uint64(i)) - cms.Add(enc[:]) - } -} - -func BenchmarkTopK(b *testing.B) { - for _, n := range []int{ - 10, - 128, - 256, - 1024, - 8192, - } { - b.Run(fmt.Sprintf("Top%d", n), func(b *testing.B) { - out := make([]int, 0, n) - topk := New[int](n, func(in []byte, val int) []byte { - return binary.LittleEndian.AppendUint64(in, uint64(val)) - }) - b.ResetTimer() - b.ReportAllocs() - - for i := range b.N { - topk.Add(i) - } - out = topk.AppendTop(out[:0]) // should not allocate - _ = out // appease linter - }) - } -} - -func TestMultiplyHigh64(t *testing.T) { - testCases := []struct { - x, y uint64 - want uint64 - }{ - {0, 0, 0}, - {0xffffffff, 0xffffffff, 0}, - {0x2, 0xf000000000000000, 1}, - {0x3, 0xf000000000000000, 2}, - {0x3, 0xf000000000000001, 2}, - {0x3, 0xffffffffffffffff, 2}, - {0xffffffffffffffff, 0xffffffffffffffff, 0xfffffffffffffffe}, - } - for _, tc := range testCases { - got := multiplyHigh64(tc.x, tc.y) - if got != tc.want { - t.Errorf("got multiplyHigh64(%x, %x) = %x, want %x", tc.x, tc.y, got, tc.want) - } - } -} diff --git a/util/truncate/truncate_test.go b/util/truncate/truncate_test.go deleted file mode 100644 index c0d9e6e14df99..0000000000000 --- a/util/truncate/truncate_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package truncate_test - -import ( - "testing" - - "tailscale.com/util/truncate" -) - -func TestString(t *testing.T) { - tests := []struct { - input string - size int - want string - }{ - {"", 1000, ""}, // n > length - {"abc", 4, "abc"}, // n > length - {"abc", 3, "abc"}, // n == length - {"abcdefg", 4, "abcd"}, // n < length, safe - {"abcdefg", 0, ""}, // n < length, safe - {"abc\U0001fc2d", 3, "abc"}, // n < length, at boundary - {"abc\U0001fc2d", 4, "abc"}, // n < length, mid-rune - {"abc\U0001fc2d", 5, "abc"}, // n < length, mid-rune - {"abc\U0001fc2d", 6, "abc"}, // n < length, mid-rune - {"abc\U0001fc2defg", 7, "abc"}, // n < length, cut multibyte - } - - for _, tc := range tests { - got := truncate.String(tc.input, tc.size) - if got != tc.want { - t.Errorf("truncate(%q, %d): got %q, want %q", tc.input, tc.size, got, tc.want) - } - } -} diff --git a/util/uniq/slice_test.go b/util/uniq/slice_test.go deleted file mode 100644 index 564fc08660332..0000000000000 --- a/util/uniq/slice_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package uniq_test - -import ( - "reflect" - "strconv" - "testing" - - "tailscale.com/util/uniq" -) - -func runTests(t *testing.T, cb func(*[]uint32)) { - tests := []struct { - // Use uint32 to be different from an int-typed slice index - in []uint32 - want []uint32 - }{ - {in: []uint32{0, 1, 2}, want: []uint32{0, 1, 2}}, - {in: []uint32{0, 1, 2, 2}, want: []uint32{0, 1, 2}}, - {in: []uint32{0, 0, 1, 2}, want: []uint32{0, 1, 2}}, - {in: []uint32{0, 1, 0, 2}, want: []uint32{0, 1, 0, 2}}, - {in: []uint32{0}, want: []uint32{0}}, - {in: []uint32{0, 0}, want: []uint32{0}}, - {in: []uint32{}, want: []uint32{}}, - } - - for _, test := range tests { - in := make([]uint32, len(test.in)) - copy(in, test.in) - cb(&test.in) - if !reflect.DeepEqual(test.in, test.want) { - t.Errorf("uniq.Slice(%v) = %v, want %v", in, test.in, test.want) - } - start := len(test.in) - test.in = test.in[:cap(test.in)] - for i := start; i < len(in); i++ { - if test.in[i] != 0 { - t.Errorf("uniq.Slice(%v): non-0 in tail of %v at index %v", in, test.in, i) - } - } - } -} - -func TestModifySlice(t *testing.T) { - runTests(t, func(slice *[]uint32) { - uniq.ModifySlice(slice) - }) -} - -func TestModifySliceFunc(t *testing.T) { - runTests(t, func(slice *[]uint32) { - uniq.ModifySliceFunc(slice, func(i, j uint32) bool { - return i == j - }) - }) -} - -func Benchmark(b *testing.B) { - benches := []struct { - name string - reset func(s []byte) - }{ - {name: "AllDups", - reset: func(s []byte) { - for i := range s { - s[i] = '*' - } - }, - }, - {name: "NoDups", - reset: func(s []byte) { - for i := range s { - s[i] = byte(i) - } - }, - }, - } - - for _, bb := range benches { - b.Run(bb.name, func(b *testing.B) { - for size := 1; size <= 4096; size *= 16 { - b.Run(strconv.Itoa(size), func(b *testing.B) { - benchmark(b, 64, bb.reset) - }) - } - }) - } -} - -func benchmark(b *testing.B, size int64, reset func(s []byte)) { - b.ReportAllocs() - b.SetBytes(size) - s := make([]byte, size) - b.ResetTimer() - for range b.N { - s = s[:size] - reset(s) - uniq.ModifySlice(&s) - } -} diff --git a/util/usermetric/metrics.go b/util/usermetric/metrics.go index 7f85989ff062a..6d534d2e16b4b 100644 --- a/util/usermetric/metrics.go +++ b/util/usermetric/metrics.go @@ -10,7 +10,7 @@ package usermetric import ( "sync" - "tailscale.com/metrics" + "github.com/sagernet/tailscale/metrics" ) // Metrics contains user-facing metrics that are used by multiple packages. diff --git a/util/usermetric/usermetric.go b/util/usermetric/usermetric.go index 7913a4ef0d5f8..9479237c3a941 100644 --- a/util/usermetric/usermetric.go +++ b/util/usermetric/usermetric.go @@ -12,8 +12,8 @@ import ( "net/http" "strings" - "tailscale.com/metrics" - "tailscale.com/tsweb/varz" + "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/tsweb/varz" ) // Registry tracks user-facing metrics of various Tailscale subsystems. diff --git a/util/usermetric/usermetric_test.go b/util/usermetric/usermetric_test.go deleted file mode 100644 index e92db5bfce130..0000000000000 --- a/util/usermetric/usermetric_test.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package usermetric - -import ( - "bytes" - "testing" -) - -func TestGauge(t *testing.T) { - var reg Registry - g := reg.NewGauge("test_gauge", "This is a test gauge") - g.Set(15) - - var buf bytes.Buffer - g.WritePrometheus(&buf, "test_gauge") - const want = `# TYPE test_gauge gauge -# HELP test_gauge This is a test gauge -test_gauge 15 -` - if got := buf.String(); got != want { - t.Errorf("got %q; want %q", got, want) - } - -} diff --git a/util/vizerror/vizerror_test.go b/util/vizerror/vizerror_test.go deleted file mode 100644 index 242ca6462f37b..0000000000000 --- a/util/vizerror/vizerror_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package vizerror - -import ( - "errors" - "fmt" - "io/fs" - "testing" -) - -func TestNew(t *testing.T) { - err := New("abc") - if err.Error() != "abc" { - t.Errorf(`New("abc").Error() = %q, want %q`, err.Error(), "abc") - } -} - -func TestErrorf(t *testing.T) { - err := Errorf("%w", fs.ErrNotExist) - - if got, want := err.Error(), "file does not exist"; got != want { - t.Errorf("Errorf().Error() = %q, want %q", got, want) - } - - // ensure error wrapping still works - if !errors.Is(err, fs.ErrNotExist) { - t.Errorf("error chain does not contain fs.ErrNotExist") - } -} - -func TestAs(t *testing.T) { - verr := New("visible error") - err := fmt.Errorf("wrap: %w", verr) - - got, ok := As(err) - if !ok { - t.Errorf("As() return false, want true") - } - if got != verr { - t.Errorf("As() returned error %v, want %v", got, verr) - } -} - -func TestWrap(t *testing.T) { - wrapped := errors.New("wrapped") - err := Wrap(wrapped) - if err.Error() != "wrapped" { - t.Errorf(`Wrap(wrapped).Error() = %q, want %q`, err.Error(), "wrapped") - } - if errors.Unwrap(err) != wrapped { - t.Errorf("Unwrap = %q, want %q", errors.Unwrap(err), wrapped) - } -} - -func TestWrapWithMessage(t *testing.T) { - wrapped := errors.New("wrapped") - err := WrapWithMessage(wrapped, "safe") - if err.Error() != "safe" { - t.Errorf(`WrapWithMessage(wrapped, "safe").Error() = %q, want %q`, err.Error(), "safe") - } - if errors.Unwrap(err) != wrapped { - t.Errorf("Unwrap = %q, want %q", errors.Unwrap(err), wrapped) - } -} diff --git a/util/winutil/conpty/conpty_windows.go b/util/winutil/conpty/conpty_windows.go index 0a35759b49136..b1b616ac78067 100644 --- a/util/winutil/conpty/conpty_windows.go +++ b/util/winutil/conpty/conpty_windows.go @@ -11,8 +11,8 @@ import ( "os" "github.com/dblohm7/wingoes" + "github.com/sagernet/tailscale/util/winutil" "golang.org/x/sys/windows" - "tailscale.com/util/winutil" ) var ( diff --git a/util/winutil/gp/gp_windows_test.go b/util/winutil/gp/gp_windows_test.go deleted file mode 100644 index e2520b46d56ae..0000000000000 --- a/util/winutil/gp/gp_windows_test.go +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package gp - -import ( - "errors" - "sync" - "testing" - "time" - - "tailscale.com/util/cibuild" -) - -func TestWatchForPolicyChange(t *testing.T) { - if cibuild.On() { - // Unlike tests that also use the GP API in net\dns\manager_windows_test.go, - // this one does not require elevation. However, a Group Policy change notification - // never arrives when this tests runs on a GitHub-hosted runner. - t.Skipf("test requires running on a real Windows environment") - } - - done, close := setupMachinePolicyChangeNotifier(t) - defer close() - - // RefreshMachinePolicy is a non-blocking call. - if err := RefreshMachinePolicy(true); err != nil { - t.Fatalf("RefreshMachinePolicy failed: %v", err) - } - - // We should receive a policy change notification when - // the Group Policy service completes policy processing. - // Otherwise, the test will eventually time out. - <-done -} - -func TestGroupPolicyReadLock(t *testing.T) { - if cibuild.On() { - // Unlike tests that also use the GP API in net\dns\manager_windows_test.go, - // this one does not require elevation. However, a Group Policy change notification - // never arrives when this tests runs on a GitHub-hosted runner. - t.Skipf("test requires running on a real Windows environment") - } - - done, close := setupMachinePolicyChangeNotifier(t) - defer close() - - doWithMachinePolicyLocked(t, func() { - // RefreshMachinePolicy is a non-blocking call. - if err := RefreshMachinePolicy(true); err != nil { - t.Fatalf("RefreshMachinePolicy failed: %v", err) - } - - // Give the Group Policy service a few seconds to attempt to refresh the policy. - // It shouldn't be able to do so while the lock is held, and the below should time out. - timeout := time.NewTimer(5 * time.Second) - defer timeout.Stop() - select { - case <-timeout.C: - case <-done: - t.Fatal("Policy refresh occurred while the policy lock was held") - } - }) - - // We should receive a policy change notification once the lock is released - // and GP can refresh the policy. - // Otherwise, the test will eventually time out. - <-done -} - -func TestHammerGroupPolicyReadLock(t *testing.T) { - const N = 10_000 - - enter := func(bool) (policyLockHandle, error) { return 1, nil } - leave := func(policyLockHandle) error { return nil } - - doWithCustomEnterLeaveFuncs(t, func(gpLock *PolicyLock) { - var wg sync.WaitGroup - wg.Add(N) - for range N { - go func() { - defer wg.Done() - if err := gpLock.Lock(); err != nil { - t.Errorf("(*PolicyLock).Lock failed: %v", err) - return - } - defer gpLock.Unlock() - if gpLock.handle == 0 { - t.Error("(*PolicyLock).handle is 0") - return - } - }() - } - wg.Wait() - }, enter, leave) -} - -func TestGroupPolicyReadLockClose(t *testing.T) { - init := make(chan struct{}) - enter := func(bool) (policyLockHandle, error) { - close(init) - time.Sleep(500 * time.Millisecond) - return 1, nil - } - leave := func(policyLockHandle) error { return nil } - - doWithCustomEnterLeaveFuncs(t, func(gpLock *PolicyLock) { - done := make(chan struct{}) - go func() { - defer close(done) - - err := gpLock.Lock() - if err == nil { - defer gpLock.Unlock() - } - - // We closed gpLock before the enter function returned. - // (*PolicyLock).Lock is expected to fail. - if err == nil || !errors.Is(err, ErrInvalidLockState) { - t.Errorf("(*PolicyLock).Lock: got %v; want %v", err, ErrInvalidLockState) - } - // gpLock must not be held as Lock() failed. - if lockCnt := gpLock.lockCnt.Load(); lockCnt != 0 { - t.Errorf("lockCnt: got %v; want 0", lockCnt) - } - }() - - <-init - // Close gpLock right before the enter function returns. - if err := gpLock.Close(); err != nil { - t.Fatalf("(*PolicyLock).Close failed: %v", err) - } - <-done - }, enter, leave) -} - -func TestGroupPolicyReadLockErr(t *testing.T) { - wantErr := errors.New("failed to acquire the lock") - - enter := func(bool) (policyLockHandle, error) { return 0, wantErr } - leave := func(policyLockHandle) error { t.Error("leaveCriticalPolicySection must not be called"); return nil } - - doWithCustomEnterLeaveFuncs(t, func(gpLock *PolicyLock) { - err := gpLock.Lock() - if err == nil { - defer gpLock.Unlock() - } - if err != wantErr { - t.Errorf("(*PolicyLock).Lock: got %v; want %v", err, wantErr) - } - // gpLock must not be held when Lock() fails. - // The LSB indicates that the lock has not been closed. - if lockCnt := gpLock.lockCnt.Load(); lockCnt&^(1) != 0 { - t.Errorf("lockCnt: got %v; want 0", lockCnt) - } - }, enter, leave) -} - -func setupMachinePolicyChangeNotifier(t *testing.T) (chan struct{}, func()) { - done := make(chan struct{}) - var watcher *ChangeWatcher - watcher, err := NewChangeWatcher(MachinePolicy, func() { - close(done) - }) - if err != nil { - t.Fatalf("NewChangeWatcher failed: %v", err) - } - return done, func() { - if err := watcher.Close(); err != nil { - t.Errorf("(*ChangeWatcher).Close failed: %v", err) - } - } -} - -func doWithMachinePolicyLocked(t *testing.T, f func()) { - gpLock := NewMachinePolicyLock() - defer gpLock.Close() - if err := gpLock.Lock(); err != nil { - t.Fatalf("(*PolicyLock).Lock failed: %v", err) - } - defer gpLock.Unlock() - f() -} - -func doWithCustomEnterLeaveFuncs(t *testing.T, f func(l *PolicyLock), enter func(bool) (policyLockHandle, error), leave func(policyLockHandle) error) { - t.Helper() - - l := NewMachinePolicyLock() - l.enterFn, l.leaveFn = enter, leave - t.Cleanup(func() { - if err := l.Close(); err != nil { - t.Fatalf("(*PolicyLock).Close failed: %v", err) - } - }) - - f(l) -} diff --git a/util/winutil/policy/policy_windows.go b/util/winutil/policy/policy_windows.go index 89142951f8bd5..74f0bd89f8000 100644 --- a/util/winutil/policy/policy_windows.go +++ b/util/winutil/policy/policy_windows.go @@ -7,7 +7,7 @@ package policy import ( "time" - "tailscale.com/util/winutil" + "github.com/sagernet/tailscale/util/winutil" ) // PreferenceOptionPolicy is a policy that governs whether a boolean variable diff --git a/util/winutil/policy/policy_windows_test.go b/util/winutil/policy/policy_windows_test.go deleted file mode 100644 index cf2390c568cce..0000000000000 --- a/util/winutil/policy/policy_windows_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package policy - -import "testing" - -func TestSelectControlURL(t *testing.T) { - tests := []struct { - reg, disk, want string - }{ - // Modern default case. - {"", "", "https://controlplane.tailscale.com"}, - - // For a user who installed prior to Dec 2020, with - // stuff in their registry. - {"https://login.tailscale.com", "", "https://login.tailscale.com"}, - - // Ignore pre-Dec'20 LoginURL from installer if prefs - // prefs overridden manually to an on-prem control - // server. - {"https://login.tailscale.com", "http://on-prem", "http://on-prem"}, - - // Something unknown explicitly set in the registry always wins. - {"http://explicit-reg", "", "http://explicit-reg"}, - {"http://explicit-reg", "http://on-prem", "http://explicit-reg"}, - {"http://explicit-reg", "https://login.tailscale.com", "http://explicit-reg"}, - {"http://explicit-reg", "https://controlplane.tailscale.com", "http://explicit-reg"}, - - // If nothing in the registry, disk wins. - {"", "http://on-prem", "http://on-prem"}, - } - for _, tt := range tests { - if got := SelectControlURL(tt.reg, tt.disk); got != tt.want { - t.Errorf("(reg %q, disk %q) = %q; want %q", tt.reg, tt.disk, got, tt.want) - } - } -} diff --git a/util/winutil/restartmgr_windows.go b/util/winutil/restartmgr_windows.go index a52e2fee9f933..84448e3740308 100644 --- a/util/winutil/restartmgr_windows.go +++ b/util/winutil/restartmgr_windows.go @@ -17,9 +17,9 @@ import ( "unsafe" "github.com/dblohm7/wingoes" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/multierr" "golang.org/x/sys/windows" - "tailscale.com/types/logger" - "tailscale.com/util/multierr" ) var ( diff --git a/util/winutil/restartmgr_windows_test.go b/util/winutil/restartmgr_windows_test.go deleted file mode 100644 index 6b2d75c3c5459..0000000000000 --- a/util/winutil/restartmgr_windows_test.go +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package winutil - -import ( - "fmt" - "os/user" - "path/filepath" - "strings" - "testing" - "time" - "unsafe" - - "golang.org/x/sys/windows" -) - -const oldFashionedCleanupExitCode = 7778 - -// oldFashionedCleanup cleans up any outstanding binaries using older APIs. -// This would be necessary if the restart manager were to fail during the test. -func oldFashionedCleanup(t *testing.T, binary string) { - snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) - if err != nil { - t.Logf("CreateToolhelp32Snapshot failed: %v", err) - } - defer windows.CloseHandle(snap) - - binary = filepath.Clean(binary) - binbase := filepath.Base(binary) - pe := windows.ProcessEntry32{ - Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{})), - } - for perr := windows.Process32First(snap, &pe); perr == nil; perr = windows.Process32Next(snap, &pe) { - curBin := windows.UTF16ToString(pe.ExeFile[:]) - // Coarse check against the leaf name of the binary - if !strings.EqualFold(binbase, curBin) { - continue - } - - proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE, false, pe.ProcessID) - if err != nil { - t.Logf("OpenProcess failed: %v", err) - continue - } - defer windows.CloseHandle(proc) - - img, err := ProcessImageName(proc) - if err != nil { - t.Logf("ProcessImageName failed: %v", err) - continue - } - - // Now check that their fully-qualified paths match. - if !strings.EqualFold(binary, filepath.Clean(img)) { - continue - } - - t.Logf("Found leftover pid %d, terminating...", pe.ProcessID) - if err := windows.TerminateProcess(proc, oldFashionedCleanupExitCode); err != nil && err != windows.ERROR_ACCESS_DENIED { - t.Logf("TerminateProcess failed: %v", err) - } - } -} - -func testRestartableProcessesImpl(N int, t *testing.T) { - const binary = "testrestartableprocesses" - fq := pathToTestProg(t, binary) - - for range N { - startTestProg(t, binary, "RestartableProcess") - } - t.Cleanup(func() { - oldFashionedCleanup(t, fq) - }) - - logf := func(format string, args ...any) { - t.Logf(format, args...) - } - rms, err := NewRestartManagerSession(logf) - if err != nil { - t.Fatalf("NewRestartManagerSession: %v", err) - } - defer rms.Close() - - if err := rms.AddPaths([]string{fq}); err != nil { - t.Fatalf("AddPaths: %v", err) - } - - ups, err := rms.AffectedProcesses() - if err != nil { - t.Fatalf("AffectedProcesses: %v", err) - } - - rps := NewRestartableProcesses() - defer rps.Close() - - for _, up := range ups { - rp, err := up.AsRestartableProcess() - if err != nil { - t.Errorf("AsRestartableProcess: %v", err) - continue - } - rps.Add(rp) - } - - const terminateWithExitCode = 7777 - if err := rps.Terminate(logf, terminateWithExitCode, time.Duration(15)*time.Second); err != nil { - t.Errorf("Terminate: %v", err) - } - - for k, v := range rps { - if v.hasExitCode { - if v.exitCode != terminateWithExitCode { - // Not strictly an error, but worth noting. - logf("Subprocess %d terminated with unexpected exit code %d", k, v.exitCode) - } - } else { - t.Errorf("Subprocess %d did not produce an exit code", k) - } - if v.handle != 0 { - t.Errorf("Subprocess %d is unexpectedly still open", k) - } - } -} - -func TestRestartableProcesses(t *testing.T) { - u, err := user.Current() - if err != nil { - t.Fatalf("Could not obtain current user") - } - if u.Uid != localSystemSID { - t.Skipf("This test must be run as SYSTEM") - } - - forN := func(fn func(int, *testing.T)) func([]int) { - return func(ns []int) { - for _, n := range ns { - t.Run(fmt.Sprintf("N=%d", n), func(tt *testing.T) { fn(n, tt) }) - } - } - }(testRestartableProcessesImpl) - - // Testing indicates that the restart manager cannot handle more than 127 processes (on Windows 10, at least), so we use that as our highest value. - ns := []int{0, 1, _MAXIMUM_WAIT_OBJECTS - 1, _MAXIMUM_WAIT_OBJECTS, _MAXIMUM_WAIT_OBJECTS + 1, _MAXIMUM_WAIT_OBJECTS*2 - 1} - forN(ns) -} diff --git a/util/winutil/s4u/lsa_windows.go b/util/winutil/s4u/lsa_windows.go deleted file mode 100644 index 3ff2171f91d70..0000000000000 --- a/util/winutil/s4u/lsa_windows.go +++ /dev/null @@ -1,399 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package s4u - -import ( - "errors" - "fmt" - "os" - "os/user" - "path/filepath" - "strings" - "unicode" - "unsafe" - - "github.com/dblohm7/wingoes" - "golang.org/x/sys/windows" - "tailscale.com/types/lazy" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/winenv" -) - -const ( - _MICROSOFT_KERBEROS_NAME = "Kerberos" - _MSV1_0_PACKAGE_NAME = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0" -) - -type _LSAHANDLE windows.Handle -type _LSA_OPERATIONAL_MODE uint32 - -type _KERB_LOGON_SUBMIT_TYPE int32 - -const ( - _KerbInteractiveLogon _KERB_LOGON_SUBMIT_TYPE = 2 - _KerbSmartCardLogon _KERB_LOGON_SUBMIT_TYPE = 6 - _KerbWorkstationUnlockLogon _KERB_LOGON_SUBMIT_TYPE = 7 - _KerbSmartCardUnlockLogon _KERB_LOGON_SUBMIT_TYPE = 8 - _KerbProxyLogon _KERB_LOGON_SUBMIT_TYPE = 9 - _KerbTicketLogon _KERB_LOGON_SUBMIT_TYPE = 10 - _KerbTicketUnlockLogon _KERB_LOGON_SUBMIT_TYPE = 11 - _KerbS4ULogon _KERB_LOGON_SUBMIT_TYPE = 12 - _KerbCertificateLogon _KERB_LOGON_SUBMIT_TYPE = 13 - _KerbCertificateS4ULogon _KERB_LOGON_SUBMIT_TYPE = 14 - _KerbCertificateUnlockLogon _KERB_LOGON_SUBMIT_TYPE = 15 - _KerbNoElevationLogon _KERB_LOGON_SUBMIT_TYPE = 83 - _KerbLuidLogon _KERB_LOGON_SUBMIT_TYPE = 84 -) - -type _KERB_S4U_LOGON_FLAGS uint32 - -const ( - _KERB_S4U_LOGON_FLAG_CHECK_LOGONHOURS _KERB_S4U_LOGON_FLAGS = 0x2 - //lint:ignore U1000 maps to a win32 API - _KERB_S4U_LOGON_FLAG_IDENTIFY _KERB_S4U_LOGON_FLAGS = 0x8 -) - -type _KERB_S4U_LOGON struct { - MessageType _KERB_LOGON_SUBMIT_TYPE - Flags _KERB_S4U_LOGON_FLAGS - ClientUpn windows.NTUnicodeString - ClientRealm windows.NTUnicodeString -} - -type _MSV1_0_LOGON_SUBMIT_TYPE int32 - -const ( - _MsV1_0InteractiveLogon _MSV1_0_LOGON_SUBMIT_TYPE = 2 - _MsV1_0Lm20Logon _MSV1_0_LOGON_SUBMIT_TYPE = 3 - _MsV1_0NetworkLogon _MSV1_0_LOGON_SUBMIT_TYPE = 4 - _MsV1_0SubAuthLogon _MSV1_0_LOGON_SUBMIT_TYPE = 5 - _MsV1_0WorkstationUnlockLogon _MSV1_0_LOGON_SUBMIT_TYPE = 7 - _MsV1_0S4ULogon _MSV1_0_LOGON_SUBMIT_TYPE = 12 - _MsV1_0VirtualLogon _MSV1_0_LOGON_SUBMIT_TYPE = 82 - _MsV1_0NoElevationLogon _MSV1_0_LOGON_SUBMIT_TYPE = 83 - _MsV1_0LuidLogon _MSV1_0_LOGON_SUBMIT_TYPE = 84 -) - -type _MSV1_0_S4U_LOGON_FLAGS uint32 - -const ( - _MSV1_0_S4U_LOGON_FLAG_CHECK_LOGONHOURS _MSV1_0_S4U_LOGON_FLAGS = 0x2 -) - -type _MSV1_0_S4U_LOGON struct { - MessageType _MSV1_0_LOGON_SUBMIT_TYPE - Flags _MSV1_0_S4U_LOGON_FLAGS - UserPrincipalName windows.NTUnicodeString - DomainName windows.NTUnicodeString -} - -type _SECURITY_LOGON_TYPE int32 - -const ( - _UndefinedLogonType _SECURITY_LOGON_TYPE = 0 - _Interactive _SECURITY_LOGON_TYPE = 2 - _Network _SECURITY_LOGON_TYPE = 3 - _Batch _SECURITY_LOGON_TYPE = 4 - _Service _SECURITY_LOGON_TYPE = 5 - _Proxy _SECURITY_LOGON_TYPE = 6 - _Unlock _SECURITY_LOGON_TYPE = 7 - _NetworkCleartext _SECURITY_LOGON_TYPE = 8 - _NewCredentials _SECURITY_LOGON_TYPE = 9 - _RemoteInteractive _SECURITY_LOGON_TYPE = 10 - _CachedInteractive _SECURITY_LOGON_TYPE = 11 - _CachedRemoteInteractive _SECURITY_LOGON_TYPE = 12 - _CachedUnlock _SECURITY_LOGON_TYPE = 13 -) - -const _TOKEN_SOURCE_LENGTH = 8 - -type _TOKEN_SOURCE struct { - SourceName [_TOKEN_SOURCE_LENGTH]byte - SourceIdentifier windows.LUID -} - -type _QUOTA_LIMITS struct { - PagedPoolLimit uintptr - NonPagedPoolLimit uintptr - MinimumWorkingSetSize uintptr - MaximumWorkingSetSize uintptr - PagefileLimit uintptr - TimeLimit int64 -} - -var ( - // ErrBadSrcName is returned if srcName contains non-ASCII characters, is - // empty, or is too long. It may be wrapped with additional information; use - // errors.Is when checking for it. - ErrBadSrcName = errors.New("srcName must be ASCII with length > 0 and <= 8") -) - -// LSA packages (and their IDs) are always initialized during system startup, -// so we can retain their resolved IDs for the lifetime of our process. -var ( - authPkgIDKerberos lazy.SyncValue[uint32] - authPkgIDMSV1_0 lazy.SyncValue[uint32] -) - -type lsaSession struct { - handle _LSAHANDLE -} - -func newLSASessionForQuery() (lsa *lsaSession, err error) { - var h _LSAHANDLE - if e := wingoes.ErrorFromNTStatus(lsaConnectUntrusted(&h)); e.Failed() { - return nil, e - } - - return &lsaSession{handle: h}, nil -} - -func newLSASessionForLogon(processName string) (lsa *lsaSession, err error) { - // processName is used by LSA for audit logging purposes. - // If empty, the current process name is used. - if processName == "" { - exe, err := os.Executable() - if err != nil { - return nil, err - } - - processName = strings.TrimSuffix(filepath.Base(exe), filepath.Ext(exe)) - } - - if err := checkASCII(processName); err != nil { - return nil, err - } - - logonProcessName, err := windows.NewNTString(processName) - if err != nil { - return nil, err - } - - var h _LSAHANDLE - var mode _LSA_OPERATIONAL_MODE - if e := wingoes.ErrorFromNTStatus(lsaRegisterLogonProcess(logonProcessName, &h, &mode)); e.Failed() { - return nil, e - } - - return &lsaSession{handle: h}, nil -} - -func (ls *lsaSession) getAuthPkgID(pkgName string) (id uint32, err error) { - ntPkgName, err := windows.NewNTString(pkgName) - if err != nil { - return 0, err - } - - if e := wingoes.ErrorFromNTStatus(lsaLookupAuthenticationPackage(ls.handle, ntPkgName, &id)); e.Failed() { - return 0, e - } - - return id, nil -} - -func (ls *lsaSession) Close() error { - if e := wingoes.ErrorFromNTStatus(lsaDeregisterLogonProcess(ls.handle)); e.Failed() { - return e - } - ls.handle = 0 - return nil -} - -func checkASCII(s string) error { - for _, c := range []byte(s) { - if c > unicode.MaxASCII { - return fmt.Errorf("%q must be ASCII but contains value 0x%02X", s, c) - } - } - - return nil -} - -var ( - thisComputer = []uint16{'.', 0} - computerName lazy.SyncValue[string] -) - -func getComputerName() (string, error) { - var buf [windows.MAX_COMPUTERNAME_LENGTH + 1]uint16 - size := uint32(len(buf)) - if err := windows.GetComputerName(&buf[0], &size); err != nil { - return "", err - } - - return windows.UTF16ToString(buf[:size]), nil -} - -// checkDomainAccount strips out the computer name (if any) from -// username and returns the result in sanitizedUserName. isDomainAccount is set -// to true if username contains a domain component that does not refer to the -// local computer. -func checkDomainAccount(username string) (sanitizedUserName string, isDomainAccount bool, err error) { - before, after, hasBackslash := strings.Cut(username, `\`) - if !hasBackslash { - return username, false, nil - } - if before == "." { - return after, false, nil - } - - comp, err := computerName.GetErr(getComputerName) - if err != nil { - return username, false, err - } - - if strings.EqualFold(before, comp) { - return after, false, nil - } - return username, true, nil -} - -// logonAs performs a S4U logon for u on behalf of srcName, and returns an -// access token for the user if successful. srcName must be non-empty, ASCII, -// and no more than 8 characters long. If srcName does not meet this criteria, -// LogonAs will return ErrBadSrcName wrapped with additional information; use -// errors.Is to check for it. When capLevel == CapCreateProcess, the logon -// enforces the user's logon hours policy (when present). -func (ls *lsaSession) logonAs(srcName string, u *user.User, capLevel CapabilityLevel) (token windows.Token, err error) { - if l := len(srcName); l == 0 || l > _TOKEN_SOURCE_LENGTH { - return 0, fmt.Errorf("%w, actual length is %d", ErrBadSrcName, l) - } - if err := checkASCII(srcName); err != nil { - return 0, fmt.Errorf("%w: %v", ErrBadSrcName, err) - } - - sanitizedUserName, isDomainUser, err := checkDomainAccount(u.Username) - if err != nil { - return 0, err - } - if isDomainUser && !winenv.IsDomainJoined() { - return 0, fmt.Errorf("%w: cannot logon as domain user without being joined to a domain", os.ErrInvalid) - } - - var pkgID uint32 - var authInfo unsafe.Pointer - var authInfoLen uint32 - enforceLogonHours := capLevel == CapCreateProcess - if isDomainUser { - pkgID, err = authPkgIDKerberos.GetErr(func() (uint32, error) { - return ls.getAuthPkgID(_MICROSOFT_KERBEROS_NAME) - }) - if err != nil { - return 0, err - } - - upn16, err := samToUPN16(sanitizedUserName) - if err != nil { - return 0, fmt.Errorf("samToUPN16: %w", err) - } - - logonInfo, logonInfoLen, slcs := winutil.AllocateContiguousBuffer[_KERB_S4U_LOGON](upn16) - logonInfo.MessageType = _KerbS4ULogon - if enforceLogonHours { - logonInfo.Flags = _KERB_S4U_LOGON_FLAG_CHECK_LOGONHOURS - } - winutil.SetNTString(&logonInfo.ClientUpn, slcs[0]) - - authInfo = unsafe.Pointer(logonInfo) - authInfoLen = logonInfoLen - } else { - pkgID, err = authPkgIDMSV1_0.GetErr(func() (uint32, error) { - return ls.getAuthPkgID(_MSV1_0_PACKAGE_NAME) - }) - if err != nil { - return 0, err - } - - upn16, err := windows.UTF16FromString(sanitizedUserName) - if err != nil { - return 0, err - } - - logonInfo, logonInfoLen, slcs := winutil.AllocateContiguousBuffer[_MSV1_0_S4U_LOGON](upn16, thisComputer) - logonInfo.MessageType = _MsV1_0S4ULogon - if enforceLogonHours { - logonInfo.Flags = _MSV1_0_S4U_LOGON_FLAG_CHECK_LOGONHOURS - } - for i, nts := range []*windows.NTUnicodeString{&logonInfo.UserPrincipalName, &logonInfo.DomainName} { - winutil.SetNTString(nts, slcs[i]) - } - - authInfo = unsafe.Pointer(logonInfo) - authInfoLen = logonInfoLen - } - - var srcContext _TOKEN_SOURCE - copy(srcContext.SourceName[:], []byte(srcName)) - if err := allocateLocallyUniqueId(&srcContext.SourceIdentifier); err != nil { - return 0, err - } - - originName, err := windows.NewNTString(srcName) - if err != nil { - return 0, err - } - - var profileBuf uintptr - var profileBufLen uint32 - var logonID windows.LUID - var quotas _QUOTA_LIMITS - var subNTStatus windows.NTStatus - ntStatus := lsaLogonUser(ls.handle, originName, _Network, pkgID, authInfo, authInfoLen, nil, &srcContext, &profileBuf, &profileBufLen, &logonID, &token, "as, &subNTStatus) - if e := wingoes.ErrorFromNTStatus(ntStatus); e.Failed() { - return 0, fmt.Errorf("LsaLogonUser(%q): %w, SubStatus: %v", u.Username, e, subNTStatus) - } - if profileBuf != 0 { - lsaFreeReturnBuffer(profileBuf) - } - return token, nil -} - -// samToUPN16 converts SAM-style account name samName to a UPN account name, -// returned as a UTF-16 slice. -func samToUPN16(samName string) (upn16 []uint16, err error) { - _, samAccount, hasSep := strings.Cut(samName, `\`) - if !hasSep { - return nil, fmt.Errorf("%w: expected samName to contain a backslash", os.ErrInvalid) - } - - // This is essentially the same algorithm used by Win32-OpenSSH: - // First, try obtaining a UPN directly... - upn16, err = translateName(samName, windows.NameSamCompatible, windows.NameUserPrincipal) - if err == nil { - return upn16, err - } - - // Fallback: Try manually composing a UPN. First obtain the canonical name... - canonical16, err := translateName(samName, windows.NameSamCompatible, windows.NameCanonical) - if err != nil { - return nil, err - } - canonical := windows.UTF16ToString(canonical16) - - // Extract the domain name... - domain, _, _ := strings.Cut(canonical, "/") - - // ...and finally create the UPN by joining the samAccount and domain. - upn := strings.Join([]string{samAccount, domain}, "@") - return windows.UTF16FromString(upn) -} - -func translateName(from string, fromFmt uint32, toFmt uint32) (result []uint16, err error) { - from16, err := windows.UTF16PtrFromString(from) - if err != nil { - return nil, err - } - - var to16Len uint32 - if err := windows.TranslateName(from16, fromFmt, toFmt, nil, &to16Len); err != nil { - return nil, err - } - - to16Buf := make([]uint16, to16Len) - if err := windows.TranslateName(from16, fromFmt, toFmt, unsafe.SliceData(to16Buf), &to16Len); err != nil { - return nil, err - } - - return to16Buf, nil -} diff --git a/util/winutil/s4u/mksyscall.go b/util/winutil/s4u/mksyscall.go deleted file mode 100644 index 8925c0209b124..0000000000000 --- a/util/winutil/s4u/mksyscall.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package s4u - -//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go mksyscall.go -//go:generate go run golang.org/x/tools/cmd/goimports -w zsyscall_windows.go - -//sys allocateLocallyUniqueId(luid *windows.LUID) (err error) [int32(failretval)==0] = advapi32.AllocateLocallyUniqueId -//sys impersonateLoggedOnUser(token windows.Token) (err error) [int32(failretval)==0] = advapi32.ImpersonateLoggedOnUser -//sys lsaConnectUntrusted(lsaHandle *_LSAHANDLE) (ret windows.NTStatus) = secur32.LsaConnectUntrusted -//sys lsaDeregisterLogonProcess(lsaHandle _LSAHANDLE) (ret windows.NTStatus) = secur32.LsaDeregisterLogonProcess -//sys lsaFreeReturnBuffer(buffer uintptr) (ret windows.NTStatus) = secur32.LsaFreeReturnBuffer -//sys lsaLogonUser(lsaHandle _LSAHANDLE, originName *windows.NTString, logonType _SECURITY_LOGON_TYPE, authenticationPackage uint32, authenticationInformation unsafe.Pointer, authenticationInformationLength uint32, localGroups *windows.Tokengroups, sourceContext *_TOKEN_SOURCE, profileBuffer *uintptr, profileBufferLength *uint32, logonID *windows.LUID, token *windows.Token, quotas *_QUOTA_LIMITS, subStatus *windows.NTStatus) (ret windows.NTStatus) = secur32.LsaLogonUser -//sys lsaLookupAuthenticationPackage(lsaHandle _LSAHANDLE, packageName *windows.NTString, authenticationPackage *uint32) (ret windows.NTStatus) = secur32.LsaLookupAuthenticationPackage -//sys lsaRegisterLogonProcess(logonProcessName *windows.NTString, lsaHandle *_LSAHANDLE, securityMode *_LSA_OPERATIONAL_MODE) (ret windows.NTStatus) = secur32.LsaRegisterLogonProcess diff --git a/util/winutil/s4u/s4u_windows.go b/util/winutil/s4u/s4u_windows.go deleted file mode 100644 index 8926aaedc5071..0000000000000 --- a/util/winutil/s4u/s4u_windows.go +++ /dev/null @@ -1,947 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package s4u is an API for accessing Service-For-User (S4U) functionality on Windows. -package s4u - -import ( - "encoding/binary" - "errors" - "flag" - "fmt" - "io" - "math" - "os" - "os/user" - "runtime" - "slices" - "strconv" - "strings" - "sync" - "sync/atomic" - "unsafe" - - "golang.org/x/sys/windows" - "tailscale.com/cmd/tailscaled/childproc" - "tailscale.com/types/logger" - "tailscale.com/util/winutil" - "tailscale.com/util/winutil/conpty" -) - -func init() { - childproc.Add("s4u", beRelay) -} - -var errInsufficientCapabilityLevel = errors.New("insufficient capability level") - -// ListGroupIDsForSSHPreAuthOnly returns user u's group memberships as a slice -// containing group SIDs. srcName must contain the name of the service that is -// retrieving this information. srcName must be non-empty, ASCII-only, and no -// longer than 8 characters. -// -// NOTE: This should only be used by Tailscale SSH! It is not a generic -// mechanism for access checks! -func ListGroupIDsForSSHPreAuthOnly(srcName string, u *user.User) ([]string, error) { - tok, err := createToken(srcName, u, tokenTypeIdentification, CapImpersonateOnly) - if err != nil { - return nil, err - } - defer tok.Close() - - tokenGroups, err := tok.GetTokenGroups() - if err != nil { - return nil, err - } - - result := make([]string, 0, tokenGroups.GroupCount) - for _, group := range tokenGroups.AllGroups() { - if group.Attributes&windows.SE_GROUP_ENABLED != 0 { - result = append(result, group.Sid.String()) - } - } - - return result, nil -} - -type tokenType uint - -const ( - tokenTypeIdentification tokenType = iota - tokenTypeImpersonation -) - -// createToken creates a new S4U access token for user u for the purposes -// specified by s4uType, with capability capLevel. srcName must contain the name -// of the service that is intended to use the token. srcName must be non-empty, -// ASCII-only, and no longer than 8 characters. -// -// When s4uType is tokenTypeImpersonation, the current OS thread's access token must have SeTcbPrivilege. -func createToken(srcName string, u *user.User, s4uType tokenType, capLevel CapabilityLevel) (tok windows.Token, err error) { - if u == nil { - return 0, os.ErrInvalid - } - - var lsa *lsaSession - switch s4uType { - case tokenTypeIdentification: - lsa, err = newLSASessionForQuery() - case tokenTypeImpersonation: - lsa, err = newLSASessionForLogon("") - default: - return 0, os.ErrInvalid - } - if err != nil { - return 0, err - } - defer lsa.Close() - - return lsa.logonAs(srcName, u, capLevel) -} - -// Session encapsulates an S4U login session. -type Session struct { - refCnt atomic.Int32 - logf logger.Logf - token windows.Token - userProfile *winutil.UserProfile - capLevel CapabilityLevel -} - -// CapabilityLevel specifies the desired capabilities that will be supported by a Session. -type CapabilityLevel uint - -const ( - // The Session supports Do but none of the StartProcess* methods. - CapImpersonateOnly CapabilityLevel = iota - // The Session supports both Do and the StartProcess* methods. - CapCreateProcess -) - -// Login logs user u into Windows on behalf of service srcName, loads the user's -// profile, and returns a Session that may be used for impersonating that user, -// or optionally creating processes as that user. Logs will be written to logf, -// if provided. srcName must be non-empty, ASCII-only, and no longer than 8 -// characters. -// -// The current OS thread's access token must have SeTcbPrivilege. -func Login(logf logger.Logf, srcName string, u *user.User, capLevel CapabilityLevel) (sess *Session, err error) { - token, err := createToken(srcName, u, tokenTypeImpersonation, capLevel) - if err != nil { - return nil, err - } - tokenCloseOnce := sync.OnceFunc(func() { token.Close() }) - defer func() { - if err != nil { - tokenCloseOnce() - } - }() - - sessToken := token - if capLevel == CapCreateProcess { - // Obtain token's security descriptor so that it may be applied to - // a primary token. - sd, err := windows.GetSecurityInfo(windows.Handle(token), - windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION) - if err != nil { - return nil, err - } - - sa := windows.SecurityAttributes{ - Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), - SecurityDescriptor: sd, - } - - // token is an impersonation token. Upgrade us to a primary token so that - // our StartProcess* methods will work correctly. - var dupToken windows.Token - if err := windows.DuplicateTokenEx(token, 0, &sa, windows.SecurityImpersonation, - windows.TokenPrimary, &dupToken); err != nil { - return nil, err - } - sessToken = dupToken - defer func() { - if err != nil { - sessToken.Close() - } - }() - tokenCloseOnce() - } - - userProfile, err := winutil.LoadUserProfile(sessToken, u) - if err != nil { - return nil, err - } - - if logf == nil { - logf = logger.Discard - } else { - logf = logger.WithPrefix(logf, "(s4u) ") - } - - return &Session{logf: logf, token: sessToken, userProfile: userProfile, capLevel: capLevel}, nil -} - -// Close unloads the user profile and S4U access token associated with the -// session. The close operation is not guaranteed to have finished when Close -// returns; it may remain alive until all processes created by ss have -// themselves been closed, and no more Do requests are pending. -func (ss *Session) Close() error { - refs := ss.refCnt.Load() - if (refs & 1) != 0 { - // Close already called - return nil - } - - // Set the low bit to indicate that a close operation has been requested. - // We don't have atomic OR so we need to use CAS. Sigh. - for !ss.refCnt.CompareAndSwap(refs, refs|1) { - refs = ss.refCnt.Load() - } - - if refs > 1 { - // Still active processes, just return. - return nil - } - - return ss.closeInternal() -} - -func (ss *Session) closeInternal() error { - if ss.userProfile != nil { - if err := ss.userProfile.Close(); err != nil { - return err - } - ss.userProfile = nil - } - - if ss.token != 0 { - if err := ss.token.Close(); err != nil { - return err - } - ss.token = 0 - } - return nil -} - -// CapabilityLevel returns the CapabilityLevel that was specified when the -// session was created. -func (ss *Session) CapabilityLevel() CapabilityLevel { - return ss.capLevel -} - -// Do executes fn while impersonating ss's user. Impersonation only affects -// the current goroutine; any new goroutines spawned by fn will not be -// impersonated. Do may be called concurrently by multiple goroutines. -// -// Do returns an error if impersonation did not succeed and fn could not be run. -// If called after ss has already been closed, it will panic. -func (ss *Session) Do(fn func()) error { - if fn == nil { - return os.ErrInvalid - } - - ss.addRef() - defer ss.release() - - // Impersonation touches thread-local state. - runtime.LockOSThread() - defer runtime.UnlockOSThread() - if err := impersonateLoggedOnUser(ss.token); err != nil { - return err - } - defer func() { - if err := windows.RevertToSelf(); err != nil { - // This is not recoverable in any way, shape, or form! - panic(fmt.Sprintf("RevertToSelf failed: %v", err)) - } - }() - - fn() - return nil -} - -func (ss *Session) addRef() { - if (ss.refCnt.Add(2) & 1) != 0 { - panic("addRef after Close") - } -} - -func (ss *Session) release() { - rc := ss.refCnt.Add(-2) - if rc < 0 { - panic("negative refcount") - } - if rc == 1 { - ss.closeInternal() - } -} - -type startProcessOpts struct { - token windows.Token - extraEnv map[string]string - ptySize windows.Coord - pipes bool -} - -// StartProcess creates a new process running under ss via cmdLineInfo. -// The process will either be started with its working directory set to the S4U -// user's profile directory, or for Administrative users, the system32 -// directory. The child process will receive the S4U user's environment. -// extraEnv, when specified, contains any additional environment -// variables to be inserted into the environment. -// -// If called after ss has already been closed, StartProcess will panic. -func (ss *Session) StartProcess(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string) (psp *Process, err error) { - if ss.capLevel != CapCreateProcess { - return nil, errInsufficientCapabilityLevel - } - - opts := startProcessOpts{ - token: ss.token, - extraEnv: extraEnv, - } - return startProcessInternal(ss, ss.logf, cmdLineInfo, opts) -} - -// StartProcessWithPTY creates a new process running under ss via cmdLineInfo -// with a pseudoconsole initialized to initialPtySize. The resulting Process -// will return non-nil values from Stdin and Stdout, but Stderr will return nil. -// The process will either be started with its working directory set to the S4U -// user's profile directory, or for Administrative users, the system32 -// directory. The child process will receive the S4U user's environment. -// extraEnv, when specified, contains any additional environment -// variables to be inserted into the environment. -// -// If called after ss has already been closed, StartProcessWithPTY will panic. -func (ss *Session) StartProcessWithPTY(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string, initialPtySize windows.Coord) (psp *Process, err error) { - if ss.capLevel != CapCreateProcess { - return nil, errInsufficientCapabilityLevel - } - - opts := startProcessOpts{ - token: ss.token, - extraEnv: extraEnv, - ptySize: initialPtySize, - } - return startProcessInternal(ss, ss.logf, cmdLineInfo, opts) -} - -// StartProcessWithPipes creates a new process running under ss via cmdLineInfo -// with all standard handles set to pipes. The resulting Process will return -// non-nil values from Stdin, Stdout, and Stderr. -// The process will either be started with its working directory set to the S4U -// user's profile directory, or for Administrative users, the system32 -// directory. The child process will receive the S4U user's environment. -// extraEnv, when specified, contains any additional environment -// variables to be inserted into the environment. -// -// If called after ss has already been closed, StartProcessWithPipes will panic. -func (ss *Session) StartProcessWithPipes(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string) (psp *Process, err error) { - if ss.capLevel != CapCreateProcess { - return nil, errInsufficientCapabilityLevel - } - - opts := startProcessOpts{ - token: ss.token, - extraEnv: extraEnv, - pipes: true, - } - return startProcessInternal(ss, ss.logf, cmdLineInfo, opts) -} - -// startProcessInternal is the common implementation behind Session's exported -// StartProcess* methods. It uses opts to distinguish between the various -// requested modes of operation. -// -// A note on pseudoconsoles: -// The conpty API currently does not provide a way to create a pseudoconsole for -// a different user than the current process. The way we deal with this is -// to first create a "relay" process running with the desired user token, -// and then create the actual requested process as a child of the relay, -// at which time we create the pseudoconsole. The relay simply copies the -// PTY's I/O into/out of its own stdin and stdout, which are piped to the -// parent still running as LocalSystem. We also relay pseudoconsole resize requests. -func startProcessInternal(ss *Session, logf logger.Logf, cmdLineInfo winutil.CommandLineInfo, opts startProcessOpts) (psp *Process, err error) { - var sib winutil.StartupInfoBuilder - defer sib.Close() - - var sp Process - defer func() { - if err != nil { - sp.Close() - } - }() - - var zeroCoord windows.Coord - ptySizeValid := opts.ptySize != zeroCoord - useToken := opts.token != 0 - usePty := ptySizeValid && !useToken - useRelay := ptySizeValid && useToken - useSystem32WD := useToken && opts.token.IsElevated() - - if usePty { - sp.pty, err = conpty.NewPseudoConsole(opts.ptySize) - if err != nil { - return nil, err - } - - if err := sp.pty.ConfigureStartupInfo(&sib); err != nil { - return nil, err - } - - sp.wStdin = sp.pty.InputPipe() - sp.rStdout = sp.pty.OutputPipe() - } else if useRelay || opts.pipes { - if sp.wStdin, sp.rStdout, sp.rStderr, err = createStdPipes(&sib); err != nil { - return nil, err - } - } - - var relayStderr io.ReadCloser - if useRelay { - // Later on we're going to use stderr for logging instead of providing it to the caller. - relayStderr = sp.rStderr - sp.rStderr = nil - defer func() { - if err != nil { - relayStderr.Close() - } - }() - - // Set up a pipe to send PTY resize requests. - var resizeRead, resizeWrite windows.Handle - if err := windows.CreatePipe(&resizeRead, &resizeWrite, nil, 0); err != nil { - return nil, err - } - sp.wResize = os.NewFile(uintptr(resizeWrite), "wPTYResizePipe") - defer windows.CloseHandle(resizeRead) - if err := sib.InheritHandles(resizeRead); err != nil { - return nil, err - } - - // Revise the command line. First, get the existing one. - _, _, strCmdLine, err := cmdLineInfo.Resolve() - if err != nil { - return nil, err - } - - // Now rebuild it, passing the strCmdLine as the --cmd argument... - newArgs := []string{ - "be-child", "s4u", - "--resize", fmt.Sprintf("0x%x", uintptr(resizeRead)), - "--x", strconv.Itoa(int(opts.ptySize.X)), - "--y", strconv.Itoa(int(opts.ptySize.Y)), - "--cmd", strCmdLine, - } - - // ...to be passed in as arguments to our own executable. - cmdLineInfo.ExePath, err = os.Executable() - if err != nil { - return nil, err - } - cmdLineInfo.SetArgs(newArgs) - } - - exePath, cmdLine, cmdLineStr, err := cmdLineInfo.Resolve() - if err != nil { - return nil, err - } - logf("starting %s", cmdLineStr) - - var env []string - var wd16 *uint16 - if useToken { - env, err = opts.token.Environ(false) - if err != nil { - return nil, err - } - - folderID := windows.FOLDERID_Profile - if useSystem32WD { - folderID = windows.FOLDERID_System - } - wd, err := opts.token.KnownFolderPath(folderID, windows.KF_FLAG_DEFAULT) - if err != nil { - return nil, err - } - wd16, err = windows.UTF16PtrFromString(wd) - if err != nil { - return nil, err - } - } else { - env = os.Environ() - } - - env = mergeEnv(env, opts.extraEnv) - - var env16 *uint16 - if useToken || len(opts.extraEnv) > 0 { - env16 = winutil.NewEnvBlock(env) - } - - if useToken { - // We want the child process to be assigned to job such that when it exits, - // its descendents within the job will be terminated as well. - job, err := createJob() - if err != nil { - return nil, err - } - // We don't need to hang onto job beyond this func... - defer job.Close() - - if err := sib.AssignToJob(job.Handle()); err != nil { - return nil, err - } - - // ...because we're now gonna make a read-only copy... - qjob, err := job.QueryOnlyClone() - if err != nil { - return nil, err - } - defer qjob.Close() - - // ...which will be inherited by the child process. - // When the child process terminates, the job will too. - if err := sib.InheritHandles(qjob.Handle()); err != nil { - return nil, err - } - } - - si, inheritHandles, creationFlags, err := sib.Resolve() - if err != nil { - return nil, err - } - - var pi windows.ProcessInformation - if useToken { - // DETACHED_PROCESS so that the child does not receive a console. - // CREATE_NEW_PROCESS_GROUP so that the child's console group is isolated from ours. - creationFlags |= windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP - doCreate := func() { - err = windows.CreateProcessAsUser(opts.token, exePath, cmdLine, nil, nil, inheritHandles, creationFlags, env16, wd16, si, &pi) - } - switch { - case useRelay: - doCreate() - case ss != nil: - // We want to ensure that the executable is accessible via the token's - // security context, not ours. - if err := ss.Do(doCreate); err != nil { - return nil, err - } - default: - panic("should not have reached here") - } - } else { - err = windows.CreateProcess(exePath, cmdLine, nil, nil, inheritHandles, creationFlags, env16, wd16, si, &pi) - } - if err != nil { - return nil, err - } - windows.CloseHandle(pi.Thread) - - if relayStderr != nil { - logw := logger.FuncWriter(logger.WithPrefix(logf, fmt.Sprintf("(s4u relay process %d [0x%x]) ", pi.ProcessId, pi.ProcessId))) - go func() { - defer relayStderr.Close() - io.Copy(logw, relayStderr) - }() - } - - sp.hproc = pi.Process - sp.pid = pi.ProcessId - if ss != nil { - ss.addRef() - sp.sess = ss - } - return &sp, nil -} - -type jobObject windows.Handle - -func createJob() (job *jobObject, err error) { - hjob, err := windows.CreateJobObject(nil, nil) - if err != nil { - return nil, err - } - defer func() { - if err != nil { - windows.CloseHandle(hjob) - } - }() - - limitInfo := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ - BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ - // We want every process within the job to terminate when the job is closed. - // We also want to allow processes within the job to create child processes - // that are outside the job (otherwise you couldn't leave background - // processes running after exiting a session, for example). - // These flags also match those used by the Win32 port of OpenSSH. - LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK, - }, - } - _, err = windows.SetInformationJobObject(hjob, - windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limitInfo)), - uint32(unsafe.Sizeof(limitInfo))) - if err != nil { - return nil, err - } - - jo := jobObject(hjob) - return &jo, nil -} - -func (job *jobObject) Close() error { - if hjob := job.Handle(); hjob != 0 { - windows.CloseHandle(hjob) - *job = 0 - } - return nil -} - -func (job *jobObject) Handle() windows.Handle { - if job == nil { - return 0 - } - return windows.Handle(*job) -} - -const _JOB_OBJECT_QUERY = 0x0004 - -func (job *jobObject) QueryOnlyClone() (*jobObject, error) { - hjob := job.Handle() - cp := windows.CurrentProcess() - - var dupe windows.Handle - err := windows.DuplicateHandle(cp, hjob, cp, &dupe, _JOB_OBJECT_QUERY, true, 0) - if err != nil { - return nil, err - } - - result := jobObject(dupe) - return &result, nil -} - -func createStdPipes(sib *winutil.StartupInfoBuilder) (stdin io.WriteCloser, stdout, stderr io.ReadCloser, err error) { - var rStdin, wStdin windows.Handle - if err := windows.CreatePipe(&rStdin, &wStdin, nil, 0); err != nil { - return nil, nil, nil, err - } - defer func() { - if err != nil { - windows.CloseHandle(rStdin) - windows.CloseHandle(wStdin) - } - }() - - var rStdout, wStdout windows.Handle - if err := windows.CreatePipe(&rStdout, &wStdout, nil, 0); err != nil { - return nil, nil, nil, err - } - defer func() { - if err != nil { - windows.CloseHandle(rStdout) - windows.CloseHandle(wStdout) - } - }() - - var rStderr, wStderr windows.Handle - if err := windows.CreatePipe(&rStderr, &wStderr, nil, 0); err != nil { - return nil, nil, nil, err - } - defer func() { - if err != nil { - windows.CloseHandle(rStderr) - windows.CloseHandle(wStderr) - } - }() - - if err := sib.SetStdHandles(rStdin, wStdout, wStderr); err != nil { - return nil, nil, nil, err - } - - stdin = os.NewFile(uintptr(wStdin), "wStdin") - stdout = os.NewFile(uintptr(rStdout), "rStdout") - stderr = os.NewFile(uintptr(rStderr), "rStderr") - return stdin, stdout, stderr, nil -} - -// Process encapsulates a child process started with a Session. -type Process struct { - sess *Session - wStdin io.WriteCloser - rStdout io.ReadCloser - rStderr io.ReadCloser - wResize io.WriteCloser - pty *conpty.PseudoConsole - hproc windows.Handle - pid uint32 -} - -// Stdin returns the write side of a pipe connected to the child process's -// stdin, or nil if no I/O was requested. -func (sp *Process) Stdin() io.WriteCloser { - return sp.wStdin -} - -// Stdout returns the read side of a pipe connected to the child process's -// stdout, or nil if no I/O was requested. -func (sp *Process) Stdout() io.ReadCloser { - return sp.rStdout -} - -// Stderr returns the read side of a pipe connected to the child process's -// stderr, or nil if no I/O was requested. -func (sp *Process) Stderr() io.ReadCloser { - return sp.rStderr -} - -// Terminate kills the process. -func (sp *Process) Terminate() { - if sp.hproc != 0 { - windows.TerminateProcess(sp.hproc, 255) - } -} - -// Close waits for sp to complete and then cleans up any resources owned by it. -// Close must wait because the Session associated with sp should not be destroyed -// until all its processes have terminated. If necessary, call Terminate to -// forcibly end the process. -// -// If the process was created with a pseudoconsole then the caller must continue -// concurrently draining sp's stdout until either Close finishes executing, or EOF. -func (sp *Process) Close() error { - for _, pc := range []*io.WriteCloser{&sp.wStdin, &sp.wResize} { - if *pc == nil { - continue - } - (*pc).Close() - (*pc) = nil - } - - if sp.pty != nil { - if err := sp.pty.Close(); err != nil { - return err - } - sp.pty = nil - } - - if sp.hproc != 0 { - if _, err := sp.Wait(); err != nil { - return err - } - windows.CloseHandle(sp.hproc) - sp.hproc = 0 - sp.pid = 0 - if sp.sess != nil { - sp.sess.release() - sp.sess = nil - } - } - - // Order is important here. Do not close sp.rStdout until _after_ - // ss.pty (when present) has been closed! We're going to do one better by - // doing this after the process is done. - for _, pc := range []*io.ReadCloser{&sp.rStdout, &sp.rStderr} { - if *pc == nil { - continue - } - (*pc).Close() - (*pc) = nil - } - return nil -} - -// Wait blocks the caller until sp terminates. It returns the process exit code. -// exitCode will be set to 254 if the process terminated but the exit code could -// not be retrieved. -func (sp *Process) Wait() (exitCode uint32, err error) { - _, err = windows.WaitForSingleObject(sp.hproc, windows.INFINITE) - if err == nil { - if err := windows.GetExitCodeProcess(sp.hproc, &exitCode); err != nil { - exitCode = 254 - } - } - return exitCode, err -} - -// OSProcess returns an *os.Process associated with sp. This is useful for -// integration with external code that expects an os.Process. -func (sp *Process) OSProcess() (*os.Process, error) { - if sp.hproc == 0 { - return nil, winutil.ErrDefunctProcess - } - return os.FindProcess(int(sp.pid)) -} - -// PTYResizer returns a function to be called to resize the pseudoconsole. -// It returns nil if no pseudoconsole was requested when creating sp. -func (sp *Process) PTYResizer() func(windows.Coord) error { - if sp.wResize != nil { - wResize := sp.wResize - return func(c windows.Coord) error { - return binary.Write(wResize, binary.LittleEndian, c) - } - } - - if sp.pty != nil { - pty := sp.pty - return func(c windows.Coord) error { - return pty.Resize(c) - } - } - - return nil -} - -type relayArgs struct { - command string - resize string - ptyX int - ptyY int -} - -func parseRelayArgs(args []string) (a relayArgs) { - flags := flag.NewFlagSet("", flag.ExitOnError) - flags.StringVar(&a.command, "cmd", "", "the command to run") - flags.StringVar(&a.resize, "resize", "", "handle to resize pipe") - flags.IntVar(&a.ptyX, "x", 80, "initial width of pty") - flags.IntVar(&a.ptyY, "y", 25, "initial height of pty") - flags.Parse(args) - return a -} - -func flagSizeErr(flagName byte) error { - return fmt.Errorf("--%c must be greater than zero and less than %d", flagName, math.MaxInt16) -} - -const debugRelay = false - -func beRelay(args []string) error { - ra := parseRelayArgs(args) - if ra.command == "" { - return fmt.Errorf("--cmd must be specified") - } - - bitSize := int(unsafe.Sizeof(windows.Handle(0)) * 8) - resize64, err := strconv.ParseUint(ra.resize, 0, bitSize) - if err != nil { - return err - } - hResize := windows.Handle(resize64) - if ft, _ := windows.GetFileType(hResize); ft != windows.FILE_TYPE_PIPE { - return fmt.Errorf("--resize is an invalid handle type") - } - resize := os.NewFile(uintptr(hResize), "rPTYResizePipe") - defer resize.Close() - - switch { - case ra.ptyX <= 0 || ra.ptyX > math.MaxInt16: - return flagSizeErr('x') - case ra.ptyY <= 0 || ra.ptyY > math.MaxInt16: - return flagSizeErr('y') - default: - } - - logf := logger.Discard - if debugRelay { - // Our parent process will write our stderr to its log. - logf = func(format string, args ...any) { - fmt.Fprintf(os.Stderr, format, args...) - } - } - - logf("starting") - argv, err := windows.DecomposeCommandLine(ra.command) - if err != nil { - logf("DecomposeCommandLine failed: %v", err) - return err - } - - cli := winutil.CommandLineInfo{ - ExePath: argv[0], - } - cli.SetArgs(argv[1:]) - - opts := startProcessOpts{ - ptySize: windows.Coord{X: int16(ra.ptyX), Y: int16(ra.ptyY)}, - } - psp, err := startProcessInternal(nil, logf, cli, opts) - if err != nil { - logf("startProcessInternal failed: %v", err) - return err - } - defer psp.Close() - - go resizeLoop(logf, resize, psp.PTYResizer()) - if debugRelay { - go debugLogPTYInput(logf, psp.wStdin, os.Stdin) - go debugLogPTYOutput(logf, os.Stdout, psp.rStdout) - } else { - go io.Copy(psp.wStdin, os.Stdin) - go io.Copy(os.Stdout, psp.rStdout) - } - - exitCode, err := psp.Wait() - if err != nil { - logf("waiting on relayed process: %v", err) - return err - } - if exitCode > 0 { - logf("relayed process returned %v", exitCode) - } - - if err := psp.Close(); err != nil { - logf("s4u.Process.Close error: %v", err) - return err - } - return nil -} - -func resizeLoop(logf logger.Logf, resizePipe io.Reader, resizeFn func(windows.Coord) error) { - var coord windows.Coord - for binary.Read(resizePipe, binary.LittleEndian, &coord) == nil { - logf("resizing pty window to %#v", coord) - resizeFn(coord) - } -} - -func debugLogPTYInput(logf logger.Logf, w io.Writer, r io.Reader) { - logw := logger.FuncWriter(logger.WithPrefix(logf, "(pty input) ")) - io.Copy(io.MultiWriter(w, logw), r) -} - -func debugLogPTYOutput(logf logger.Logf, w io.Writer, r io.Reader) { - logw := logger.FuncWriter(logger.WithPrefix(logf, "(pty output) ")) - io.Copy(w, io.TeeReader(r, logw)) -} - -// mergeEnv returns the union of existingEnv and extraEnv, deduplicated and -// sorted. -func mergeEnv(existingEnv []string, extraEnv map[string]string) []string { - if len(extraEnv) == 0 { - return existingEnv - } - - mergedMap := make(map[string]string, len(existingEnv)+len(extraEnv)) - for _, line := range existingEnv { - k, v, _ := strings.Cut(line, "=") - mergedMap[strings.ToUpper(k)] = v - } - - for k, v := range extraEnv { - mergedMap[strings.ToUpper(k)] = v - } - - result := make([]string, 0, len(mergedMap)) - for k, v := range mergedMap { - result = append(result, strings.Join([]string{k, v}, "=")) - } - - slices.SortFunc(result, func(l, r string) int { - kl, _, _ := strings.Cut(l, "=") - kr, _, _ := strings.Cut(r, "=") - return strings.Compare(kl, kr) - }) - return result -} diff --git a/util/winutil/s4u/zsyscall_windows.go b/util/winutil/s4u/zsyscall_windows.go deleted file mode 100644 index 6a8c78427dbd3..0000000000000 --- a/util/winutil/s4u/zsyscall_windows.go +++ /dev/null @@ -1,104 +0,0 @@ -// Code generated by 'go generate'; DO NOT EDIT. - -package s4u - -import ( - "syscall" - "unsafe" - - "golang.org/x/sys/windows" -) - -var _ unsafe.Pointer - -// Do the interface allocations only once for common -// Errno values. -const ( - errnoERROR_IO_PENDING = 997 -) - -var ( - errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) - errERROR_EINVAL error = syscall.EINVAL -) - -// errnoErr returns common boxed Errno values, to prevent -// allocations at runtime. -func errnoErr(e syscall.Errno) error { - switch e { - case 0: - return errERROR_EINVAL - case errnoERROR_IO_PENDING: - return errERROR_IO_PENDING - } - // TODO: add more here, after collecting data on the common - // error values see on Windows. (perhaps when running - // all.bat?) - return e -} - -var ( - modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") - modsecur32 = windows.NewLazySystemDLL("secur32.dll") - - procAllocateLocallyUniqueId = modadvapi32.NewProc("AllocateLocallyUniqueId") - procImpersonateLoggedOnUser = modadvapi32.NewProc("ImpersonateLoggedOnUser") - procLsaConnectUntrusted = modsecur32.NewProc("LsaConnectUntrusted") - procLsaDeregisterLogonProcess = modsecur32.NewProc("LsaDeregisterLogonProcess") - procLsaFreeReturnBuffer = modsecur32.NewProc("LsaFreeReturnBuffer") - procLsaLogonUser = modsecur32.NewProc("LsaLogonUser") - procLsaLookupAuthenticationPackage = modsecur32.NewProc("LsaLookupAuthenticationPackage") - procLsaRegisterLogonProcess = modsecur32.NewProc("LsaRegisterLogonProcess") -) - -func allocateLocallyUniqueId(luid *windows.LUID) (err error) { - r1, _, e1 := syscall.Syscall(procAllocateLocallyUniqueId.Addr(), 1, uintptr(unsafe.Pointer(luid)), 0, 0) - if int32(r1) == 0 { - err = errnoErr(e1) - } - return -} - -func impersonateLoggedOnUser(token windows.Token) (err error) { - r1, _, e1 := syscall.Syscall(procImpersonateLoggedOnUser.Addr(), 1, uintptr(token), 0, 0) - if int32(r1) == 0 { - err = errnoErr(e1) - } - return -} - -func lsaConnectUntrusted(lsaHandle *_LSAHANDLE) (ret windows.NTStatus) { - r0, _, _ := syscall.Syscall(procLsaConnectUntrusted.Addr(), 1, uintptr(unsafe.Pointer(lsaHandle)), 0, 0) - ret = windows.NTStatus(r0) - return -} - -func lsaDeregisterLogonProcess(lsaHandle _LSAHANDLE) (ret windows.NTStatus) { - r0, _, _ := syscall.Syscall(procLsaDeregisterLogonProcess.Addr(), 1, uintptr(lsaHandle), 0, 0) - ret = windows.NTStatus(r0) - return -} - -func lsaFreeReturnBuffer(buffer uintptr) (ret windows.NTStatus) { - r0, _, _ := syscall.Syscall(procLsaFreeReturnBuffer.Addr(), 1, uintptr(buffer), 0, 0) - ret = windows.NTStatus(r0) - return -} - -func lsaLogonUser(lsaHandle _LSAHANDLE, originName *windows.NTString, logonType _SECURITY_LOGON_TYPE, authenticationPackage uint32, authenticationInformation unsafe.Pointer, authenticationInformationLength uint32, localGroups *windows.Tokengroups, sourceContext *_TOKEN_SOURCE, profileBuffer *uintptr, profileBufferLength *uint32, logonID *windows.LUID, token *windows.Token, quotas *_QUOTA_LIMITS, subStatus *windows.NTStatus) (ret windows.NTStatus) { - r0, _, _ := syscall.Syscall15(procLsaLogonUser.Addr(), 14, uintptr(lsaHandle), uintptr(unsafe.Pointer(originName)), uintptr(logonType), uintptr(authenticationPackage), uintptr(authenticationInformation), uintptr(authenticationInformationLength), uintptr(unsafe.Pointer(localGroups)), uintptr(unsafe.Pointer(sourceContext)), uintptr(unsafe.Pointer(profileBuffer)), uintptr(unsafe.Pointer(profileBufferLength)), uintptr(unsafe.Pointer(logonID)), uintptr(unsafe.Pointer(token)), uintptr(unsafe.Pointer(quotas)), uintptr(unsafe.Pointer(subStatus)), 0) - ret = windows.NTStatus(r0) - return -} - -func lsaLookupAuthenticationPackage(lsaHandle _LSAHANDLE, packageName *windows.NTString, authenticationPackage *uint32) (ret windows.NTStatus) { - r0, _, _ := syscall.Syscall(procLsaLookupAuthenticationPackage.Addr(), 3, uintptr(lsaHandle), uintptr(unsafe.Pointer(packageName)), uintptr(unsafe.Pointer(authenticationPackage))) - ret = windows.NTStatus(r0) - return -} - -func lsaRegisterLogonProcess(logonProcessName *windows.NTString, lsaHandle *_LSAHANDLE, securityMode *_LSA_OPERATIONAL_MODE) (ret windows.NTStatus) { - r0, _, _ := syscall.Syscall(procLsaRegisterLogonProcess.Addr(), 3, uintptr(unsafe.Pointer(logonProcessName)), uintptr(unsafe.Pointer(lsaHandle)), uintptr(unsafe.Pointer(securityMode))) - ret = windows.NTStatus(r0) - return -} diff --git a/util/winutil/subprocess_windows_test.go b/util/winutil/subprocess_windows_test.go deleted file mode 100644 index f7c205d61c393..0000000000000 --- a/util/winutil/subprocess_windows_test.go +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright 2015 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package winutil - -import ( - "bytes" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" -) - -// The code in this file is adapted from internal/testenv in the Go source tree -// and is used for writing tests that require spawning subprocesses. - -var toRemove []string - -func TestMain(m *testing.M) { - status := m.Run() - for _, file := range toRemove { - os.RemoveAll(file) - } - os.Exit(status) -} - -var testprog struct { - sync.Mutex - dir string - target map[string]*buildexe -} - -type buildexe struct { - once sync.Once - exe string - err error -} - -func pathToTestProg(t *testing.T, binary string) string { - exe, err := buildTestProg(t, binary, "-buildvcs=false") - if err != nil { - t.Fatal(err) - } - return exe -} - -func startTestProg(t *testing.T, binary, name string, env ...string) { - exe, err := buildTestProg(t, binary, "-buildvcs=false") - if err != nil { - t.Fatal(err) - } - - startBuiltTestProg(t, exe, name, env...) -} - -func startBuiltTestProg(t *testing.T, exe, name string, env ...string) { - cmd := exec.Command(exe, name) - cmd.Env = append(cmd.Env, env...) - if testing.Short() { - cmd.Env = append(cmd.Env, "RUNTIME_TEST_SHORT=1") - } - start(t, cmd) -} - -var serializeBuild = make(chan bool, 2) - -func buildTestProg(t *testing.T, binary string, flags ...string) (string, error) { - testprog.Lock() - if testprog.dir == "" { - dir, err := os.MkdirTemp("", "go-build") - if err != nil { - t.Fatalf("failed to create temp directory: %v", err) - } - testprog.dir = dir - toRemove = append(toRemove, dir) - } - - if testprog.target == nil { - testprog.target = make(map[string]*buildexe) - } - name := binary - if len(flags) > 0 { - nameFlags := make([]string, 0, len(flags)) - for _, flag := range flags { - nameFlags = append(nameFlags, strings.ReplaceAll(flag, "=", "_")) - } - name += "_" + strings.Join(nameFlags, "_") - } - target, ok := testprog.target[name] - if !ok { - target = &buildexe{} - testprog.target[name] = target - } - - dir := testprog.dir - - // Unlock testprog while actually building, so that other - // tests can look up executables that were already built. - testprog.Unlock() - - target.once.Do(func() { - // Only do two "go build"'s at a time, - // to keep load from getting too high. - serializeBuild <- true - defer func() { <-serializeBuild }() - - // Don't get confused if goToolPath calls t.Skip. - target.err = errors.New("building test called t.Skip") - - exe := filepath.Join(dir, name+".exe") - - t.Logf("running go build -o %s %s", exe, strings.Join(flags, " ")) - cmd := exec.Command(goToolPath(t), append([]string{"build", "-o", exe}, flags...)...) - cmd.Dir = "testdata/" + binary - out, err := cmd.CombinedOutput() - if err != nil { - target.err = fmt.Errorf("building %s %v: %v\n%s", binary, flags, err, out) - } else { - target.exe = exe - target.err = nil - } - }) - - return target.exe, target.err -} - -// goTool reports the path to the Go tool. -func goTool() (string, error) { - if !hasGoBuild() { - return "", errors.New("platform cannot run go tool") - } - exeSuffix := ".exe" - goroot, err := findGOROOT() - if err != nil { - return "", fmt.Errorf("cannot find go tool: %w", err) - } - path := filepath.Join(goroot, "bin", "go"+exeSuffix) - if _, err := os.Stat(path); err == nil { - return path, nil - } - goBin, err := exec.LookPath("go" + exeSuffix) - if err != nil { - return "", errors.New("cannot find go tool: " + err.Error()) - } - return goBin, nil -} - -// knownEnv is a list of environment variables that affect the operation -// of the Go command. -const knownEnv = ` - AR - CC - CGO_CFLAGS - CGO_CFLAGS_ALLOW - CGO_CFLAGS_DISALLOW - CGO_CPPFLAGS - CGO_CPPFLAGS_ALLOW - CGO_CPPFLAGS_DISALLOW - CGO_CXXFLAGS - CGO_CXXFLAGS_ALLOW - CGO_CXXFLAGS_DISALLOW - CGO_ENABLED - CGO_FFLAGS - CGO_FFLAGS_ALLOW - CGO_FFLAGS_DISALLOW - CGO_LDFLAGS - CGO_LDFLAGS_ALLOW - CGO_LDFLAGS_DISALLOW - CXX - FC - GCCGO - GO111MODULE - GO386 - GOAMD64 - GOARCH - GOARM - GOBIN - GOCACHE - GOENV - GOEXE - GOEXPERIMENT - GOFLAGS - GOGCCFLAGS - GOHOSTARCH - GOHOSTOS - GOINSECURE - GOMIPS - GOMIPS64 - GOMODCACHE - GONOPROXY - GONOSUMDB - GOOS - GOPATH - GOPPC64 - GOPRIVATE - GOPROXY - GOROOT - GOSUMDB - GOTMPDIR - GOTOOLDIR - GOVCS - GOWASM - GOWORK - GO_EXTLINK_ENABLED - PKG_CONFIG -` - -// goToolPath reports the path to the Go tool. -// It is a convenience wrapper around goTool. -// If the tool is unavailable goToolPath calls t.Skip. -// If the tool should be available and isn't, goToolPath calls t.Fatal. -func goToolPath(t testing.TB) string { - mustHaveGoBuild(t) - path, err := goTool() - if err != nil { - t.Fatal(err) - } - // Add all environment variables that affect the Go command to test metadata. - // Cached test results will be invalidate when these variables change. - // See golang.org/issue/32285. - for _, envVar := range strings.Fields(knownEnv) { - os.Getenv(envVar) - } - return path -} - -// hasGoBuild reports whether the current system can build programs with “go build” -// and then run them with os.StartProcess or exec.Command. -func hasGoBuild() bool { - if os.Getenv("GO_GCFLAGS") != "" { - // It's too much work to require every caller of the go command - // to pass along "-gcflags="+os.Getenv("GO_GCFLAGS"). - // For now, if $GO_GCFLAGS is set, report that we simply can't - // run go build. - return false - } - return true -} - -// mustHaveGoBuild checks that the current system can build programs with “go build” -// and then run them with os.StartProcess or exec.Command. -// If not, mustHaveGoBuild calls t.Skip with an explanation. -func mustHaveGoBuild(t testing.TB) { - if os.Getenv("GO_GCFLAGS") != "" { - t.Skipf("skipping test: 'go build' not compatible with setting $GO_GCFLAGS") - } - if !hasGoBuild() { - t.Skipf("skipping test: 'go build' not available on %s/%s", runtime.GOOS, runtime.GOARCH) - } -} - -var ( - gorootOnce sync.Once - gorootPath string - gorootErr error -) - -func findGOROOT() (string, error) { - gorootOnce.Do(func() { - gorootPath = runtime.GOROOT() - if gorootPath != "" { - // If runtime.GOROOT() is non-empty, assume that it is valid. - // - // (It might not be: for example, the user may have explicitly set GOROOT - // to the wrong directory, or explicitly set GOROOT_FINAL but not GOROOT - // and hasn't moved the tree to GOROOT_FINAL yet. But those cases are - // rare, and if that happens the user can fix what they broke.) - return - } - - // runtime.GOROOT doesn't know where GOROOT is (perhaps because the test - // binary was built with -trimpath, or perhaps because GOROOT_FINAL was set - // without GOROOT and the tree hasn't been moved there yet). - // - // Since this is internal/testenv, we can cheat and assume that the caller - // is a test of some package in a subdirectory of GOROOT/src. ('go test' - // runs the test in the directory containing the packaged under test.) That - // means that if we start walking up the tree, we should eventually find - // GOROOT/src/go.mod, and we can report the parent directory of that. - - cwd, err := os.Getwd() - if err != nil { - gorootErr = fmt.Errorf("finding GOROOT: %w", err) - return - } - - dir := cwd - for { - parent := filepath.Dir(dir) - if parent == dir { - // dir is either "." or only a volume name. - gorootErr = fmt.Errorf("failed to locate GOROOT/src in any parent directory") - return - } - - if base := filepath.Base(dir); base != "src" { - dir = parent - continue // dir cannot be GOROOT/src if it doesn't end in "src". - } - - b, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - if os.IsNotExist(err) { - dir = parent - continue - } - gorootErr = fmt.Errorf("finding GOROOT: %w", err) - return - } - goMod := string(b) - - for goMod != "" { - var line string - line, goMod, _ = strings.Cut(goMod, "\n") - fields := strings.Fields(line) - if len(fields) >= 2 && fields[0] == "module" && fields[1] == "std" { - // Found "module std", which is the module declaration in GOROOT/src! - gorootPath = parent - return - } - } - } - }) - - return gorootPath, gorootErr -} - -// start runs cmd asynchronously and returns immediately. -func start(t testing.TB, cmd *exec.Cmd) { - args := cmd.Args - if args == nil { - args = []string{cmd.Path} - } - - var b bytes.Buffer - cmd.Stdout = &b - cmd.Stderr = &b - if err := cmd.Start(); err != nil { - t.Fatalf("starting %s: %v", args, err) - } -} diff --git a/util/winutil/svcdiag_windows.go b/util/winutil/svcdiag_windows.go index 372377cf93217..3c02c2f0ccc28 100644 --- a/util/winutil/svcdiag_windows.go +++ b/util/winutil/svcdiag_windows.go @@ -10,11 +10,11 @@ import ( "strings" "unsafe" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/set" "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc/mgr" - "tailscale.com/types/logger" - "tailscale.com/util/set" ) // LogSvcState obtains the state of the Windows service named rootSvcName and diff --git a/util/winutil/userprofile_windows.go b/util/winutil/userprofile_windows.go index d2e6067c7a93f..b47331434e480 100644 --- a/util/winutil/userprofile_windows.go +++ b/util/winutil/userprofile_windows.go @@ -8,10 +8,10 @@ import ( "strings" "unsafe" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/winutil/winenv" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "tailscale.com/types/logger" - "tailscale.com/util/winutil/winenv" ) type _PROFILEINFO struct { diff --git a/util/winutil/userprofile_windows_test.go b/util/winutil/userprofile_windows_test.go deleted file mode 100644 index 09dcfd59627aa..0000000000000 --- a/util/winutil/userprofile_windows_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package winutil - -import ( - "testing" - - "golang.org/x/sys/windows" -) - -func TestGetRoamingProfilePath(t *testing.T) { - token := windows.GetCurrentProcessToken() - computerName, userName, err := getComputerAndUserName(token, nil) - if err != nil { - t.Fatal(err) - } - - if _, err := getRoamingProfilePath(t.Logf, token, computerName, userName); err != nil { - t.Error(err) - } - - // TODO(aaron): Flesh out better once can run tests under domain accounts. -} diff --git a/util/winutil/winutil_windows_test.go b/util/winutil/winutil_windows_test.go deleted file mode 100644 index d437ffa383d82..0000000000000 --- a/util/winutil/winutil_windows_test.go +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package winutil - -import ( - "reflect" - "testing" - "unsafe" -) - -//lint:file-ignore U1000 Fields are unused but necessary for tests. - -const ( - localSystemSID = "S-1-5-18" - networkSID = "S-1-5-2" -) - -func TestLookupPseudoUser(t *testing.T) { - localSystem, err := LookupPseudoUser(localSystemSID) - if err != nil { - t.Errorf("LookupPseudoUser(%q) error: %v", localSystemSID, err) - } - if localSystem.Gid != localSystemSID { - t.Errorf("incorrect Gid, got %q, want %q", localSystem.Gid, localSystemSID) - } - t.Logf("localSystem: %v", localSystem) - - // networkSID is a built-in known group but not a pseudo-user. - _, err = LookupPseudoUser(networkSID) - if err == nil { - t.Errorf("LookupPseudoUser(%q) unexpectedly succeeded", networkSID) - } -} - -type testType interface { - byte | uint16 | uint32 | uint64 -} - -type noPointers[T testType] struct { - foo byte - bar T - baz bool -} - -type hasPointer struct { - foo byte - bar uint32 - s1 *struct{} - baz byte -} - -func checkContiguousBuffer[T any, BU BufUnit](t *testing.T, extra []BU, pt *T, ptLen uint32, slcs [][]BU) { - szBU := int(unsafe.Sizeof(BU(0))) - expectedAlign := max(reflect.TypeFor[T]().Align(), szBU) - // Check that pointer is aligned - if rem := uintptr(unsafe.Pointer(pt)) % uintptr(expectedAlign); rem != 0 { - t.Errorf("pointer alignment got %d, want 0", rem) - } - // Check that alloc length is aligned - if rem := int(ptLen) % expectedAlign; rem != 0 { - t.Errorf("allocation length alignment got %d, want 0", rem) - } - expectedLen := int(unsafe.Sizeof(*pt)) - expectedLen = alignUp(expectedLen, szBU) - expectedLen += len(extra) * szBU - expectedLen = alignUp(expectedLen, expectedAlign) - if gotLen := int(ptLen); gotLen != expectedLen { - t.Errorf("allocation length got %d, want %d", gotLen, expectedLen) - } - if l := len(slcs); l != 1 { - t.Errorf("len(slcs) got %d, want 1", l) - } - if len(extra) == 0 && slcs[0] != nil { - t.Error("slcs[0] got non-nil, want nil") - } - if len(extra) != len(slcs[0]) { - t.Errorf("len(slcs[0]) got %d, want %d", len(slcs[0]), len(extra)) - } else if rem := uintptr(unsafe.Pointer(unsafe.SliceData(slcs[0]))) % uintptr(szBU); rem != 0 { - t.Errorf("additional data alignment got %d, want 0", rem) - } -} - -func TestAllocateContiguousBuffer(t *testing.T) { - t.Run("NoValues", testNoValues) - t.Run("NoPointers", testNoPointers) - t.Run("HasPointer", testHasPointer) -} - -func testNoValues(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("expected panic but didn't get one") - } - }() - - AllocateContiguousBuffer[hasPointer, byte]() -} - -const maxTestBufLen = 8 - -func testNoPointers(t *testing.T) { - buf8 := make([]byte, maxTestBufLen) - buf16 := make([]uint16, maxTestBufLen) - for i := range maxTestBufLen { - s8, sl, slcs8 := AllocateContiguousBuffer[noPointers[byte]](buf8[:i]) - checkContiguousBuffer(t, buf8[:i], s8, sl, slcs8) - s16, sl, slcs8 := AllocateContiguousBuffer[noPointers[uint16]](buf8[:i]) - checkContiguousBuffer(t, buf8[:i], s16, sl, slcs8) - s32, sl, slcs8 := AllocateContiguousBuffer[noPointers[uint32]](buf8[:i]) - checkContiguousBuffer(t, buf8[:i], s32, sl, slcs8) - s64, sl, slcs8 := AllocateContiguousBuffer[noPointers[uint64]](buf8[:i]) - checkContiguousBuffer(t, buf8[:i], s64, sl, slcs8) - s8, sl, slcs16 := AllocateContiguousBuffer[noPointers[byte]](buf16[:i]) - checkContiguousBuffer(t, buf16[:i], s8, sl, slcs16) - s16, sl, slcs16 = AllocateContiguousBuffer[noPointers[uint16]](buf16[:i]) - checkContiguousBuffer(t, buf16[:i], s16, sl, slcs16) - s32, sl, slcs16 = AllocateContiguousBuffer[noPointers[uint32]](buf16[:i]) - checkContiguousBuffer(t, buf16[:i], s32, sl, slcs16) - s64, sl, slcs16 = AllocateContiguousBuffer[noPointers[uint64]](buf16[:i]) - checkContiguousBuffer(t, buf16[:i], s64, sl, slcs16) - } -} - -func testHasPointer(t *testing.T) { - buf8 := make([]byte, maxTestBufLen) - buf16 := make([]uint16, maxTestBufLen) - for i := range maxTestBufLen { - s, sl, slcs8 := AllocateContiguousBuffer[hasPointer](buf8[:i]) - checkContiguousBuffer(t, buf8[:i], s, sl, slcs8) - s, sl, slcs16 := AllocateContiguousBuffer[hasPointer](buf16[:i]) - checkContiguousBuffer(t, buf16[:i], s, sl, slcs16) - } -} diff --git a/util/zstdframe/options.go b/util/zstdframe/options.go index b4b0f2b85304c..a50eed8ae1489 100644 --- a/util/zstdframe/options.go +++ b/util/zstdframe/options.go @@ -9,7 +9,7 @@ import ( "sync" "github.com/klauspost/compress/zstd" - "tailscale.com/util/must" + "github.com/sagernet/tailscale/util/must" ) // Option is an option that can be passed to [AppendEncode] or [AppendDecode]. diff --git a/util/zstdframe/zstd_test.go b/util/zstdframe/zstd_test.go deleted file mode 100644 index 120fd3508460f..0000000000000 --- a/util/zstdframe/zstd_test.go +++ /dev/null @@ -1,238 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package zstdframe - -import ( - "math/bits" - "math/rand/v2" - "os" - "runtime" - "strings" - "sync" - "testing" - - "github.com/klauspost/compress/zstd" - "tailscale.com/util/must" -) - -// Use the concatenation of all Go source files in zstdframe as testdata. -var src = func() (out []byte) { - for _, de := range must.Get(os.ReadDir(".")) { - if strings.HasSuffix(de.Name(), ".go") { - out = append(out, must.Get(os.ReadFile(de.Name()))...) - } - } - return out -}() -var dst []byte -var dsts [][]byte - -// zstdEnc is identical to getEncoder without options, -// except it relies on concurrency managed by the zstd package itself. -var zstdEnc = must.Get(zstd.NewWriter(nil, - zstd.WithEncoderConcurrency(runtime.NumCPU()), - zstd.WithSingleSegment(true), - zstd.WithZeroFrames(true), - zstd.WithEncoderLevel(zstd.SpeedDefault), - zstd.WithEncoderCRC(true), - zstd.WithLowerEncoderMem(false))) - -// zstdDec is identical to getDecoder without options, -// except it relies on concurrency managed by the zstd package itself. -var zstdDec = must.Get(zstd.NewReader(nil, - zstd.WithDecoderConcurrency(runtime.NumCPU()), - zstd.WithDecoderMaxMemory(1<<63), - zstd.IgnoreChecksum(false), - zstd.WithDecoderLowmem(false))) - -var coders = []struct { - name string - appendEncode func([]byte, []byte) []byte - appendDecode func([]byte, []byte) ([]byte, error) -}{{ - name: "zstd", - appendEncode: func(dst, src []byte) []byte { return zstdEnc.EncodeAll(src, dst) }, - appendDecode: func(dst, src []byte) ([]byte, error) { return zstdDec.DecodeAll(src, dst) }, -}, { - name: "zstdframe", - appendEncode: func(dst, src []byte) []byte { return AppendEncode(dst, src) }, - appendDecode: func(dst, src []byte) ([]byte, error) { return AppendDecode(dst, src) }, -}} - -func TestDecodeMaxSize(t *testing.T) { - var enc, dec []byte - zeros := make([]byte, 1<<16, 2<<16) - check := func(encSize, maxDecSize int) { - var gotErr, wantErr error - enc = AppendEncode(enc[:0], zeros[:encSize]) - - // Directly calling zstd.Decoder.DecodeAll may not trigger size check - // since it only operates on closest power-of-two. - dec, gotErr = func() ([]byte, error) { - d := getDecoder(MaxDecodedSize(uint64(maxDecSize))) - defer putDecoder(d) - return d.Decoder.DecodeAll(enc, dec[:0]) // directly call zstd.Decoder.DecodeAll - }() - if encSize > 1<<(64-bits.LeadingZeros64(uint64(maxDecSize)-1)) { - wantErr = zstd.ErrDecoderSizeExceeded - } - if gotErr != wantErr { - t.Errorf("DecodeAll(AppendEncode(%d), %d) error = %v, want %v", encSize, maxDecSize, gotErr, wantErr) - } - - // Calling AppendDecode should perform the exact size check. - dec, gotErr = AppendDecode(dec[:0], enc, MaxDecodedSize(uint64(maxDecSize))) - if encSize > maxDecSize { - wantErr = zstd.ErrDecoderSizeExceeded - } - if gotErr != wantErr { - t.Errorf("AppendDecode(AppendEncode(%d), %d) error = %v, want %v", encSize, maxDecSize, gotErr, wantErr) - } - } - - rn := rand.New(rand.NewPCG(0, 0)) - for n := 1 << 10; n <= len(zeros); n <<= 1 { - nl := rn.IntN(n + 1) - check(nl, nl) - check(nl, nl-1) - check(nl, (n+nl)/2) - check(nl, n) - check((n+nl)/2, n) - check(n-1, n-1) - check(n-1, n) - check(n-1, n+1) - check(n, n-1) - check(n, n) - check(n, n+1) - check(n+1, n-1) - check(n+1, n) - check(n+1, n+1) - } -} - -func BenchmarkEncode(b *testing.B) { - options := []struct { - name string - opts []Option - }{ - {name: "Best", opts: []Option{BestCompression}}, - {name: "Better", opts: []Option{BetterCompression}}, - {name: "Default", opts: []Option{DefaultCompression}}, - {name: "Fastest", opts: []Option{FastestCompression}}, - {name: "FastestLowMemory", opts: []Option{FastestCompression, LowMemory(true)}}, - {name: "FastestWindowSize", opts: []Option{FastestCompression, MaxWindowSize(1 << 10)}}, - {name: "FastestNoChecksum", opts: []Option{FastestCompression, WithChecksum(false)}}, - } - for _, bb := range options { - b.Run(bb.name, func(b *testing.B) { - b.ReportAllocs() - b.SetBytes(int64(len(src))) - for range b.N { - dst = AppendEncode(dst[:0], src, bb.opts...) - } - }) - if testing.Verbose() { - ratio := float64(len(src)) / float64(len(dst)) - b.Logf("ratio: %0.3fx", ratio) - } - } -} - -func BenchmarkDecode(b *testing.B) { - options := []struct { - name string - opts []Option - }{ - {name: "Checksum", opts: []Option{WithChecksum(true)}}, - {name: "NoChecksum", opts: []Option{WithChecksum(false)}}, - {name: "LowMemory", opts: []Option{LowMemory(true)}}, - } - src := AppendEncode(nil, src) - for _, bb := range options { - b.Run(bb.name, func(b *testing.B) { - b.ReportAllocs() - b.SetBytes(int64(len(src))) - for range b.N { - dst = must.Get(AppendDecode(dst[:0], src, bb.opts...)) - } - }) - } -} - -func BenchmarkEncodeParallel(b *testing.B) { - numCPU := runtime.NumCPU() - for _, coder := range coders { - dsts = dsts[:0] - for range numCPU { - dsts = append(dsts, coder.appendEncode(nil, src)) - } - b.Run(coder.name, func(b *testing.B) { - b.ReportAllocs() - for range b.N { - var group sync.WaitGroup - for j := 0; j < numCPU; j++ { - group.Add(1) - go func(j int) { - defer group.Done() - dsts[j] = coder.appendEncode(dsts[j][:0], src) - }(j) - } - group.Wait() - } - }) - } -} - -func BenchmarkDecodeParallel(b *testing.B) { - numCPU := runtime.NumCPU() - for _, coder := range coders { - dsts = dsts[:0] - src := AppendEncode(nil, src) - for range numCPU { - dsts = append(dsts, must.Get(coder.appendDecode(nil, src))) - } - b.Run(coder.name, func(b *testing.B) { - b.ReportAllocs() - for range b.N { - var group sync.WaitGroup - for j := 0; j < numCPU; j++ { - group.Add(1) - go func(j int) { - defer group.Done() - dsts[j] = must.Get(coder.appendDecode(dsts[j][:0], src)) - }(j) - } - group.Wait() - } - }) - } -} - -var opt Option - -func TestOptionAllocs(t *testing.T) { - t.Run("EncoderLevel", func(t *testing.T) { - t.Log(testing.AllocsPerRun(1e3, func() { opt = EncoderLevel(zstd.SpeedFastest) })) - }) - t.Run("MaxDecodedSize/PowerOfTwo", func(t *testing.T) { - t.Log(testing.AllocsPerRun(1e3, func() { opt = MaxDecodedSize(1024) })) - }) - t.Run("MaxDecodedSize/Prime", func(t *testing.T) { - t.Log(testing.AllocsPerRun(1e3, func() { opt = MaxDecodedSize(1021) })) - }) - t.Run("MaxWindowSize", func(t *testing.T) { - t.Log(testing.AllocsPerRun(1e3, func() { opt = MaxWindowSize(1024) })) - }) - t.Run("LowMemory", func(t *testing.T) { - t.Log(testing.AllocsPerRun(1e3, func() { opt = LowMemory(true) })) - }) -} - -func TestGetDecoderAllocs(t *testing.T) { - t.Log(testing.AllocsPerRun(1e3, func() { getDecoder() })) -} - -func TestGetEncoderAllocs(t *testing.T) { - t.Log(testing.AllocsPerRun(1e3, func() { getEncoder() })) -} diff --git a/version/cmp_test.go b/version/cmp_test.go deleted file mode 100644 index e244d5e16fe22..0000000000000 --- a/version/cmp_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package version_test - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - "tailscale.com/tstest" - "tailscale.com/version" -) - -func TestParse(t *testing.T) { - parse := version.ExportParse - type parsed = version.ExportParsed - - tests := []struct { - version string - parsed parsed - want bool - }{ - {"1", parsed{Major: 1}, true}, - {"1.2", parsed{Major: 1, Minor: 2}, true}, - {"1.2.3", parsed{Major: 1, Minor: 2, Patch: 3}, true}, - {"1.2.3-4", parsed{Major: 1, Minor: 2, Patch: 3, ExtraCommits: 4}, true}, - {"1.2-4", parsed{Major: 1, Minor: 2, ExtraCommits: 4}, true}, - {"1.2.3-4-extra", parsed{Major: 1, Minor: 2, Patch: 3, ExtraCommits: 4}, true}, - {"1.2.3-4a-test", parsed{Major: 1, Minor: 2, Patch: 3}, true}, - {"1.2-extra", parsed{Major: 1, Minor: 2}, true}, - {"1.2.3-extra", parsed{Major: 1, Minor: 2, Patch: 3}, true}, - {"date.20200612", parsed{Datestamp: 20200612}, true}, - {"borkbork", parsed{}, false}, - {"1a.2.3", parsed{}, false}, - {"", parsed{}, false}, - } - - for _, test := range tests { - gotParsed, got := parse(test.version) - if got != test.want { - t.Errorf("version(%q) = %v, want %v", test.version, got, test.want) - } - if diff := cmp.Diff(gotParsed, test.parsed); diff != "" { - t.Errorf("parse(%q) diff (-got+want):\n%s", test.version, diff) - } - err := tstest.MinAllocsPerRun(t, 0, func() { - gotParsed, got = parse(test.version) - }) - if err != nil { - t.Errorf("parse(%q): %v", test.version, err) - } - } -} - -func TestAtLeast(t *testing.T) { - tests := []struct { - v, m string - want bool - }{ - {"1", "1", true}, - {"1.2", "1", true}, - {"1.2.3", "1", true}, - {"1.2.3-4", "1", true}, - {"0.98-0", "0.98", true}, - {"0.97.1-216", "0.98", false}, - {"0.94", "0.98", false}, - {"0.98", "0.98", true}, - {"0.98.0-0", "0.98", true}, - {"1.2.3-4", "1.2.4-4", false}, - {"1.2.3-4", "1.2.3-4", true}, - {"date.20200612", "date.20200612", true}, - {"date.20200701", "date.20200612", true}, - {"date.20200501", "date.20200612", false}, - } - - for _, test := range tests { - got := version.AtLeast(test.v, test.m) - if got != test.want { - t.Errorf("AtLeast(%q, %q) = %v, want %v", test.v, test.m, got, test.want) - } - } -} diff --git a/version/distro/distro.go b/version/distro/distro.go index ce61137cf3280..989426c2ac366 100644 --- a/version/distro/distro.go +++ b/version/distro/distro.go @@ -10,8 +10,8 @@ import ( "runtime" "strconv" - "tailscale.com/types/lazy" - "tailscale.com/util/lineiter" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/util/lineiter" ) type Distro string diff --git a/version/distro/distro_test.go b/version/distro/distro_test.go deleted file mode 100644 index 4d61c720581c7..0000000000000 --- a/version/distro/distro_test.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package distro - -import "testing" - -func BenchmarkGet(b *testing.B) { - b.ReportAllocs() - var d Distro - for range b.N { - d = Get() - } - _ = d -} diff --git a/version/export_test.go b/version/export_test.go deleted file mode 100644 index 8e8ce5ecb2129..0000000000000 --- a/version/export_test.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package version - -var ( - ExportParse = parse - ExportFindModuleInfo = findModuleInfo - ExportCmdName = cmdName -) - -type ( - ExportParsed = parsed -) diff --git a/version/mkversion/mkversion_test.go b/version/mkversion/mkversion_test.go deleted file mode 100644 index 210d3053a14a3..0000000000000 --- a/version/mkversion/mkversion_test.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package mkversion - -import ( - "fmt" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" -) - -func mkInfo(gitHash, otherHash, otherDate string, major, minor, patch, changeCount int) verInfo { - return verInfo{ - major: major, - minor: minor, - patch: patch, - changeCount: changeCount, - hash: gitHash, - otherHash: otherHash, - otherDate: otherDate, - } -} - -func TestMkversion(t *testing.T) { - otherDate := fmt.Sprintf("%d", time.Date(2023, time.January, 27, 1, 2, 3, 4, time.UTC).Unix()) - - tests := []struct { - in verInfo - want string - }{ - {mkInfo("abcdef", "", otherDate, 0, 98, 0, 0), ` - VERSION_MAJOR=0 - VERSION_MINOR=98 - VERSION_PATCH=0 - VERSION_SHORT="0.98.0" - VERSION_LONG="0.98.0-tabcdef" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="stable"`}, - {mkInfo("abcdef", "", otherDate, 0, 98, 1, 0), ` - VERSION_MAJOR=0 - VERSION_MINOR=98 - VERSION_PATCH=1 - VERSION_SHORT="0.98.1" - VERSION_LONG="0.98.1-tabcdef" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="stable"`}, - {mkInfo("abcdef", "", otherDate, 1, 2, 9, 0), ` - VERSION_MAJOR=1 - VERSION_MINOR=2 - VERSION_PATCH=9 - VERSION_SHORT="1.2.9" - VERSION_LONG="1.2.9-tabcdef" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="stable"`}, - {mkInfo("abcdef", "", otherDate, 1, 15, 0, 129), ` - VERSION_MAJOR=1 - VERSION_MINOR=15 - VERSION_PATCH=129 - VERSION_SHORT="1.15.129" - VERSION_LONG="1.15.129-tabcdef" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="unstable"`}, - {mkInfo("abcdef", "", otherDate, 1, 2, 0, 17), ` - VERSION_MAJOR=1 - VERSION_MINOR=2 - VERSION_PATCH=0 - VERSION_SHORT="1.2.0" - VERSION_LONG="1.2.0-17-tabcdef" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="stable"`}, - {mkInfo("abcdef", "defghi", otherDate, 1, 15, 0, 129), ` - VERSION_MAJOR=1 - VERSION_MINOR=15 - VERSION_PATCH=129 - VERSION_SHORT="1.15.129" - VERSION_LONG="1.15.129-tabcdef-gdefghi" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="unstable" - VERSION_EXTRA_HASH="defghi" - VERSION_XCODE="101.15.129" - VERSION_XCODE_MACOS="274.27.3723" - VERSION_WINRES="1,15,129,0" - VERSION_MSIPRODUCT_AMD64="89C96952-1FB8-5A4D-B02E-16A8060C56AA" - VERSION_MSIPRODUCT_ARM64="DB1A2E86-66C4-5CEC-8F4C-7DB805370F3A" - VERSION_MSIPRODUCT_X86="DC57C0C3-5164-5C92-86B3-2800CEFF0540"`}, - {mkInfo("abcdef", "", otherDate, 1, 2, 0, 17), ` - VERSION_MAJOR=1 - VERSION_MINOR=2 - VERSION_PATCH=0 - VERSION_SHORT="1.2.0" - VERSION_LONG="1.2.0-17-tabcdef" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="stable"`}, - {mkInfo("abcdef", "defghi", otherDate, 1, 15, 0, 129), ` - VERSION_MAJOR=1 - VERSION_MINOR=15 - VERSION_PATCH=129 - VERSION_SHORT="1.15.129" - VERSION_LONG="1.15.129-tabcdef-gdefghi" - VERSION_GIT_HASH="abcdef" - VERSION_TRACK="unstable" - VERSION_EXTRA_HASH="defghi" - VERSION_XCODE="101.15.129" - VERSION_XCODE_MACOS="274.27.3723" - VERSION_WINRES="1,15,129,0" - VERSION_MSIPRODUCT_AMD64="89C96952-1FB8-5A4D-B02E-16A8060C56AA" - VERSION_MSIPRODUCT_ARM64="DB1A2E86-66C4-5CEC-8F4C-7DB805370F3A" - VERSION_MSIPRODUCT_X86="DC57C0C3-5164-5C92-86B3-2800CEFF0540"`}, - {mkInfo("abcdef", "", otherDate, 0, 99, 5, 0), ""}, // unstable, patch number not allowed - {mkInfo("abcdef", "", otherDate, 0, 99, 5, 123), ""}, // unstable, patch number not allowed - {mkInfo("abcdef", "defghi", "", 1, 15, 0, 129), ""}, // missing otherDate - } - - for _, test := range tests { - want := strings.ReplaceAll(strings.TrimSpace(test.want), " ", "") - info, err := mkOutput(test.in) - if err != nil { - if test.want != "" { - t.Errorf("%#v got unexpected error %v", test.in, err) - } - continue - } - got := strings.TrimSpace(info.String()) - if diff := cmp.Diff(got, want); want != "" && diff != "" { - t.Errorf("%#v wrong output (-got+want):\n%s", test.in, diff) - } - } -} diff --git a/version/modinfo_test.go b/version/modinfo_test.go deleted file mode 100644 index 746e6296de795..0000000000000 --- a/version/modinfo_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package version_test - -import ( - "flag" - "os/exec" - "path/filepath" - "strings" - "testing" - - "tailscale.com/version" -) - -var ( - findModuleInfo = version.ExportFindModuleInfo - cmdName = version.ExportCmdName -) - -func TestFindModuleInfo(t *testing.T) { - dir := t.TempDir() - name := filepath.Join(dir, "tailscaled-version-test") - out, err := exec.Command("go", "build", "-o", name, "tailscale.com/cmd/tailscaled").CombinedOutput() - if err != nil { - t.Fatalf("failed to build tailscaled: %v\n%s", err, out) - } - modinfo, err := findModuleInfo(name) - if err != nil { - t.Fatal(err) - } - prefix := "path\ttailscale.com/cmd/tailscaled\nmod\ttailscale.com" - if !strings.HasPrefix(modinfo, prefix) { - t.Errorf("unexpected modinfo contents %q", modinfo) - } -} - -var findModuleInfoName = flag.String("module-info-file", "", "if non-empty, test findModuleInfo against this filename") - -func TestFindModuleInfoManual(t *testing.T) { - exe := *findModuleInfoName - if exe == "" { - t.Skip("skipping without --module-info-file filename") - } - cmd := cmdName(exe) - mod, err := findModuleInfo(exe) - if err != nil { - t.Fatal(err) - } - t.Logf("Got %q from: %s", cmd, mod) -} diff --git a/version/print.go b/version/print.go index 7d8554279f255..82ba4ff87597e 100644 --- a/version/print.go +++ b/version/print.go @@ -8,7 +8,7 @@ import ( "runtime" "strings" - "tailscale.com/types/lazy" + "github.com/sagernet/tailscale/types/lazy" ) var stringLazy = lazy.SyncFunc(func() string { diff --git a/version/prop.go b/version/prop.go index fee76c65fe0f2..6ef3e58873b87 100644 --- a/version/prop.go +++ b/version/prop.go @@ -10,8 +10,8 @@ import ( "strconv" "strings" - "tailscale.com/tailcfg" - "tailscale.com/types/lazy" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/lazy" ) // IsMobile reports whether this is a mobile client build. diff --git a/version/version.go b/version/version.go index 5edea22ca6df0..0d9bc03f6f1d1 100644 --- a/version/version.go +++ b/version/version.go @@ -10,8 +10,8 @@ import ( "strconv" "strings" - tailscaleroot "tailscale.com" - "tailscale.com/types/lazy" + tailscaleroot "github.com/sagernet/tailscale" + "github.com/sagernet/tailscale/types/lazy" ) // Stamp vars can have their value set at build time by linker flags (see diff --git a/version/version_export.go b/version/version_export.go new file mode 100644 index 0000000000000..5c4dbe45e99b6 --- /dev/null +++ b/version/version_export.go @@ -0,0 +1,10 @@ +package version + +import "github.com/sagernet/tailscale/types/lazy" + +func SetVersion(version string) { + short = lazy.SyncValue[string]{} + short.MustSet(version) + long = lazy.SyncValue[string]{} + long.MustSet(version) +} diff --git a/version/version_internal_test.go b/version/version_internal_test.go deleted file mode 100644 index 19aeab44228bd..0000000000000 --- a/version/version_internal_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package version - -import "testing" - -func TestIsValidLongWithTwoRepos(t *testing.T) { - tests := []struct { - long string - want bool - }{ - {"1.2.3-t01234abcde-g01234abcde", true}, - {"1.2.259-t01234abcde-g01234abcde", true}, // big patch version - {"1.2.3-t01234abcde", false}, // missing repo - {"1.2.3-g01234abcde", false}, // missing repo - {"-t01234abcde-g01234abcde", false}, - {"1.2.3", false}, - {"1.2.3-t01234abcde-g", false}, - {"1.2.3-t01234abcde-gERRBUILDINFO", false}, - } - for _, tt := range tests { - if got := isValidLongWithTwoRepos(tt.long); got != tt.want { - t.Errorf("IsValidLongWithTwoRepos(%q) = %v; want %v", tt.long, got, tt.want) - } - } -} diff --git a/version/version_test.go b/version/version_test.go deleted file mode 100644 index a515650586cc4..0000000000000 --- a/version/version_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package version_test - -import ( - "bytes" - "os" - "testing" - - ts "tailscale.com" - "tailscale.com/version" -) - -func TestAlpineTag(t *testing.T) { - if tag := readAlpineTag(t, "../Dockerfile.base"); tag == "" { - t.Fatal(`"FROM alpine:" not found in Dockerfile.base`) - } else if tag != ts.AlpineDockerTag { - t.Errorf("alpine version mismatch: Dockerfile.base has %q; ALPINE.txt has %q", tag, ts.AlpineDockerTag) - } - if tag := readAlpineTag(t, "../Dockerfile"); tag == "" { - t.Fatal(`"FROM alpine:" not found in Dockerfile`) - } else if tag != ts.AlpineDockerTag { - t.Errorf("alpine version mismatch: Dockerfile has %q; ALPINE.txt has %q", tag, ts.AlpineDockerTag) - } -} - -func readAlpineTag(t *testing.T, file string) string { - f, err := os.ReadFile(file) - if err != nil { - t.Fatal(err) - } - for _, line := range bytes.Split(f, []byte{'\n'}) { - line = bytes.TrimSpace(line) - _, suf, ok := bytes.Cut(line, []byte("FROM alpine:")) - if !ok { - continue - } - return string(suf) - } - return "" -} - -func TestShortAllocs(t *testing.T) { - allocs := int(testing.AllocsPerRun(10000, func() { - _ = version.Short() - })) - if allocs > 0 { - t.Errorf("allocs = %v; want 0", allocs) - } -} diff --git a/wf/firewall.go b/wf/firewall.go index 076944c8decad..d9ba997f814ce 100644 --- a/wf/firewall.go +++ b/wf/firewall.go @@ -11,9 +11,9 @@ import ( "net/netip" "os" + "github.com/sagernet/tailscale/net/netaddr" "github.com/tailscale/wf" "golang.org/x/sys/windows" - "tailscale.com/net/netaddr" ) // Known addresses. diff --git a/wgengine/bench/bench.go b/wgengine/bench/bench.go index 8695f18d15899..7e506783c4dca 100644 --- a/wgengine/bench/bench.go +++ b/wgengine/bench/bench.go @@ -18,7 +18,7 @@ import ( "sync" "time" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) const PayloadSize = 1000 diff --git a/wgengine/bench/bench_test.go b/wgengine/bench/bench_test.go deleted file mode 100644 index 4fae86c0580ba..0000000000000 --- a/wgengine/bench/bench_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Create two wgengine instances and pass data through them, measuring -// throughput, latency, and packet loss. -package main - -import ( - "fmt" - "testing" - "time" - - "tailscale.com/types/logger" -) - -func BenchmarkTrivialNoAlloc(b *testing.B) { - run(b, setupTrivialNoAllocTest) -} -func BenchmarkTrivial(b *testing.B) { - run(b, setupTrivialTest) -} - -func BenchmarkBlockingChannel(b *testing.B) { - run(b, setupBlockingChannelTest) -} - -func BenchmarkNonblockingChannel(b *testing.B) { - run(b, setupNonblockingChannelTest) -} - -func BenchmarkDoubleChannel(b *testing.B) { - run(b, setupDoubleChannelTest) -} - -func BenchmarkUDP(b *testing.B) { - run(b, setupUDPTest) -} - -func BenchmarkBatchTCP(b *testing.B) { - run(b, setupBatchTCPTest) -} - -func BenchmarkWireGuardTest(b *testing.B) { - b.Skip("https://github.com/tailscale/tailscale/issues/2716") - run(b, func(logf logger.Logf, traf *TrafficGen) { - setupWGTest(b, logf, traf, Addr1, Addr2) - }) -} - -type SetupFunc func(logger.Logf, *TrafficGen) - -func run(b *testing.B, setup SetupFunc) { - sizes := []int{ - ICMPMinSize + 8, - ICMPMinSize + 100, - ICMPMinSize + 1000, - } - - for _, size := range sizes { - b.Run(fmt.Sprintf("%d", size), func(b *testing.B) { - runOnce(b, setup, size) - }) - } -} - -func runOnce(b *testing.B, setup SetupFunc, payload int) { - b.StopTimer() - b.ReportAllocs() - - var logf logger.Logf = b.Logf - if !testing.Verbose() { - logf = logger.Discard - } - - traf := NewTrafficGen(b.StartTimer) - setup(logf, traf) - - logf("initialized. (n=%v)", b.N) - b.SetBytes(int64(payload)) - - traf.Start(Addr1.Addr(), Addr2.Addr(), payload, int64(b.N)) - - var cur, prev Snapshot - var pps int64 - i := 0 - for traf.Running() { - i += 1 - time.Sleep(10 * time.Millisecond) - - if (i % 100) == 0 { - prev = cur - cur = traf.Snap() - d := cur.Sub(prev) - - if prev.WhenNsec != 0 { - logf("%v @%7d pkt/sec", d, pps) - } - } - - pps = traf.Adjust() - } - - cur = traf.Snap() - d := cur.Sub(prev) - loss := float64(d.LostPackets) / float64(d.RxPackets) - - b.ReportMetric(loss*100, "%lost") -} diff --git a/wgengine/bench/trafficgen.go b/wgengine/bench/trafficgen.go index ce79c616f86ed..7b6dc76409aea 100644 --- a/wgengine/bench/trafficgen.go +++ b/wgengine/bench/trafficgen.go @@ -11,8 +11,8 @@ import ( "sync" "time" - "tailscale.com/net/packet" - "tailscale.com/types/ipproto" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/types/ipproto" ) type Snapshot struct { diff --git a/wgengine/bench/wg.go b/wgengine/bench/wg.go index 45823dd56825f..e05cb4f515c80 100644 --- a/wgengine/bench/wg.go +++ b/wgengine/bench/wg.go @@ -12,18 +12,17 @@ import ( "sync" "testing" - "github.com/tailscale/wireguard-go/tun" - - "tailscale.com/net/dns" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/wgengine" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/router" - "tailscale.com/wgengine/wgcfg" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tsd" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/wgengine" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/wireguard-go/tun" ) func epFromTyped(eps []tailcfg.Endpoint) (ret []netip.AddrPort) { diff --git a/wgengine/capture/capture.go b/wgengine/capture/capture.go index 6ea5a9549b4f1..2216b5c620083 100644 --- a/wgengine/capture/capture.go +++ b/wgengine/capture/capture.go @@ -7,16 +7,15 @@ package capture import ( "bytes" "context" + _ "embed" "encoding/binary" "io" "net/http" "sync" "time" - _ "embed" - - "tailscale.com/net/packet" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/util/set" ) //go:embed ts-dissector.lua diff --git a/wgengine/filter/filter.go b/wgengine/filter/filter.go index 9e5d8a37f2b24..f1310d3383019 100644 --- a/wgengine/filter/filter.go +++ b/wgengine/filter/filter.go @@ -11,20 +11,20 @@ import ( "sync" "time" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/flowtrack" + "github.com/sagernet/tailscale/net/ipset" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime/rate" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/slicesx" + "github.com/sagernet/tailscale/wgengine/filter/filtertype" "go4.org/netipx" - "tailscale.com/envknob" - "tailscale.com/net/flowtrack" - "tailscale.com/net/ipset" - "tailscale.com/net/netaddr" - "tailscale.com/net/packet" - "tailscale.com/tailcfg" - "tailscale.com/tstime/rate" - "tailscale.com/types/ipproto" - "tailscale.com/types/logger" - "tailscale.com/types/views" - "tailscale.com/util/mak" - "tailscale.com/util/slicesx" - "tailscale.com/wgengine/filter/filtertype" ) // Filter is a stateful packet filter. diff --git a/wgengine/filter/filter_test.go b/wgengine/filter/filter_test.go deleted file mode 100644 index f2796d71f6da7..0000000000000 --- a/wgengine/filter/filter_test.go +++ /dev/null @@ -1,1141 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package filter - -import ( - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "net/netip" - "os" - "slices" - "strconv" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "go4.org/netipx" - xmaps "golang.org/x/exp/maps" - "tailscale.com/net/flowtrack" - "tailscale.com/net/ipset" - "tailscale.com/net/packet" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/tstime/rate" - "tailscale.com/types/ipproto" - "tailscale.com/types/logger" - "tailscale.com/types/views" - "tailscale.com/util/must" - "tailscale.com/wgengine/filter/filtertype" -) - -// testAllowedProto is an IP protocol number we treat as allowed for -// these tests. -const ( - testAllowedProto ipproto.Proto = 116 - testDeniedProto ipproto.Proto = 127 // CRUDP, appropriately cruddy -) - -// m returnns a Match with the given srcs and dsts. -// -// opts can be ipproto.Proto values (if none, defaultProtos is used) -// or tailcfg.NodeCapability values. Other values panic. -func m(srcs []netip.Prefix, dsts []NetPortRange, opts ...any) Match { - var protos []ipproto.Proto - var caps []tailcfg.NodeCapability - for _, o := range opts { - switch o := o.(type) { - case ipproto.Proto: - protos = append(protos, o) - case tailcfg.NodeCapability: - caps = append(caps, o) - default: - panic(fmt.Sprintf("unknown option type %T", o)) - } - } - if len(protos) == 0 { - protos = defaultProtos - } - return Match{ - IPProto: views.SliceOf(protos), - Srcs: srcs, - SrcsContains: ipset.NewContainsIPFunc(views.SliceOf(srcs)), - SrcCaps: caps, - Dsts: dsts, - } -} - -func newFilter(logf logger.Logf) *Filter { - matches := []Match{ - m(nets("8.1.1.1", "8.2.2.2"), netports("1.2.3.4:22", "5.6.7.8:23-24")), - m(nets("9.1.1.1", "9.2.2.2"), netports("1.2.3.4:22", "5.6.7.8:23-24"), ipproto.SCTP), - m(nets("8.1.1.1", "8.2.2.2"), netports("5.6.7.8:27-28")), - m(nets("2.2.2.2"), netports("8.1.1.1:22")), - m(nets("0.0.0.0/0"), netports("100.122.98.50:*")), - m(nets("0.0.0.0/0"), netports("0.0.0.0/0:443")), - m(nets("153.1.1.1", "153.1.1.2", "153.3.3.3"), netports("1.2.3.4:999")), - m(nets("::1", "::2"), netports("2001::1:22", "2001::2:22")), - m(nets("::/0"), netports("::/0:443")), - m(nets("0.0.0.0/0"), netports("0.0.0.0/0:*"), testAllowedProto), - m(nets("::/0"), netports("::/0:*"), testAllowedProto), - m(nil, netports("1.2.3.4:22"), tailcfg.NodeCapability("cap-hit-1234-ssh")), - } - - // Expects traffic to 100.122.98.50, 1.2.3.4, 5.6.7.8, - // 102.102.102.102, 119.119.119.119, 8.1.0.0/16 - var localNets netipx.IPSetBuilder - for _, n := range nets("100.122.98.50", "1.2.3.4", "5.6.7.8", "102.102.102.102", "119.119.119.119", "8.1.0.0/16", "2001::/16") { - localNets.AddPrefix(n) - } - - var logB netipx.IPSetBuilder - logB.Complement() - localNetsSet, _ := localNets.IPSet() - logBSet, _ := logB.IPSet() - - return New(matches, nil, localNetsSet, logBSet, nil, logf) -} - -func TestFilter(t *testing.T) { - filt := newFilter(t.Logf) - - ipWithCap := netip.MustParseAddr("10.0.0.1") - ipWithoutCap := netip.MustParseAddr("10.0.0.2") - filt.srcIPHasCap = func(ip netip.Addr, cap tailcfg.NodeCapability) bool { - return cap == "cap-hit-1234-ssh" && ip == ipWithCap - } - - type InOut struct { - want Response - p packet.Parsed - } - tests := []InOut{ - // allow 8.1.1.1 => 1.2.3.4:22 - {Accept, parsed(ipproto.TCP, "8.1.1.1", "1.2.3.4", 999, 22)}, - {Accept, parsed(ipproto.ICMPv4, "8.1.1.1", "1.2.3.4", 0, 0)}, - {Drop, parsed(ipproto.TCP, "8.1.1.1", "1.2.3.4", 0, 0)}, - {Accept, parsed(ipproto.TCP, "8.1.1.1", "1.2.3.4", 0, 22)}, - {Drop, parsed(ipproto.TCP, "8.1.1.1", "1.2.3.4", 0, 21)}, - // allow 8.2.2.2. => 1.2.3.4:22 - {Accept, parsed(ipproto.TCP, "8.2.2.2", "1.2.3.4", 0, 22)}, - {Drop, parsed(ipproto.TCP, "8.2.2.2", "1.2.3.4", 0, 23)}, - {Drop, parsed(ipproto.TCP, "8.3.3.3", "1.2.3.4", 0, 22)}, - // allow 8.1.1.1 => 5.6.7.8:23-24 - {Accept, parsed(ipproto.TCP, "8.1.1.1", "5.6.7.8", 0, 23)}, - {Accept, parsed(ipproto.TCP, "8.1.1.1", "5.6.7.8", 0, 24)}, - {Drop, parsed(ipproto.TCP, "8.1.1.3", "5.6.7.8", 0, 24)}, - {Drop, parsed(ipproto.TCP, "8.1.1.1", "5.6.7.8", 0, 22)}, - // allow * => *:443 - {Accept, parsed(ipproto.TCP, "17.34.51.68", "8.1.34.51", 0, 443)}, - {Drop, parsed(ipproto.TCP, "17.34.51.68", "8.1.34.51", 0, 444)}, - // allow * => 100.122.98.50:* - {Accept, parsed(ipproto.TCP, "17.34.51.68", "100.122.98.50", 0, 999)}, - {Accept, parsed(ipproto.TCP, "17.34.51.68", "100.122.98.50", 0, 0)}, - - // allow ::1, ::2 => [2001::1]:22 - {Accept, parsed(ipproto.TCP, "::1", "2001::1", 0, 22)}, - {Accept, parsed(ipproto.ICMPv6, "::1", "2001::1", 0, 0)}, - {Accept, parsed(ipproto.TCP, "::2", "2001::1", 0, 22)}, - {Accept, parsed(ipproto.TCP, "::2", "2001::2", 0, 22)}, - {Drop, parsed(ipproto.TCP, "::1", "2001::1", 0, 23)}, - {Drop, parsed(ipproto.TCP, "::1", "2001::3", 0, 22)}, - {Drop, parsed(ipproto.TCP, "::3", "2001::1", 0, 22)}, - // allow * => *:443 - {Accept, parsed(ipproto.TCP, "::1", "2001::1", 0, 443)}, - {Drop, parsed(ipproto.TCP, "::1", "2001::1", 0, 444)}, - - // localNets prefilter - accepted by policy filter, but - // unexpected dst IP. - {Drop, parsed(ipproto.TCP, "8.1.1.1", "16.32.48.64", 0, 443)}, - {Drop, parsed(ipproto.TCP, "1::", "2602::1", 0, 443)}, - - // Don't allow protocols not specified by filter - {Drop, parsed(ipproto.SCTP, "8.1.1.1", "1.2.3.4", 999, 22)}, - // But SCTP is allowed for 9.1.1.1 - {Accept, parsed(ipproto.SCTP, "9.1.1.1", "1.2.3.4", 999, 22)}, - - // Unknown protocol is allowed if all its ports are allowed. - {Accept, parsed(testAllowedProto, "1.2.3.4", "5.6.7.8", 0, 0)}, - {Accept, parsed(testAllowedProto, "2001::1", "2001::2", 0, 0)}, - {Drop, parsed(testDeniedProto, "1.2.3.4", "5.6.7.8", 0, 0)}, - {Drop, parsed(testDeniedProto, "2001::1", "2001::2", 0, 0)}, - - // Test use of a node capability to grant access. - // 10.0.0.1 has the capability; 10.0.0.2 does not (see srcIPHasCap at top of func) - {Accept, parsed(ipproto.TCP, ipWithCap.String(), "1.2.3.4", 30000, 22)}, - {Drop, parsed(ipproto.TCP, ipWithoutCap.String(), "1.2.3.4", 30000, 22)}, - } - for i, test := range tests { - aclFunc := filt.runIn4 - if test.p.IPVersion == 6 { - aclFunc = filt.runIn6 - } - if got, why := aclFunc(&test.p); test.want != got { - t.Errorf("#%d runIn got=%v want=%v why=%q packet:%v", i, got, test.want, why, test.p) - continue - } - if test.p.IPProto == ipproto.TCP { - var got Response - if test.p.IPVersion == 4 { - got = filt.CheckTCP(test.p.Src.Addr(), test.p.Dst.Addr(), test.p.Dst.Port()) - } else { - got = filt.CheckTCP(test.p.Src.Addr(), test.p.Dst.Addr(), test.p.Dst.Port()) - } - if test.want != got { - t.Errorf("#%d CheckTCP got=%v want=%v packet:%v", i, got, test.want, test.p) - } - // TCP and UDP are treated equivalently in the filter - verify that. - test.p.IPProto = ipproto.UDP - if got, why := aclFunc(&test.p); test.want != got { - t.Errorf("#%d runIn (UDP) got=%v want=%v why=%q packet:%v", i, got, test.want, why, test.p) - } - } - // Update UDP state - _, _ = filt.runOut(&test.p) - } -} - -func TestUDPState(t *testing.T) { - acl := newFilter(t.Logf) - flags := LogDrops | LogAccepts - - a4 := parsed(ipproto.UDP, "119.119.119.119", "102.102.102.102", 4242, 4343) - b4 := parsed(ipproto.UDP, "102.102.102.102", "119.119.119.119", 4343, 4242) - - // Unsolicited UDP traffic gets dropped - if got := acl.RunIn(&a4, flags); got != Drop { - t.Fatalf("incoming initial packet not dropped, got=%v: %v", got, a4) - } - // We talk to that peer - if got := acl.RunOut(&b4, flags); got != Accept { - t.Fatalf("outbound packet didn't egress, got=%v: %v", got, b4) - } - // Now, the same packet as before is allowed back. - if got := acl.RunIn(&a4, flags); got != Accept { - t.Fatalf("incoming response packet not accepted, got=%v: %v", got, a4) - } - - a6 := parsed(ipproto.UDP, "2001::2", "2001::1", 4242, 4343) - b6 := parsed(ipproto.UDP, "2001::1", "2001::2", 4343, 4242) - - // Unsolicited UDP traffic gets dropped - if got := acl.RunIn(&a6, flags); got != Drop { - t.Fatalf("incoming initial packet not dropped: %v", a4) - } - // We talk to that peer - if got := acl.RunOut(&b6, flags); got != Accept { - t.Fatalf("outbound packet didn't egress: %v", b4) - } - // Now, the same packet as before is allowed back. - if got := acl.RunIn(&a6, flags); got != Accept { - t.Fatalf("incoming response packet not accepted: %v", a4) - } -} - -func TestNoAllocs(t *testing.T) { - acl := newFilter(t.Logf) - - tcp4Packet := raw4(ipproto.TCP, "8.1.1.1", "1.2.3.4", 999, 22, 0) - udp4Packet := raw4(ipproto.UDP, "8.1.1.1", "1.2.3.4", 999, 22, 0) - tcp6Packet := raw6(ipproto.TCP, "2001::1", "2001::2", 999, 22, 0) - udp6Packet := raw6(ipproto.UDP, "2001::1", "2001::2", 999, 22, 0) - - tests := []struct { - name string - dir direction - packet []byte - }{ - {"tcp4_in", in, tcp4Packet}, - {"tcp6_in", in, tcp6Packet}, - {"tcp4_out", out, tcp4Packet}, - {"tcp6_out", out, tcp6Packet}, - {"udp4_in", in, udp4Packet}, - {"udp6_in", in, udp6Packet}, - {"udp4_out", out, udp4Packet}, - {"udp6_out", out, udp6Packet}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := tstest.MinAllocsPerRun(t, 0, func() { - q := &packet.Parsed{} - q.Decode(test.packet) - switch test.dir { - case in: - acl.RunIn(q, 0) - case out: - acl.RunOut(q, 0) - } - }) - if err != nil { - t.Error(err) - } - }) - } -} - -func TestParseIPSet(t *testing.T) { - tests := []struct { - host string - want []netip.Prefix - wantErr string - }{ - {"8.8.8.8", pfx("8.8.8.8/32"), ""}, - {"1::2", pfx("1::2/128"), ""}, - {"8.8.8.0/24", pfx("8.8.8.0/24"), ""}, - {"8.8.8.8/24", nil, "8.8.8.8/24 contains non-network bits set"}, - {"1.0.0.0-1.255.255.255", pfx("1.0.0.0/8"), ""}, - {"1.0.0.0-2.1.2.3", pfx("1.0.0.0/8", "2.0.0.0/16", "2.1.0.0/23", "2.1.2.0/30"), ""}, - {"1.0.0.2-1.0.0.1", nil, "invalid IP range \"1.0.0.2-1.0.0.1\""}, - {"*", pfx("0.0.0.0/0", "::/0"), ""}, - } - for _, tt := range tests { - got, gotCap, err := parseIPSet(tt.host) - if err != nil { - if err.Error() == tt.wantErr { - continue - } - t.Errorf("parseIPSet(%q) error: %v; want error %q", tt.host, err, tt.wantErr) - } - if gotCap != "" { - t.Errorf("parseIPSet(%q) cap: %q; want empty", tt.host, gotCap) - } - compareIP := cmp.Comparer(func(a, b netip.Addr) bool { return a == b }) - compareIPPrefix := cmp.Comparer(func(a, b netip.Prefix) bool { return a == b }) - if diff := cmp.Diff(got, tt.want, compareIP, compareIPPrefix); diff != "" { - t.Errorf("parseIPSet(%q) = %s; want %s", tt.host, got, tt.want) - continue - } - } - - capTests := []struct { - in string - want tailcfg.NodeCapability - }{ - {"cap:foo", "foo"}, - {"cap:people-in-8.8.8.0/24", "people-in-8.8.8.0/24"}, // test precedence of "/" search - } - for _, tt := range capTests { - pfxes, gotCap, err := parseIPSet(tt.in) - if err != nil { - t.Errorf("parseIPSet(%q) error: %v; want no error", tt.in, err) - continue - } - if gotCap != tt.want { - t.Errorf("parseIPSet(%q) cap: %q; want %q", tt.in, gotCap, tt.want) - } - if len(pfxes) != 0 { - t.Errorf("parseIPSet(%q) pfxes: %v; want empty", tt.in, pfxes) - } - } -} - -func BenchmarkFilter(b *testing.B) { - tcp4Packet := raw4(ipproto.TCP, "8.1.1.1", "1.2.3.4", 999, 22, 0) - udp4Packet := raw4(ipproto.UDP, "8.1.1.1", "1.2.3.4", 999, 22, 0) - icmp4Packet := raw4(ipproto.ICMPv4, "8.1.1.1", "1.2.3.4", 0, 0, 0) - - tcp6Packet := raw6(ipproto.TCP, "::1", "2001::1", 999, 22, 0) - udp6Packet := raw6(ipproto.UDP, "::1", "2001::1", 999, 22, 0) - icmp6Packet := raw6(ipproto.ICMPv6, "::1", "2001::1", 0, 0, 0) - - benches := []struct { - name string - dir direction - packet []byte - }{ - // Non-SYN TCP and ICMP have similar code paths in and out. - {"icmp4", in, icmp4Packet}, - {"tcp4_syn_in", in, tcp4Packet}, - {"tcp4_syn_out", out, tcp4Packet}, - {"udp4_in", in, udp4Packet}, - {"udp4_out", out, udp4Packet}, - {"icmp6", in, icmp6Packet}, - {"tcp6_syn_in", in, tcp6Packet}, - {"tcp6_syn_out", out, tcp6Packet}, - {"udp6_in", in, udp6Packet}, - {"udp6_out", out, udp6Packet}, - } - - for _, bench := range benches { - b.Run(bench.name, func(b *testing.B) { - acl := newFilter(b.Logf) - b.ReportAllocs() - b.ResetTimer() - for range b.N { - q := &packet.Parsed{} - q.Decode(bench.packet) - // This branch seems to have no measurable impact on performance. - if bench.dir == in { - acl.RunIn(q, 0) - } else { - acl.RunOut(q, 0) - } - } - }) - } -} - -func TestPreFilter(t *testing.T) { - packets := []struct { - desc string - want Response - b []byte - }{ - {"empty", Accept, []byte{}}, - {"short", Drop, []byte("short")}, - {"junk", Drop, raw4default(ipproto.Unknown, 10)}, - {"fragment", Accept, raw4default(ipproto.Fragment, 40)}, - {"tcp", noVerdict, raw4default(ipproto.TCP, 0)}, - {"udp", noVerdict, raw4default(ipproto.UDP, 0)}, - {"icmp", noVerdict, raw4default(ipproto.ICMPv4, 0)}, - } - f := NewAllowNone(t.Logf, &netipx.IPSet{}) - for _, testPacket := range packets { - p := &packet.Parsed{} - p.Decode(testPacket.b) - got := f.pre(p, LogDrops|LogAccepts, in) - if got != testPacket.want { - t.Errorf("%q got=%v want=%v packet:\n%s", testPacket.desc, got, testPacket.want, packet.Hexdump(testPacket.b)) - } - } -} - -func TestOmitDropLogging(t *testing.T) { - tests := []struct { - name string - pkt *packet.Parsed - dir direction - want bool - }{ - { - name: "v4_tcp_out", - pkt: &packet.Parsed{IPVersion: 4, IPProto: ipproto.TCP}, - dir: out, - want: false, - }, - { - name: "v6_icmp_out", // as seen on Linux - pkt: parseHexPkt(t, "60 00 00 00 00 00 3a 00 fe800000000000000000000000000000 ff020000000000000000000000000002"), - dir: out, - want: true, - }, - { - name: "v6_to_MLDv2_capable_routers", // as seen on Windows - pkt: parseHexPkt(t, "60 00 00 00 00 24 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff 02 00 00 00 00 00 00 00 00 00 00 00 00 00 16 3a 00 05 02 00 00 01 00 8f 00 6e 80 00 00 00 01 04 00 00 00 ff 02 00 00 00 00 00 00 00 00 00 00 00 00 00 0c"), - dir: out, - want: true, - }, - { - name: "v4_igmp_out", // on Windows, from https://github.com/tailscale/tailscale/issues/618 - pkt: parseHexPkt(t, "46 00 00 30 37 3a 00 00 01 02 10 0e a9 fe 53 6b e0 00 00 16 94 04 00 00 22 00 14 05 00 00 00 02 04 00 00 00 e0 00 00 fb 04 00 00 00 e0 00 00 fc"), - dir: out, - want: true, - }, - { - name: "v6_udp_multicast", - pkt: parseHexPkt(t, "60 00 00 00 00 00 11 00 fe800000000000007dc6bc04499262a3 ff120000000000000000000000008384"), - dir: out, - want: true, - }, - { - name: "v4_multicast_out_low", - pkt: &packet.Parsed{IPVersion: 4, Dst: mustIPPort("224.0.0.0:0")}, - dir: out, - want: true, - }, - { - name: "v4_multicast_out_high", - pkt: &packet.Parsed{IPVersion: 4, Dst: mustIPPort("239.255.255.255:0")}, - dir: out, - want: true, - }, - { - name: "v4_link_local_unicast", - pkt: &packet.Parsed{IPVersion: 4, Dst: mustIPPort("169.254.1.2:0")}, - dir: out, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := omitDropLogging(tt.pkt, tt.dir) - if got != tt.want { - t.Errorf("got %v; want %v\npacket: %#v\n%s", got, tt.want, tt.pkt, packet.Hexdump(tt.pkt.Buffer())) - } - }) - } -} - -func TestLoggingPrivacy(t *testing.T) { - tstest.Replace(t, &dropBucket, rate.NewLimiter(2^32, 2^32)) - tstest.Replace(t, &acceptBucket, dropBucket) - - var ( - logged bool - testLogger logger.Logf - ) - logf := func(format string, args ...any) { - testLogger(format, args...) - logged = true - } - - f := newFilter(logf) - f.logIPs4 = ipset.NewContainsIPFunc(views.SliceOf([]netip.Prefix{ - tsaddr.CGNATRange(), - tsaddr.TailscaleULARange(), - })) - f.logIPs6 = f.logIPs4 - - var ( - ts4 = netip.AddrPortFrom(tsaddr.CGNATRange().Addr().Next(), 1234) - internet4 = netip.AddrPortFrom(netip.MustParseAddr("8.8.8.8"), 1234) - ts6 = netip.AddrPortFrom(tsaddr.TailscaleULARange().Addr().Next(), 1234) - internet6 = netip.AddrPortFrom(netip.MustParseAddr("2001::1"), 1234) - ) - - tests := []struct { - name string - pkt *packet.Parsed - dir direction - logged bool - }{ - { - name: "ts_to_ts_v4_out", - pkt: &packet.Parsed{IPVersion: 4, IPProto: ipproto.TCP, Src: ts4, Dst: ts4}, - dir: out, - logged: true, - }, - { - name: "ts_to_internet_v4_out", - pkt: &packet.Parsed{IPVersion: 4, IPProto: ipproto.TCP, Src: ts4, Dst: internet4}, - dir: out, - logged: false, - }, - { - name: "internet_to_ts_v4_out", - pkt: &packet.Parsed{IPVersion: 4, IPProto: ipproto.TCP, Src: internet4, Dst: ts4}, - dir: out, - logged: false, - }, - { - name: "ts_to_ts_v4_in", - pkt: &packet.Parsed{IPVersion: 4, IPProto: ipproto.TCP, Src: ts4, Dst: ts4}, - dir: in, - logged: true, - }, - { - name: "ts_to_internet_v4_in", - pkt: &packet.Parsed{IPVersion: 4, IPProto: ipproto.TCP, Src: ts4, Dst: internet4}, - dir: in, - logged: false, - }, - { - name: "internet_to_ts_v4_in", - pkt: &packet.Parsed{IPVersion: 4, IPProto: ipproto.TCP, Src: internet4, Dst: ts4}, - dir: in, - logged: false, - }, - { - name: "ts_to_ts_v6_out", - pkt: &packet.Parsed{IPVersion: 6, IPProto: ipproto.TCP, Src: ts6, Dst: ts6}, - dir: out, - logged: true, - }, - { - name: "ts_to_internet_v6_out", - pkt: &packet.Parsed{IPVersion: 6, IPProto: ipproto.TCP, Src: ts6, Dst: internet6}, - dir: out, - logged: false, - }, - { - name: "internet_to_ts_v6_out", - pkt: &packet.Parsed{IPVersion: 6, IPProto: ipproto.TCP, Src: internet6, Dst: ts6}, - dir: out, - logged: false, - }, - { - name: "ts_to_ts_v6_in", - pkt: &packet.Parsed{IPVersion: 6, IPProto: ipproto.TCP, Src: ts6, Dst: ts6}, - dir: in, - logged: true, - }, - { - name: "ts_to_internet_v6_in", - pkt: &packet.Parsed{IPVersion: 6, IPProto: ipproto.TCP, Src: ts6, Dst: internet6}, - dir: in, - logged: false, - }, - { - name: "internet_to_ts_v6_in", - pkt: &packet.Parsed{IPVersion: 6, IPProto: ipproto.TCP, Src: internet6, Dst: ts6}, - dir: in, - logged: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - test.pkt.StuffForTesting(1024) - logged = false - testLogger = t.Logf - switch test.dir { - case out: - f.RunOut(test.pkt, LogDrops|LogAccepts) - case in: - f.RunIn(test.pkt, LogDrops|LogAccepts) - default: - panic("unknown direction") - } - if logged != test.logged { - t.Errorf("logged = %v, want %v", logged, test.logged) - } - }) - } -} - -var mustIP = netip.MustParseAddr - -func parsed(proto ipproto.Proto, src, dst string, sport, dport uint16) packet.Parsed { - sip, dip := mustIP(src), mustIP(dst) - - var ret packet.Parsed - ret.Decode(dummyPacket) - ret.IPProto = proto - ret.Src = netip.AddrPortFrom(sip, sport) - ret.Dst = netip.AddrPortFrom(dip, dport) - ret.TCPFlags = packet.TCPSyn - - if sip.Is4() { - ret.IPVersion = 4 - } else { - ret.IPVersion = 6 - } - - return ret -} - -func raw6(proto ipproto.Proto, src, dst string, sport, dport uint16, trimLen int) []byte { - u := packet.UDP6Header{ - IP6Header: packet.IP6Header{ - Src: mustIP(src), - Dst: mustIP(dst), - }, - SrcPort: sport, - DstPort: dport, - } - - payload := make([]byte, 12) - // Set the right bit to look like a TCP SYN, if the packet ends up interpreted as TCP - payload[5] = byte(packet.TCPSyn) - - b := packet.Generate(&u, payload) // payload large enough to possibly be TCP - - // UDP marshaling clobbers IPProto, so override it here. - u.IP6Header.IPProto = proto - if err := u.IP6Header.Marshal(b); err != nil { - panic(err) - } - - if trimLen > 0 { - return b[:trimLen] - } else { - return b - } -} - -func raw4(proto ipproto.Proto, src, dst string, sport, dport uint16, trimLength int) []byte { - u := packet.UDP4Header{ - IP4Header: packet.IP4Header{ - Src: mustIP(src), - Dst: mustIP(dst), - }, - SrcPort: sport, - DstPort: dport, - } - - payload := make([]byte, 12) - // Set the right bit to look like a TCP SYN, if the packet ends up interpreted as TCP - payload[5] = byte(packet.TCPSyn) - - b := packet.Generate(&u, payload) // payload large enough to possibly be TCP - - // UDP marshaling clobbers IPProto, so override it here. - switch proto { - case ipproto.Unknown, ipproto.Fragment: - default: - u.IP4Header.IPProto = proto - } - if err := u.IP4Header.Marshal(b); err != nil { - panic(err) - } - - if proto == ipproto.Fragment { - // Set some fragment offset. This makes the IP - // checksum wrong, but we don't validate the checksum - // when parsing. - b[7] = 255 - } - - if trimLength > 0 { - return b[:trimLength] - } else { - return b - } -} - -func raw4default(proto ipproto.Proto, trimLength int) []byte { - return raw4(proto, "8.8.8.8", "8.8.8.8", 53, 53, trimLength) -} - -func parseHexPkt(t *testing.T, h string) *packet.Parsed { - t.Helper() - b, err := hex.DecodeString(strings.ReplaceAll(h, " ", "")) - if err != nil { - t.Fatalf("failed to read hex %q: %v", h, err) - } - p := new(packet.Parsed) - p.Decode(b) - return p -} - -func mustIPPort(s string) netip.AddrPort { - ipp, err := netip.ParseAddrPort(s) - if err != nil { - panic(err) - } - return ipp -} - -func pfx(strs ...string) (ret []netip.Prefix) { - for _, s := range strs { - pfx, err := netip.ParsePrefix(s) - if err != nil { - panic(err) - } - ret = append(ret, pfx) - } - return ret -} - -func nets(nets ...string) (ret []netip.Prefix) { - for _, s := range nets { - if !strings.Contains(s, "/") { - ip, err := netip.ParseAddr(s) - if err != nil { - panic(err) - } - bits := uint8(32) - if ip.Is6() { - bits = 128 - } - ret = append(ret, netip.PrefixFrom(ip, int(bits))) - } else { - pfx, err := netip.ParsePrefix(s) - if err != nil { - panic(err) - } - ret = append(ret, pfx) - } - } - return ret -} - -func ports(s string) PortRange { - if s == "*" { - return filtertype.AllPorts - } - - var fs, ls string - i := strings.IndexByte(s, '-') - if i == -1 { - fs = s - ls = fs - } else { - fs = s[:i] - ls = s[i+1:] - } - first, err := strconv.ParseInt(fs, 10, 16) - if err != nil { - panic(fmt.Sprintf("invalid NetPortRange %q", s)) - } - last, err := strconv.ParseInt(ls, 10, 16) - if err != nil { - panic(fmt.Sprintf("invalid NetPortRange %q", s)) - } - return PortRange{uint16(first), uint16(last)} -} - -func netports(netPorts ...string) (ret []NetPortRange) { - for _, s := range netPorts { - i := strings.LastIndexByte(s, ':') - if i == -1 { - panic(fmt.Sprintf("invalid NetPortRange %q", s)) - } - - npr := NetPortRange{ - Net: nets(s[:i])[0], - Ports: ports(s[i+1:]), - } - ret = append(ret, npr) - } - return ret -} - -func TestMatchesFromFilterRules(t *testing.T) { - tests := []struct { - name string - in []tailcfg.FilterRule - want []Match - }{ - { - name: "empty", - want: []Match{}, - }, - { - name: "implicit_protos", - in: []tailcfg.FilterRule{ - { - SrcIPs: []string{"100.64.1.1"}, - DstPorts: []tailcfg.NetPortRange{{ - IP: "*", - Ports: tailcfg.PortRange{First: 22, Last: 22}, - }}, - }, - }, - want: []Match{ - { - IPProto: defaultProtosView, - Dsts: []NetPortRange{ - { - Net: netip.MustParsePrefix("0.0.0.0/0"), - Ports: PortRange{22, 22}, - }, - { - Net: netip.MustParsePrefix("::0/0"), - Ports: PortRange{22, 22}, - }, - }, - Srcs: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - }, - Caps: []CapMatch{}, - }, - }, - }, - { - name: "explicit_protos", - in: []tailcfg.FilterRule{ - { - IPProto: []int{int(ipproto.TCP)}, - SrcIPs: []string{"100.64.1.1"}, - DstPorts: []tailcfg.NetPortRange{{ - IP: "1.2.0.0/16", - Ports: tailcfg.PortRange{First: 22, Last: 22}, - }}, - }, - }, - want: []Match{ - { - IPProto: views.SliceOf([]ipproto.Proto{ - ipproto.TCP, - }), - Dsts: []NetPortRange{ - { - Net: netip.MustParsePrefix("1.2.0.0/16"), - Ports: PortRange{22, 22}, - }, - }, - Srcs: []netip.Prefix{ - netip.MustParsePrefix("100.64.1.1/32"), - }, - Caps: []CapMatch{}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MatchesFromFilterRules(tt.in) - if err != nil { - t.Fatal(err) - } - cmpOpts := []cmp.Option{ - cmp.Comparer(func(a, b netip.Addr) bool { return a == b }), - cmp.Comparer(func(a, b netip.Prefix) bool { return a == b }), - cmp.Comparer(func(a, b views.Slice[ipproto.Proto]) bool { return views.SliceEqual(a, b) }), - cmpopts.IgnoreFields(Match{}, ".SrcsContains"), - } - if diff := cmp.Diff(got, tt.want, cmpOpts...); diff != "" { - t.Errorf("wrong (-got+want)\n%s", diff) - } - }) - } -} - -func TestNewAllowAllForTest(t *testing.T) { - f := NewAllowAllForTest(logger.Discard) - src := netip.MustParseAddr("100.100.2.3") - dst := netip.MustParseAddr("100.100.1.2") - res := f.CheckTCP(src, dst, 80) - if res.IsDrop() { - t.Fatalf("unexpected drop verdict: %v", res) - } -} - -func TestMatchesMatchProtoAndIPsOnlyIfAllPorts(t *testing.T) { - tests := []struct { - name string - m Match - p packet.Parsed - want bool - }{ - { - name: "all_ports_okay", - m: m(nets("0.0.0.0/0"), netports("0.0.0.0/0:*"), testAllowedProto), - p: parsed(testAllowedProto, "1.2.3.4", "5.6.7.8", 0, 0), - want: true, - }, - { - name: "all_ports_match_but_packet_wrong_proto", - m: m(nets("0.0.0.0/0"), netports("0.0.0.0/0:*"), testAllowedProto), - p: parsed(testDeniedProto, "1.2.3.4", "5.6.7.8", 0, 0), - want: false, - }, - { - name: "ports_requirements_dont_match_unknown_proto", - m: m(nets("0.0.0.0/0"), netports("0.0.0.0/0:12345"), testAllowedProto), - p: parsed(testAllowedProto, "1.2.3.4", "5.6.7.8", 0, 0), - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - matches := matches{tt.m} - got := matches.matchProtoAndIPsOnlyIfAllPorts(&tt.p) - if got != tt.want { - t.Errorf("got = %v; want %v", got, tt.want) - } - }) - } -} - -func TestPeerCaps(t *testing.T) { - mm, err := MatchesFromFilterRules([]tailcfg.FilterRule{ - { - SrcIPs: []string{"*"}, - CapGrant: []tailcfg.CapGrant{{ - Dsts: []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/0"), - }, - Caps: []tailcfg.PeerCapability{"is_ipv4"}, - }}, - }, - { - SrcIPs: []string{"*"}, - CapGrant: []tailcfg.CapGrant{{ - Dsts: []netip.Prefix{ - netip.MustParsePrefix("::/0"), - }, - Caps: []tailcfg.PeerCapability{"is_ipv6"}, - }}, - }, - { - SrcIPs: []string{"100.199.0.0/16"}, - CapGrant: []tailcfg.CapGrant{{ - Dsts: []netip.Prefix{ - netip.MustParsePrefix("100.200.0.0/16"), - }, - Caps: []tailcfg.PeerCapability{"some_super_admin"}, - }}, - }, - }) - if err != nil { - t.Fatal(err) - } - filt := New(mm, nil, nil, nil, nil, t.Logf) - tests := []struct { - name string - src, dst string // IP - want []tailcfg.PeerCapability - }{ - { - name: "v4", - src: "1.2.3.4", - dst: "2.4.5.5", - want: []tailcfg.PeerCapability{"is_ipv4"}, - }, - { - name: "v6", - src: "1::1", - dst: "2::2", - want: []tailcfg.PeerCapability{"is_ipv6"}, - }, - { - name: "admin", - src: "100.199.1.2", - dst: "100.200.3.4", - want: []tailcfg.PeerCapability{"is_ipv4", "some_super_admin"}, - }, - { - name: "not_admin_bad_src", - src: "100.198.1.2", // 198, not 199 - dst: "100.200.3.4", - want: []tailcfg.PeerCapability{"is_ipv4"}, - }, - { - name: "not_admin_bad_dst", - src: "100.199.1.2", - dst: "100.201.3.4", // 201, not 200 - want: []tailcfg.PeerCapability{"is_ipv4"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := xmaps.Keys(filt.CapsWithValues(netip.MustParseAddr(tt.src), netip.MustParseAddr(tt.dst))) - slices.Sort(got) - slices.Sort(tt.want) - if !slices.Equal(got, tt.want) { - t.Errorf("got %q; want %q", got, tt.want) - } - }) - } -} - -var ( - filterMatchFile = flag.String("filter-match-file", "", "JSON file of []filter.Match to benchmark") -) - -func BenchmarkFilterMatchFile(b *testing.B) { - if *filterMatchFile == "" { - b.Skip("no --filter-match-file specified; skipping") - } - benchmarkFile(b, *filterMatchFile, benchOpt{v4: true, validLocalDst: true}) -} - -func BenchmarkFilterMatch(b *testing.B) { - b.Run("not-local-v4", func(b *testing.B) { - benchmarkFile(b, "testdata/matches-1.json", benchOpt{v4: true, validLocalDst: false}) - }) - b.Run("not-local-v6", func(b *testing.B) { - benchmarkFile(b, "testdata/matches-1.json", benchOpt{v4: false, validLocalDst: false}) - }) - b.Run("no-match-v4", func(b *testing.B) { - benchmarkFile(b, "testdata/matches-1.json", benchOpt{v4: true, validLocalDst: true}) - }) - b.Run("no-match-v6", func(b *testing.B) { - benchmarkFile(b, "testdata/matches-1.json", benchOpt{v4: false, validLocalDst: true}) - }) - b.Run("tcp-not-syn-v4", func(b *testing.B) { - benchmarkFile(b, "testdata/matches-1.json", benchOpt{ - v4: true, - validLocalDst: true, - tcpNotSYN: true, - wantAccept: true, - }) - }) - b.Run("udp-existing-flow-v4", func(b *testing.B) { - benchmarkFile(b, "testdata/matches-1.json", benchOpt{ - v4: true, - validLocalDst: true, - udp: true, - udpOpen: true, - wantAccept: true, - }) - }) - b.Run("tcp-not-syn-v4-no-logs", func(b *testing.B) { - benchmarkFile(b, "testdata/matches-1.json", benchOpt{ - v4: true, - validLocalDst: true, - tcpNotSYN: true, - wantAccept: true, - noLogs: true, - }) - }) -} - -type benchOpt struct { - v4 bool - validLocalDst bool - tcpNotSYN bool - noLogs bool - wantAccept bool - udp, udpOpen bool -} - -func benchmarkFile(b *testing.B, file string, opt benchOpt) { - var matches []Match - bts, err := os.ReadFile(file) - if err != nil { - b.Fatal(err) - } - if err := json.Unmarshal(bts, &matches); err != nil { - b.Fatal(err) - } - - var localNets netipx.IPSetBuilder - pfx := []netip.Prefix{ - netip.MustParsePrefix("100.96.14.120/32"), - netip.MustParsePrefix("fd7a:115c:a1e0:ab12:4843:cd96:6260:e78/128"), - } - for _, p := range pfx { - localNets.AddPrefix(p) - } - - var logIPs netipx.IPSetBuilder - logIPs.AddPrefix(tsaddr.CGNATRange()) - logIPs.AddPrefix(tsaddr.TailscaleULARange()) - - f := New(matches, nil, must.Get(localNets.IPSet()), must.Get(logIPs.IPSet()), nil, logger.Discard) - var srcIP, dstIP netip.Addr - if opt.v4 { - srcIP = netip.MustParseAddr("1.2.3.4") - dstIP = pfx[0].Addr() - } else { - srcIP = netip.MustParseAddr("2012::3456") - dstIP = pfx[1].Addr() - } - if !opt.validLocalDst { - dstIP = dstIP.Next() // to make it not in localNets - } - proto := ipproto.TCP - if opt.udp { - proto = ipproto.UDP - } - const sport = 33123 - const dport = 443 - pkt := parsed(proto, srcIP.String(), dstIP.String(), sport, dport) - if opt.tcpNotSYN { - pkt.TCPFlags = packet.TCPPsh // anything that's not SYN - } - if opt.udpOpen { - tuple := flowtrack.MakeTuple(proto, - netip.AddrPortFrom(srcIP, sport), - netip.AddrPortFrom(dstIP, dport), - ) - f.state.mu.Lock() - f.state.lru.Add(tuple, struct{}{}) - f.state.mu.Unlock() - } - - want := Drop - if opt.wantAccept { - want = Accept - } - runFlags := LogDrops | LogAccepts - if opt.noLogs { - runFlags = 0 - } - - for range b.N { - got := f.RunIn(&pkt, runFlags) - if got != want { - b.Fatalf("got %v; want %v", got, want) - } - } -} diff --git a/wgengine/filter/filtertype/filtertype.go b/wgengine/filter/filtertype/filtertype.go index 212eda43f1404..dac34ca880b78 100644 --- a/wgengine/filter/filtertype/filtertype.go +++ b/wgengine/filter/filtertype/filtertype.go @@ -9,9 +9,9 @@ import ( "net/netip" "strings" - "tailscale.com/tailcfg" - "tailscale.com/types/ipproto" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/views" ) //go:generate go run tailscale.com/cmd/cloner --type=Match,CapMatch diff --git a/wgengine/filter/filtertype/filtertype_clone.go b/wgengine/filter/filtertype/filtertype_clone.go index 63709188ea5c1..be2bec0572f96 100644 --- a/wgengine/filter/filtertype/filtertype_clone.go +++ b/wgengine/filter/filtertype/filtertype_clone.go @@ -8,9 +8,9 @@ package filtertype import ( "net/netip" - "tailscale.com/tailcfg" - "tailscale.com/types/ipproto" - "tailscale.com/types/views" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/views" ) // Clone makes a deep copy of Match. diff --git a/wgengine/filter/match.go b/wgengine/filter/match.go index 6292c49714a49..18ff55ac77417 100644 --- a/wgengine/filter/match.go +++ b/wgengine/filter/match.go @@ -6,10 +6,10 @@ package filter import ( "net/netip" - "tailscale.com/net/packet" - "tailscale.com/tailcfg" - "tailscale.com/types/views" - "tailscale.com/wgengine/filter/filtertype" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/wgengine/filter/filtertype" ) type matches []filtertype.Match diff --git a/wgengine/filter/tailcfg.go b/wgengine/filter/tailcfg.go index ff81077f727b7..04d5a55a45f13 100644 --- a/wgengine/filter/tailcfg.go +++ b/wgengine/filter/tailcfg.go @@ -8,12 +8,12 @@ import ( "net/netip" "strings" + "github.com/sagernet/tailscale/net/ipset" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/views" "go4.org/netipx" - "tailscale.com/net/ipset" - "tailscale.com/net/netaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/ipproto" - "tailscale.com/types/views" ) var defaultProtos = []ipproto.Proto{ diff --git a/wgengine/magicsock/batching_conn.go b/wgengine/magicsock/batching_conn.go index 5320d1cafa59a..78628148ac3a1 100644 --- a/wgengine/magicsock/batching_conn.go +++ b/wgengine/magicsock/batching_conn.go @@ -6,9 +6,9 @@ package magicsock import ( "net/netip" + "github.com/sagernet/tailscale/types/nettype" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" - "tailscale.com/types/nettype" ) var ( diff --git a/wgengine/magicsock/batching_conn_default.go b/wgengine/magicsock/batching_conn_default.go index 519cf8082d5ac..7166cabcd7331 100644 --- a/wgengine/magicsock/batching_conn_default.go +++ b/wgengine/magicsock/batching_conn_default.go @@ -6,7 +6,7 @@ package magicsock import ( - "tailscale.com/types/nettype" + "github.com/sagernet/tailscale/types/nettype" ) func tryUpgradeToBatchingConn(pconn nettype.PacketConn, _ string, _ int) nettype.PacketConn { diff --git a/wgengine/magicsock/batching_conn_linux.go b/wgengine/magicsock/batching_conn_linux.go index 25bf974b022ba..98fe3cc733b62 100644 --- a/wgengine/magicsock/batching_conn_linux.go +++ b/wgengine/magicsock/batching_conn_linux.go @@ -17,12 +17,12 @@ import ( "time" "unsafe" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/net/neterror" + "github.com/sagernet/tailscale/types/nettype" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "golang.org/x/sys/unix" - "tailscale.com/hostinfo" - "tailscale.com/net/neterror" - "tailscale.com/types/nettype" ) // xnetBatchReaderWriter defines the batching i/o methods of diff --git a/wgengine/magicsock/batching_conn_linux_test.go b/wgengine/magicsock/batching_conn_linux_test.go deleted file mode 100644 index 5c22bf1c73cf4..0000000000000 --- a/wgengine/magicsock/batching_conn_linux_test.go +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package magicsock - -import ( - "encoding/binary" - "net" - "testing" - - "golang.org/x/net/ipv6" -) - -func setGSOSize(control *[]byte, gsoSize uint16) { - *control = (*control)[:cap(*control)] - binary.LittleEndian.PutUint16(*control, gsoSize) -} - -func getGSOSize(control []byte) (int, error) { - if len(control) < 2 { - return 0, nil - } - return int(binary.LittleEndian.Uint16(control)), nil -} - -func Test_linuxBatchingConn_splitCoalescedMessages(t *testing.T) { - c := &linuxBatchingConn{ - setGSOSizeInControl: setGSOSize, - getGSOSizeFromControl: getGSOSize, - } - - newMsg := func(n, gso int) ipv6.Message { - msg := ipv6.Message{ - Buffers: [][]byte{make([]byte, 1024)}, - N: n, - OOB: make([]byte, 2), - } - binary.LittleEndian.PutUint16(msg.OOB, uint16(gso)) - if gso > 0 { - msg.NN = 2 - } - return msg - } - - cases := []struct { - name string - msgs []ipv6.Message - firstMsgAt int - wantNumEval int - wantMsgLens []int - wantErr bool - }{ - { - name: "second last split last empty", - msgs: []ipv6.Message{ - newMsg(0, 0), - newMsg(0, 0), - newMsg(3, 1), - newMsg(0, 0), - }, - firstMsgAt: 2, - wantNumEval: 3, - wantMsgLens: []int{1, 1, 1, 0}, - wantErr: false, - }, - { - name: "second last no split last empty", - msgs: []ipv6.Message{ - newMsg(0, 0), - newMsg(0, 0), - newMsg(1, 0), - newMsg(0, 0), - }, - firstMsgAt: 2, - wantNumEval: 1, - wantMsgLens: []int{1, 0, 0, 0}, - wantErr: false, - }, - { - name: "second last no split last no split", - msgs: []ipv6.Message{ - newMsg(0, 0), - newMsg(0, 0), - newMsg(1, 0), - newMsg(1, 0), - }, - firstMsgAt: 2, - wantNumEval: 2, - wantMsgLens: []int{1, 1, 0, 0}, - wantErr: false, - }, - { - name: "second last no split last split", - msgs: []ipv6.Message{ - newMsg(0, 0), - newMsg(0, 0), - newMsg(1, 0), - newMsg(3, 1), - }, - firstMsgAt: 2, - wantNumEval: 4, - wantMsgLens: []int{1, 1, 1, 1}, - wantErr: false, - }, - { - name: "second last split last split", - msgs: []ipv6.Message{ - newMsg(0, 0), - newMsg(0, 0), - newMsg(2, 1), - newMsg(2, 1), - }, - firstMsgAt: 2, - wantNumEval: 4, - wantMsgLens: []int{1, 1, 1, 1}, - wantErr: false, - }, - { - name: "second last no split last split overflow", - msgs: []ipv6.Message{ - newMsg(0, 0), - newMsg(0, 0), - newMsg(1, 0), - newMsg(4, 1), - }, - firstMsgAt: 2, - wantNumEval: 4, - wantMsgLens: []int{1, 1, 1, 1}, - wantErr: true, - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - got, err := c.splitCoalescedMessages(tt.msgs, 2) - if err != nil && !tt.wantErr { - t.Fatalf("err: %v", err) - } - if got != tt.wantNumEval { - t.Fatalf("got to eval: %d want: %d", got, tt.wantNumEval) - } - for i, msg := range tt.msgs { - if msg.N != tt.wantMsgLens[i] { - t.Fatalf("msg[%d].N: %d want: %d", i, msg.N, tt.wantMsgLens[i]) - } - } - }) - } -} - -func Test_linuxBatchingConn_coalesceMessages(t *testing.T) { - c := &linuxBatchingConn{ - setGSOSizeInControl: setGSOSize, - getGSOSizeFromControl: getGSOSize, - } - - cases := []struct { - name string - buffs [][]byte - wantLens []int - wantGSO []int - }{ - { - name: "one message no coalesce", - buffs: [][]byte{ - make([]byte, 1, 1), - }, - wantLens: []int{1}, - wantGSO: []int{0}, - }, - { - name: "two messages equal len coalesce", - buffs: [][]byte{ - make([]byte, 1, 2), - make([]byte, 1, 1), - }, - wantLens: []int{2}, - wantGSO: []int{1}, - }, - { - name: "two messages unequal len coalesce", - buffs: [][]byte{ - make([]byte, 2, 3), - make([]byte, 1, 1), - }, - wantLens: []int{3}, - wantGSO: []int{2}, - }, - { - name: "three messages second unequal len coalesce", - buffs: [][]byte{ - make([]byte, 2, 3), - make([]byte, 1, 1), - make([]byte, 2, 2), - }, - wantLens: []int{3, 2}, - wantGSO: []int{2, 0}, - }, - { - name: "three messages limited cap coalesce", - buffs: [][]byte{ - make([]byte, 2, 4), - make([]byte, 2, 2), - make([]byte, 2, 2), - }, - wantLens: []int{4, 2}, - wantGSO: []int{2, 0}, - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - addr := &net.UDPAddr{ - IP: net.ParseIP("127.0.0.1"), - Port: 1, - } - msgs := make([]ipv6.Message, len(tt.buffs)) - for i := range msgs { - msgs[i].Buffers = make([][]byte, 1) - msgs[i].OOB = make([]byte, 0, 2) - } - got := c.coalesceMessages(addr, tt.buffs, msgs) - if got != len(tt.wantLens) { - t.Fatalf("got len %d want: %d", got, len(tt.wantLens)) - } - for i := range got { - if msgs[i].Addr != addr { - t.Errorf("msgs[%d].Addr != passed addr", i) - } - gotLen := len(msgs[i].Buffers[0]) - if gotLen != tt.wantLens[i] { - t.Errorf("len(msgs[%d].Buffers[0]) %d != %d", i, gotLen, tt.wantLens[i]) - } - gotGSO, err := getGSOSize(msgs[i].OOB) - if err != nil { - t.Fatalf("msgs[%d] getGSOSize err: %v", i, err) - } - if gotGSO != tt.wantGSO[i] { - t.Errorf("msgs[%d] gsoSize %d != %d", i, gotGSO, tt.wantGSO[i]) - } - } - }) - } -} diff --git a/wgengine/magicsock/cloudinfo.go b/wgengine/magicsock/cloudinfo.go index 1de369631314c..825ae560a11e7 100644 --- a/wgengine/magicsock/cloudinfo.go +++ b/wgengine/magicsock/cloudinfo.go @@ -17,8 +17,8 @@ import ( "strings" "time" - "tailscale.com/types/logger" - "tailscale.com/util/cloudenv" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/cloudenv" ) const maxCloudInfoWait = 2 * time.Second diff --git a/wgengine/magicsock/cloudinfo_nocloud.go b/wgengine/magicsock/cloudinfo_nocloud.go index b4414d318c7ea..234f07b3ae35a 100644 --- a/wgengine/magicsock/cloudinfo_nocloud.go +++ b/wgengine/magicsock/cloudinfo_nocloud.go @@ -9,7 +9,7 @@ import ( "context" "net/netip" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) type cloudInfo struct{} diff --git a/wgengine/magicsock/cloudinfo_test.go b/wgengine/magicsock/cloudinfo_test.go deleted file mode 100644 index 15191aeefea36..0000000000000 --- a/wgengine/magicsock/cloudinfo_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package magicsock - -import ( - "context" - "net/http" - "net/http/httptest" - "net/netip" - "slices" - "testing" - - "tailscale.com/util/cloudenv" -) - -func TestCloudInfo_AWS(t *testing.T) { - const ( - mac1 = "06:1d:00:00:00:00" - mac2 = "06:1d:00:00:00:01" - publicV4 = "1.2.3.4" - otherV4_1 = "5.6.7.8" - otherV4_2 = "11.12.13.14" - v6addr = "2001:db8::1" - - macsPrefix = "/latest/meta-data/network/interfaces/macs/" - ) - // Launch a fake AWS IMDS server - fake := &fakeIMDS{ - tb: t, - paths: map[string]string{ - macsPrefix: mac1 + "\n" + mac2, - // This is the "main" public IP address for the instance - macsPrefix + mac1 + "/public-ipv4s": publicV4, - - // There's another interface with two public IPs - // attached to it and an IPv6 address, all of which we - // should discover. - macsPrefix + mac2 + "/public-ipv4s": otherV4_1 + "\n" + otherV4_2, - macsPrefix + mac2 + "/ipv6s": v6addr, - }, - } - - srv := httptest.NewServer(fake) - defer srv.Close() - - ci := newCloudInfo(t.Logf) - ci.cloud = cloudenv.AWS - ci.endpoint = srv.URL - - ips, err := ci.GetPublicIPs(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - wantIPs := []netip.Addr{ - netip.MustParseAddr(publicV4), - netip.MustParseAddr(otherV4_1), - netip.MustParseAddr(otherV4_2), - netip.MustParseAddr(v6addr), - } - if !slices.Equal(ips, wantIPs) { - t.Fatalf("got %v, want %v", ips, wantIPs) - } -} - -func TestCloudInfo_AWSNotPublic(t *testing.T) { - returns404 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" && r.URL.Path == "/latest/api/token" { - w.Header().Set("Server", "EC2ws") - w.Write([]byte("fake-imds-token")) - return - } - http.NotFound(w, r) - }) - srv := httptest.NewServer(returns404) - defer srv.Close() - - ci := newCloudInfo(t.Logf) - ci.cloud = cloudenv.AWS - ci.endpoint = srv.URL - - // If the IMDS server doesn't return any public IPs, it's not an error - // and we should just get an empty list. - ips, err := ci.GetPublicIPs(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(ips) != 0 { - t.Fatalf("got %v, want none", ips) - } -} - -type fakeIMDS struct { - tb testing.TB - paths map[string]string -} - -func (f *fakeIMDS) ServeHTTP(w http.ResponseWriter, r *http.Request) { - f.tb.Logf("%s %s", r.Method, r.URL.Path) - path := r.URL.Path - - // Handle the /latest/api/token case - const token = "fake-imds-token" - if r.Method == "PUT" && path == "/latest/api/token" { - w.Header().Set("Server", "EC2ws") - w.Write([]byte(token)) - return - } - - // Otherwise, require the IMDSv2 token to be set - if r.Header.Get("X-aws-ec2-metadata-token") != token { - f.tb.Errorf("missing or invalid IMDSv2 token") - http.Error(w, "missing or invalid IMDSv2 token", http.StatusForbidden) - return - } - - if v, ok := f.paths[path]; ok { - w.Write([]byte(v)) - return - } - http.NotFound(w, r) -} diff --git a/wgengine/magicsock/debughttp.go b/wgengine/magicsock/debughttp.go index aa109c242e27c..21a9243aed731 100644 --- a/wgengine/magicsock/debughttp.go +++ b/wgengine/magicsock/debughttp.go @@ -13,9 +13,9 @@ import ( "strings" "time" - "tailscale.com/tailcfg" - "tailscale.com/tstime/mono" - "tailscale.com/types/key" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime/mono" + "github.com/sagernet/tailscale/types/key" ) // ServeHTTPDebug serves an HTML representation of the innards of c for debugging. diff --git a/wgengine/magicsock/debugknobs.go b/wgengine/magicsock/debugknobs.go index f8fd9f0407d44..5e255a36c8889 100644 --- a/wgengine/magicsock/debugknobs.go +++ b/wgengine/magicsock/debugknobs.go @@ -11,7 +11,7 @@ import ( "strings" "sync" - "tailscale.com/envknob" + "github.com/sagernet/tailscale/envknob" ) // Various debugging and experimental tweakables, set by environment diff --git a/wgengine/magicsock/debugknobs_stubs.go b/wgengine/magicsock/debugknobs_stubs.go index 336d7baa19645..8a834e1f2ee49 100644 --- a/wgengine/magicsock/debugknobs_stubs.go +++ b/wgengine/magicsock/debugknobs_stubs.go @@ -8,7 +8,7 @@ package magicsock import ( "net/netip" - "tailscale.com/types/opt" + "github.com/sagernet/tailscale/types/opt" ) // All knobs are disabled on iOS and Wasm. diff --git a/wgengine/magicsock/derp.go b/wgengine/magicsock/derp.go index e9f07086271d5..aadf99c567b3d 100644 --- a/wgengine/magicsock/derp.go +++ b/wgengine/magicsock/derp.go @@ -17,23 +17,23 @@ import ( "time" "unsafe" - "github.com/tailscale/wireguard-go/conn" - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/health" - "tailscale.com/logtail/backoff" - "tailscale.com/net/dnscache" - "tailscale.com/net/netcheck" - "tailscale.com/net/tsaddr" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstime/mono" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/mak" - "tailscale.com/util/rands" - "tailscale.com/util/sysresources" - "tailscale.com/util/testenv" + "github.com/sagernet/tailscale/derp" + "github.com/sagernet/tailscale/derp/derphttp" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/logtail/backoff" + "github.com/sagernet/tailscale/net/dnscache" + "github.com/sagernet/tailscale/net/netcheck" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime/mono" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/rands" + "github.com/sagernet/tailscale/util/sysresources" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/wireguard-go/conn" ) // frameReceiveRecordRate is the minimum time between updates to last frame diff --git a/wgengine/magicsock/derp_test.go b/wgengine/magicsock/derp_test.go deleted file mode 100644 index ffb230789e4c8..0000000000000 --- a/wgengine/magicsock/derp_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package magicsock - -import ( - "testing" - - "tailscale.com/net/netcheck" -) - -func CheckDERPHeuristicTimes(t *testing.T) { - if netcheck.PreferredDERPFrameTime <= frameReceiveRecordRate { - t.Errorf("PreferredDERPFrameTime too low; should be at least frameReceiveRecordRate") - } -} diff --git a/wgengine/magicsock/endpoint.go b/wgengine/magicsock/endpoint.go index bbba3181ce453..723cc3fe31505 100644 --- a/wgengine/magicsock/endpoint.go +++ b/wgengine/magicsock/endpoint.go @@ -21,19 +21,19 @@ import ( "sync/atomic" "time" + "github.com/sagernet/tailscale/disco" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime/mono" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/ringbuffer" xmaps "golang.org/x/exp/maps" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" - "tailscale.com/disco" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/stun" - "tailscale.com/net/tstun" - "tailscale.com/tailcfg" - "tailscale.com/tstime/mono" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/util/mak" - "tailscale.com/util/ringbuffer" ) var mtuProbePingSizesV4 []int diff --git a/wgengine/magicsock/endpoint_test.go b/wgengine/magicsock/endpoint_test.go deleted file mode 100644 index 1e2de8967511c..0000000000000 --- a/wgengine/magicsock/endpoint_test.go +++ /dev/null @@ -1,326 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package magicsock - -import ( - "net/netip" - "testing" - "time" - - "github.com/dsnet/try" - "tailscale.com/types/key" -) - -func TestProbeUDPLifetimeConfig_Equals(t *testing.T) { - tests := []struct { - name string - a *ProbeUDPLifetimeConfig - b *ProbeUDPLifetimeConfig - want bool - }{ - { - "both sides nil", - nil, - nil, - true, - }, - { - "equal pointers", - defaultProbeUDPLifetimeConfig, - defaultProbeUDPLifetimeConfig, - true, - }, - { - "a nil", - nil, - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second}, - CycleCanStartEvery: time.Hour, - }, - false, - }, - { - "b nil", - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second}, - CycleCanStartEvery: time.Hour, - }, - nil, - false, - }, - { - "Cliffs unequal", - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second}, - CycleCanStartEvery: time.Hour, - }, - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second * 2}, - CycleCanStartEvery: time.Hour, - }, - false, - }, - { - "CycleCanStartEvery unequal", - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second}, - CycleCanStartEvery: time.Hour, - }, - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second}, - CycleCanStartEvery: time.Hour * 2, - }, - false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.a.Equals(tt.b); got != tt.want { - t.Errorf("Equals() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestProbeUDPLifetimeConfig_Valid(t *testing.T) { - tests := []struct { - name string - p *ProbeUDPLifetimeConfig - want bool - }{ - { - "default config valid", - defaultProbeUDPLifetimeConfig, - true, - }, - { - "no cliffs", - &ProbeUDPLifetimeConfig{ - CycleCanStartEvery: time.Hour, - }, - false, - }, - { - "zero CycleCanStartEvery", - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second * 10}, - CycleCanStartEvery: 0, - }, - false, - }, - { - "cliff too small", - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{min(udpLifetimeProbeCliffSlack*2, heartbeatInterval)}, - CycleCanStartEvery: time.Hour, - }, - false, - }, - { - "duplicate Cliffs values", - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second * 2, time.Second * 2}, - CycleCanStartEvery: time.Hour, - }, - false, - }, - { - "Cliffs not ascending", - &ProbeUDPLifetimeConfig{ - Cliffs: []time.Duration{time.Second * 2, time.Second * 1}, - CycleCanStartEvery: time.Hour, - }, - false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.p.Valid(); got != tt.want { - t.Errorf("Valid() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_endpoint_maybeProbeUDPLifetimeLocked(t *testing.T) { - var lower, higher key.DiscoPublic - a := key.NewDisco().Public() - b := key.NewDisco().Public() - if a.String() < b.String() { - lower = a - higher = b - } else { - lower = b - higher = a - } - addr := addrQuality{AddrPort: try.E1[netip.AddrPort](netip.ParseAddrPort("1.1.1.1:1"))} - newProbeUDPLifetime := func() *probeUDPLifetime { - return &probeUDPLifetime{ - config: *defaultProbeUDPLifetimeConfig, - } - } - - tests := []struct { - name string - localDisco key.DiscoPublic - remoteDisco *key.DiscoPublic - probeUDPLifetimeFn func() *probeUDPLifetime - bestAddr addrQuality - wantAfterInactivityForFn func(*probeUDPLifetime) time.Duration - wantMaybe bool - }{ - { - "nil probeUDPLifetime", - higher, - &lower, - func() *probeUDPLifetime { - return nil - }, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return 0 - }, - false, - }, - { - "local higher disco key", - higher, - &lower, - newProbeUDPLifetime, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return 0 - }, - false, - }, - { - "remote no disco key", - higher, - nil, - newProbeUDPLifetime, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return 0 - }, - false, - }, - { - "invalid bestAddr", - lower, - &higher, - newProbeUDPLifetime, - addrQuality{}, - func(lifetime *probeUDPLifetime) time.Duration { - return 0 - }, - false, - }, - { - "cycle started too recently", - lower, - &higher, - func() *probeUDPLifetime { - l := newProbeUDPLifetime() - l.cycleActive = false - l.cycleStartedAt = time.Now() - return l - }, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return 0 - }, - false, - }, - { - "maybe cliff 0 cycle not active", - lower, - &higher, - func() *probeUDPLifetime { - l := newProbeUDPLifetime() - l.cycleActive = false - l.cycleStartedAt = time.Now().Add(-l.config.CycleCanStartEvery).Add(-time.Second) - return l - }, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return lifetime.config.Cliffs[0] - udpLifetimeProbeCliffSlack - }, - true, - }, - { - "maybe cliff 0", - lower, - &higher, - func() *probeUDPLifetime { - l := newProbeUDPLifetime() - l.cycleActive = true - l.currentCliff = 0 - return l - }, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return lifetime.config.Cliffs[0] - udpLifetimeProbeCliffSlack - }, - true, - }, - { - "maybe cliff 1", - lower, - &higher, - func() *probeUDPLifetime { - l := newProbeUDPLifetime() - l.cycleActive = true - l.currentCliff = 1 - return l - }, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return lifetime.config.Cliffs[1] - udpLifetimeProbeCliffSlack - }, - true, - }, - { - "maybe cliff 2", - lower, - &higher, - func() *probeUDPLifetime { - l := newProbeUDPLifetime() - l.cycleActive = true - l.currentCliff = 2 - return l - }, - addr, - func(lifetime *probeUDPLifetime) time.Duration { - return lifetime.config.Cliffs[2] - udpLifetimeProbeCliffSlack - }, - true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - de := &endpoint{ - c: &Conn{ - discoPublic: tt.localDisco, - }, - bestAddr: tt.bestAddr, - } - if tt.remoteDisco != nil { - remote := &endpointDisco{ - key: *tt.remoteDisco, - } - de.disco.Store(remote) - } - p := tt.probeUDPLifetimeFn() - de.probeUDPLifetime = p - gotAfterInactivityFor, gotMaybe := de.maybeProbeUDPLifetimeLocked() - wantAfterInactivityFor := tt.wantAfterInactivityForFn(p) - if gotAfterInactivityFor != wantAfterInactivityFor { - t.Errorf("maybeProbeUDPLifetimeLocked() gotAfterInactivityFor = %v, want %v", gotAfterInactivityFor, wantAfterInactivityFor) - } - if gotMaybe != tt.wantMaybe { - t.Errorf("maybeProbeUDPLifetimeLocked() gotMaybe = %v, want %v", gotMaybe, tt.wantMaybe) - } - }) - } -} diff --git a/wgengine/magicsock/endpoint_tracker.go b/wgengine/magicsock/endpoint_tracker.go index 5caddd1a06960..2d17d5c3a5706 100644 --- a/wgengine/magicsock/endpoint_tracker.go +++ b/wgengine/magicsock/endpoint_tracker.go @@ -9,10 +9,10 @@ import ( "sync" "time" - "tailscale.com/tailcfg" - "tailscale.com/tempfork/heap" - "tailscale.com/util/mak" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tempfork/heap" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" ) const ( diff --git a/wgengine/magicsock/endpoint_tracker_test.go b/wgengine/magicsock/endpoint_tracker_test.go deleted file mode 100644 index 6fccdfd576878..0000000000000 --- a/wgengine/magicsock/endpoint_tracker_test.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package magicsock - -import ( - "net/netip" - "reflect" - "slices" - "strings" - "testing" - "time" - - "tailscale.com/tailcfg" -) - -func TestEndpointTracker(t *testing.T) { - local := tailcfg.Endpoint{ - Addr: netip.MustParseAddrPort("192.168.1.1:12345"), - Type: tailcfg.EndpointLocal, - } - - stun4_1 := tailcfg.Endpoint{ - Addr: netip.MustParseAddrPort("1.2.3.4:12345"), - Type: tailcfg.EndpointSTUN, - } - stun4_2 := tailcfg.Endpoint{ - Addr: netip.MustParseAddrPort("5.6.7.8:12345"), - Type: tailcfg.EndpointSTUN, - } - - stun6_1 := tailcfg.Endpoint{ - Addr: netip.MustParseAddrPort("[2a09:8280:1::1111]:12345"), - Type: tailcfg.EndpointSTUN, - } - stun6_2 := tailcfg.Endpoint{ - Addr: netip.MustParseAddrPort("[2a09:8280:1::2222]:12345"), - Type: tailcfg.EndpointSTUN, - } - - start := time.Unix(1681503440, 0) - - steps := []struct { - name string - now time.Time - eps []tailcfg.Endpoint - want []tailcfg.Endpoint - }{ - { - name: "initial endpoints", - now: start, - eps: []tailcfg.Endpoint{local, stun4_1, stun6_1}, - want: []tailcfg.Endpoint{local, stun4_1, stun6_1}, - }, - { - name: "no change", - now: start.Add(1 * time.Minute), - eps: []tailcfg.Endpoint{local, stun4_1, stun6_1}, - want: []tailcfg.Endpoint{local, stun4_1, stun6_1}, - }, - { - name: "missing stun4", - now: start.Add(2 * time.Minute), - eps: []tailcfg.Endpoint{local, stun6_1}, - want: []tailcfg.Endpoint{local, stun4_1, stun6_1}, - }, - { - name: "missing stun6", - now: start.Add(3 * time.Minute), - eps: []tailcfg.Endpoint{local, stun4_1}, - want: []tailcfg.Endpoint{local, stun4_1, stun6_1}, - }, - { - name: "multiple STUN addresses within timeout", - now: start.Add(4 * time.Minute), - eps: []tailcfg.Endpoint{local, stun4_2, stun6_2}, - want: []tailcfg.Endpoint{local, stun4_1, stun4_2, stun6_1, stun6_2}, - }, - { - name: "endpoint extended", - now: start.Add(3*time.Minute + endpointTrackerLifetime - 1), - eps: []tailcfg.Endpoint{local}, - want: []tailcfg.Endpoint{ - local, stun4_2, stun6_2, - // stun4_1 had its lifetime extended by the - // "missing stun6" test above to that start - // time plus the lifetime, while stun6 should - // have expired a minute sooner. It should thus - // be in this returned list. - stun4_1, - }, - }, - { - name: "after timeout", - now: start.Add(4*time.Minute + endpointTrackerLifetime + 1), - eps: []tailcfg.Endpoint{local, stun4_2, stun6_2}, - want: []tailcfg.Endpoint{local, stun4_2, stun6_2}, - }, - { - name: "after timeout still caches", - now: start.Add(4*time.Minute + endpointTrackerLifetime + time.Minute), - eps: []tailcfg.Endpoint{local}, - want: []tailcfg.Endpoint{local, stun4_2, stun6_2}, - }, - } - - var et endpointTracker - for _, tt := range steps { - t.Logf("STEP: %s", tt.name) - - got := et.update(tt.now, tt.eps) - - // Sort both arrays for comparison - slices.SortFunc(got, func(a, b tailcfg.Endpoint) int { - return strings.Compare(a.Addr.String(), b.Addr.String()) - }) - slices.SortFunc(tt.want, func(a, b tailcfg.Endpoint) int { - return strings.Compare(a.Addr.String(), b.Addr.String()) - }) - - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("endpoints mismatch\ngot: %+v\nwant: %+v", got, tt.want) - } - } -} - -func TestEndpointTrackerMaxNum(t *testing.T) { - start := time.Unix(1681503440, 0) - - var allEndpoints []tailcfg.Endpoint // all created endpoints - mkEp := func(i int) tailcfg.Endpoint { - ep := tailcfg.Endpoint{ - Addr: netip.AddrPortFrom(netip.MustParseAddr("1.2.3.4"), uint16(i)), - Type: tailcfg.EndpointSTUN, - } - allEndpoints = append(allEndpoints, ep) - return ep - } - - var et endpointTracker - - // Add more endpoints to the list than our limit - for i := 0; i <= endpointTrackerMaxPerAddr; i++ { - et.update(start.Add(time.Duration(i)*time.Second), []tailcfg.Endpoint{mkEp(10000 + i)}) - } - - // Now add two more, slightly later - got := et.update(start.Add(1*time.Minute), []tailcfg.Endpoint{ - mkEp(10100), - mkEp(10101), - }) - - // We expect to get the last N endpoints per our per-Addr limit, since - // all of the endpoints have the same netip.Addr. The first endpoint(s) - // that we added were dropped because we had more than the limit for - // this Addr. - want := allEndpoints[len(allEndpoints)-endpointTrackerMaxPerAddr:] - - compareEndpoints := func(got, want []tailcfg.Endpoint) { - t.Helper() - slices.SortFunc(want, func(a, b tailcfg.Endpoint) int { - return strings.Compare(a.Addr.String(), b.Addr.String()) - }) - slices.SortFunc(got, func(a, b tailcfg.Endpoint) int { - return strings.Compare(a.Addr.String(), b.Addr.String()) - }) - if !reflect.DeepEqual(got, want) { - t.Errorf("endpoints mismatch\ngot: %+v\nwant: %+v", got, want) - } - } - compareEndpoints(got, want) - - // However, if we have more than our limit of endpoints passed in to - // the endpointTracker, we will return all of them (even if they're for - // the same address). - var inputEps []tailcfg.Endpoint - for i := range endpointTrackerMaxPerAddr + 5 { - inputEps = append(inputEps, tailcfg.Endpoint{ - Addr: netip.AddrPortFrom(netip.MustParseAddr("1.2.3.4"), 10200+uint16(i)), - Type: tailcfg.EndpointSTUN, - }) - } - - want = inputEps - got = et.update(start.Add(2*time.Minute), inputEps) - compareEndpoints(got, want) -} diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index c361608ad4b23..d30ab98c61283 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -24,46 +24,45 @@ import ( "syscall" "time" - "github.com/tailscale/wireguard-go/conn" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/disco" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/hostinfo" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/connstats" + "github.com/sagernet/tailscale/net/netcheck" + "github.com/sagernet/tailscale/net/neterror" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/net/ping" + "github.com/sagernet/tailscale/net/portmapper" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/stun" + "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime" + "github.com/sagernet/tailscale/tstime/mono" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/ringbuffer" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/util/uniq" + "github.com/sagernet/tailscale/util/usermetric" + "github.com/sagernet/tailscale/wgengine/capture" + "github.com/sagernet/tailscale/wgengine/wgint" + "github.com/sagernet/wireguard-go/conn" "go4.org/mem" "golang.org/x/net/ipv6" - - "tailscale.com/control/controlknobs" - "tailscale.com/disco" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/hostinfo" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/connstats" - "tailscale.com/net/netcheck" - "tailscale.com/net/neterror" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/packet" - "tailscale.com/net/ping" - "tailscale.com/net/portmapper" - "tailscale.com/net/sockstats" - "tailscale.com/net/stun" - "tailscale.com/net/tstun" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstime" - "tailscale.com/tstime/mono" - "tailscale.com/types/key" - "tailscale.com/types/lazy" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/nettype" - "tailscale.com/types/views" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/ringbuffer" - "tailscale.com/util/set" - "tailscale.com/util/testenv" - "tailscale.com/util/uniq" - "tailscale.com/util/usermetric" - "tailscale.com/wgengine/capture" - "tailscale.com/wgengine/wgint" ) const ( @@ -2482,6 +2481,9 @@ func (c *connBind) isClosed() bool { return c.closed } +func (c *connBind) SetReservedForEndpoint(destination netip.AddrPort, reserved [3]byte) { +} + // Close closes the connection. // // Only the first close does anything. Any later closes return nil. @@ -3195,7 +3197,7 @@ func (c *Conn) SetLastNetcheckReportForTest(ctx context.Context, report *netchec // non-disco (presumably WireGuard) packet from a UDP address from which we // can't map to a Tailscale peer. But Wireguard most likely can, once it // decrypts it. So we implement the conn.PeerAwareEndpoint interface -// from https://github.com/tailscale/wireguard-go/pull/27 to allow WireGuard +// from https://github.com/sagernet/wireguard-go/pull/27 to allow WireGuard // to tell us who it is later and get the correct conn.Endpoint. type lazyEndpoint struct { c *Conn diff --git a/wgengine/magicsock/magicsock_default.go b/wgengine/magicsock/magicsock_default.go index 7614c64c92559..11e24fb3c0fff 100644 --- a/wgengine/magicsock/magicsock_default.go +++ b/wgengine/magicsock/magicsock_default.go @@ -10,8 +10,8 @@ import ( "fmt" "io" - "tailscale.com/types/logger" - "tailscale.com/types/nettype" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" ) func (c *Conn) listenRawDisco(family string) (io.Closer, error) { diff --git a/wgengine/magicsock/magicsock_linux.go b/wgengine/magicsock/magicsock_linux.go index c5df555cd4ecd..a4b93946d622f 100644 --- a/wgengine/magicsock/magicsock_linux.go +++ b/wgengine/magicsock/magicsock_linux.go @@ -17,18 +17,18 @@ import ( "time" "github.com/mdlayher/socket" + "github.com/sagernet/tailscale/disco" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/net/netns" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" "golang.org/x/net/bpf" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "golang.org/x/sys/cpu" "golang.org/x/sys/unix" - "tailscale.com/disco" - "tailscale.com/envknob" - "tailscale.com/net/netns" - "tailscale.com/types/ipproto" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/nettype" ) const ( diff --git a/wgengine/magicsock/magicsock_linux_test.go b/wgengine/magicsock/magicsock_linux_test.go deleted file mode 100644 index 6b86b04f2c8d4..0000000000000 --- a/wgengine/magicsock/magicsock_linux_test.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package magicsock - -import ( - "bytes" - "encoding/binary" - "net/netip" - "testing" - - "golang.org/x/sys/cpu" - "golang.org/x/sys/unix" - "tailscale.com/disco" -) - -func TestParseUDPPacket(t *testing.T) { - src4 := netip.MustParseAddrPort("127.0.0.1:12345") - dst4 := netip.MustParseAddrPort("127.0.0.2:54321") - - src6 := netip.MustParseAddrPort("[::1]:12345") - dst6 := netip.MustParseAddrPort("[::2]:54321") - - udp4Packet := []byte{ - // IPv4 header - 0x45, 0x00, 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, - 0x40, 0x11, 0x00, 0x00, - 0x7f, 0x00, 0x00, 0x01, // source ip - 0x7f, 0x00, 0x00, 0x02, // dest ip - - // UDP header - 0x30, 0x39, // src port - 0xd4, 0x31, // dest port - 0x00, 0x12, // length; 8 bytes header + 10 bytes payload = 18 bytes - 0x00, 0x00, // checksum; unused - - // Payload: disco magic plus 4 bytes - 0x54, 0x53, 0xf0, 0x9f, 0x92, 0xac, 0x00, 0x01, 0x02, 0x03, - } - udp6Packet := []byte{ - // IPv6 header - 0x60, 0x00, 0x00, 0x00, - 0x00, 0x12, // payload length - 0x11, // next header: UDP - 0x00, // hop limit; unused - - // Source IP - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - // Dest IP - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, - - // UDP header - 0x30, 0x39, // src port - 0xd4, 0x31, // dest port - 0x00, 0x12, // length; 8 bytes header + 10 bytes payload = 18 bytes - 0x00, 0x00, // checksum; unused - - // Payload: disco magic plus 4 bytes - 0x54, 0x53, 0xf0, 0x9f, 0x92, 0xac, 0x00, 0x01, 0x02, 0x03, - } - - // Verify that parsing the UDP packet works correctly. - t.Run("IPv4", func(t *testing.T) { - src, dst, payload := parseUDPPacket(udp4Packet, false) - if src != src4 { - t.Errorf("src = %v; want %v", src, src4) - } - if dst != dst4 { - t.Errorf("dst = %v; want %v", dst, dst4) - } - if !bytes.HasPrefix(payload, []byte(disco.Magic)) { - t.Errorf("payload = %x; must start with %x", payload, disco.Magic) - } - }) - t.Run("IPv6", func(t *testing.T) { - src, dst, payload := parseUDPPacket(udp6Packet, true) - if src != src6 { - t.Errorf("src = %v; want %v", src, src6) - } - if dst != dst6 { - t.Errorf("dst = %v; want %v", dst, dst6) - } - if !bytes.HasPrefix(payload, []byte(disco.Magic)) { - t.Errorf("payload = %x; must start with %x", payload, disco.Magic) - } - }) - t.Run("Truncated", func(t *testing.T) { - truncateBy := func(b []byte, n int) []byte { - if n >= len(b) { - return nil - } - return b[:len(b)-n] - } - - src, dst, payload := parseUDPPacket(truncateBy(udp4Packet, 11), false) - if payload != nil { - t.Errorf("payload = %x; want nil", payload) - } - if src.IsValid() || dst.IsValid() { - t.Errorf("src = %v, dst = %v; want invalid", src, dst) - } - - src, dst, payload = parseUDPPacket(truncateBy(udp6Packet, 11), true) - if payload != nil { - t.Errorf("payload = %x; want nil", payload) - } - if src.IsValid() || dst.IsValid() { - t.Errorf("src = %v, dst = %v; want invalid", src, dst) - } - }) -} - -func TestEthernetProto(t *testing.T) { - htons := func(x uint16) int { - // Network byte order is big-endian; write the value as - // big-endian to a byte slice and read it back in the native - // endian-ness. This is a no-op on a big-endian platform and a - // byte swap on a little-endian platform. - var b [2]byte - binary.BigEndian.PutUint16(b[:], x) - return int(binary.NativeEndian.Uint16(b[:])) - } - - if v4 := ethernetProtoIPv4(); v4 != htons(unix.ETH_P_IP) { - t.Errorf("ethernetProtoIPv4 = 0x%04x; want 0x%04x", v4, htons(unix.ETH_P_IP)) - } - if v6 := ethernetProtoIPv6(); v6 != htons(unix.ETH_P_IPV6) { - t.Errorf("ethernetProtoIPv6 = 0x%04x; want 0x%04x", v6, htons(unix.ETH_P_IPV6)) - } - - // As a way to verify that the htons function is working correctly, - // assert that the ETH_P_IP value returned from our function matches - // the value defined in the unix package based on whether the host is - // big-endian (network byte order) or little-endian. - if cpu.IsBigEndian { - if v4 := ethernetProtoIPv4(); v4 != unix.ETH_P_IP { - t.Errorf("ethernetProtoIPv4 = 0x%04x; want 0x%04x", v4, unix.ETH_P_IP) - } - } else { - if v4 := ethernetProtoIPv4(); v4 == unix.ETH_P_IP { - t.Errorf("ethernetProtoIPv4 = 0x%04x; want 0x%04x", v4, htons(unix.ETH_P_IP)) - } else { - t.Logf("ethernetProtoIPv4 = 0x%04x, correctly different from 0x%04x", v4, unix.ETH_P_IP) - } - } -} diff --git a/wgengine/magicsock/magicsock_notwindows.go b/wgengine/magicsock/magicsock_notwindows.go index 7c31c8202b35e..8eadd15cb0bef 100644 --- a/wgengine/magicsock/magicsock_notwindows.go +++ b/wgengine/magicsock/magicsock_notwindows.go @@ -6,8 +6,8 @@ package magicsock import ( - "tailscale.com/types/logger" - "tailscale.com/types/nettype" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" ) func trySetUDPSocketOptions(pconn nettype.PacketConn, logf logger.Logf) {} diff --git a/wgengine/magicsock/magicsock_test.go b/wgengine/magicsock/magicsock_test.go deleted file mode 100644 index 1b3f8ec73c16e..0000000000000 --- a/wgengine/magicsock/magicsock_test.go +++ /dev/null @@ -1,3110 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package magicsock - -import ( - "bytes" - "context" - crand "crypto/rand" - "crypto/tls" - "encoding/binary" - "errors" - "fmt" - "io" - "math/rand" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "os" - "runtime" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" - "testing" - "time" - "unsafe" - - qt "github.com/frankban/quicktest" - wgconn "github.com/tailscale/wireguard-go/conn" - "github.com/tailscale/wireguard-go/device" - "github.com/tailscale/wireguard-go/tun/tuntest" - "go4.org/mem" - xmaps "golang.org/x/exp/maps" - "golang.org/x/net/icmp" - "golang.org/x/net/ipv4" - "tailscale.com/cmd/testwrapper/flakytest" - "tailscale.com/control/controlknobs" - "tailscale.com/derp" - "tailscale.com/derp/derphttp" - "tailscale.com/disco" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/connstats" - "tailscale.com/net/netaddr" - "tailscale.com/net/netcheck" - "tailscale.com/net/netmon" - "tailscale.com/net/packet" - "tailscale.com/net/ping" - "tailscale.com/net/stun/stuntest" - "tailscale.com/net/tstun" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/tstest/natlab" - "tailscale.com/tstime/mono" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netlogtype" - "tailscale.com/types/netmap" - "tailscale.com/types/nettype" - "tailscale.com/types/ptr" - "tailscale.com/util/cibuild" - "tailscale.com/util/must" - "tailscale.com/util/racebuild" - "tailscale.com/util/set" - "tailscale.com/util/usermetric" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/wgcfg" - "tailscale.com/wgengine/wgcfg/nmcfg" - "tailscale.com/wgengine/wglog" -) - -func init() { - os.Setenv("IN_TS_TEST", "1") - - // Some of these tests lose a disco pong before establishing a - // direct connection, so instead of waiting 5 seconds in the - // test, reduce the wait period. - // (In particular, TestActiveDiscovery.) - discoPingInterval = 100 * time.Millisecond - pingTimeoutDuration = 100 * time.Millisecond -} - -// WaitReady waits until the magicsock is entirely initialized and connected -// to its home DERP server. This is normally not necessary, since magicsock -// is intended to be entirely asynchronous, but it helps eliminate race -// conditions in tests. In particular, you can't expect two test magicsocks -// to be able to connect to each other through a test DERP unless they are -// both fully initialized before you try. -func (c *Conn) WaitReady(t testing.TB) { - t.Helper() - timer := time.NewTimer(10 * time.Second) - defer timer.Stop() - select { - case <-c.derpStarted: - return - case <-c.connCtx.Done(): - t.Fatalf("magicsock.Conn closed while waiting for readiness") - case <-timer.C: - t.Fatalf("timeout waiting for readiness") - } -} - -func runDERPAndStun(t *testing.T, logf logger.Logf, l nettype.PacketListener, stunIP netip.Addr) (derpMap *tailcfg.DERPMap, cleanup func()) { - d := derp.NewServer(key.NewNode(), logf) - - httpsrv := httptest.NewUnstartedServer(derphttp.Handler(d)) - httpsrv.Config.ErrorLog = logger.StdLogger(logf) - httpsrv.Config.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) - httpsrv.StartTLS() - - stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, l) - - m := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - RegionID: 1, - RegionCode: "test", - Nodes: []*tailcfg.DERPNode{ - { - Name: "t1", - RegionID: 1, - HostName: "test-node.unused", - IPv4: "127.0.0.1", - IPv6: "none", - STUNPort: stunAddr.Port, - DERPPort: httpsrv.Listener.Addr().(*net.TCPAddr).Port, - InsecureForTests: true, - STUNTestIP: stunIP.String(), - }, - }, - }, - }, - } - - cleanup = func() { - httpsrv.CloseClientConnections() - httpsrv.Close() - d.Close() - stunCleanup() - } - - return m, cleanup -} - -// magicStack is a magicsock, plus all the stuff around it that's -// necessary to send and receive packets to test e2e wireguard -// happiness. -type magicStack struct { - privateKey key.NodePrivate - epCh chan []tailcfg.Endpoint // endpoint updates produced by this peer - stats *connstats.Statistics // per-connection statistics - conn *Conn // the magicsock itself - tun *tuntest.ChannelTUN // TUN device to send/receive packets - tsTun *tstun.Wrapper // wrapped tun that implements filtering and wgengine hooks - dev *device.Device // the wireguard-go Device that connects the previous things - wgLogger *wglog.Logger // wireguard-go log wrapper - netMon *netmon.Monitor // always non-nil - metrics *usermetric.Registry -} - -// newMagicStack builds and initializes an idle magicsock and -// friends. You need to call conn.SetNetworkMap and dev.Reconfig -// before anything interesting happens. -func newMagicStack(t testing.TB, logf logger.Logf, l nettype.PacketListener, derpMap *tailcfg.DERPMap) *magicStack { - privateKey := key.NewNode() - return newMagicStackWithKey(t, logf, l, derpMap, privateKey) -} - -func newMagicStackWithKey(t testing.TB, logf logger.Logf, l nettype.PacketListener, derpMap *tailcfg.DERPMap, privateKey key.NodePrivate) *magicStack { - t.Helper() - - netMon, err := netmon.New(logf) - if err != nil { - t.Fatalf("netmon.New: %v", err) - } - ht := new(health.Tracker) - - var reg usermetric.Registry - epCh := make(chan []tailcfg.Endpoint, 100) // arbitrary - conn, err := NewConn(Options{ - NetMon: netMon, - Metrics: ®, - Logf: logf, - HealthTracker: ht, - DisablePortMapper: true, - TestOnlyPacketListener: l, - EndpointsFunc: func(eps []tailcfg.Endpoint) { - epCh <- eps - }, - }) - if err != nil { - t.Fatalf("constructing magicsock: %v", err) - } - conn.SetDERPMap(derpMap) - if err := conn.SetPrivateKey(privateKey); err != nil { - t.Fatalf("setting private key in magicsock: %v", err) - } - - tun := tuntest.NewChannelTUN() - tsTun := tstun.Wrap(logf, tun.TUN(), ®) - tsTun.SetFilter(filter.NewAllowAllForTest(logf)) - tsTun.Start() - - wgLogger := wglog.NewLogger(logf) - dev := wgcfg.NewDevice(tsTun, conn.Bind(), wgLogger.DeviceLogger) - dev.Up() - - // Wait for magicsock to connect up to DERP. - conn.WaitReady(t) - - // Wait for first endpoint update to be available - deadline := time.Now().Add(2 * time.Second) - for len(epCh) == 0 && time.Now().Before(deadline) { - time.Sleep(100 * time.Millisecond) - } - - return &magicStack{ - privateKey: privateKey, - epCh: epCh, - conn: conn, - tun: tun, - tsTun: tsTun, - dev: dev, - wgLogger: wgLogger, - netMon: netMon, - metrics: ®, - } -} - -func (s *magicStack) Reconfig(cfg *wgcfg.Config) error { - s.tsTun.SetWGConfig(cfg) - s.wgLogger.SetPeers(cfg.Peers) - return wgcfg.ReconfigDevice(s.dev, cfg, s.conn.logf) -} - -func (s *magicStack) String() string { - pub := s.Public() - return pub.ShortString() -} - -func (s *magicStack) Close() { - s.dev.Close() - s.conn.Close() - s.netMon.Close() -} - -func (s *magicStack) Public() key.NodePublic { - return s.privateKey.Public() -} - -// Status returns a subset of the ipnstate.Status, only involving -// the magicsock-specific parts. -func (s *magicStack) Status() *ipnstate.Status { - var sb ipnstate.StatusBuilder - sb.WantPeers = true - s.conn.UpdateStatus(&sb) - return sb.Status() -} - -// IP returns the Tailscale IP address assigned to this magicStack. -// -// Something external needs to provide a NetworkMap and WireGuard -// configs to the magicStack in order for it to acquire an IP -// address. See meshStacks for one possible source of netmaps and IPs. -func (s *magicStack) IP() netip.Addr { - for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { - s.conn.mu.Lock() - addr := s.conn.firstAddrForTest - s.conn.mu.Unlock() - if addr.IsValid() { - return addr - } - } - panic("timed out waiting for magicstack to get an IP assigned") -} - -// meshStacks monitors epCh on all given ms, and plumbs network maps -// and WireGuard configs into everyone to form a full mesh that has up -// to date endpoint info. Think of it as an extremely stripped down -// and purpose-built Tailscale control plane. -func meshStacks(logf logger.Logf, mutateNetmap func(idx int, nm *netmap.NetworkMap), ms ...*magicStack) (cleanup func()) { - ctx, cancel := context.WithCancel(context.Background()) - - // Serialize all reconfigurations globally, just to keep things - // simpler. - var ( - mu sync.Mutex - eps = make([][]tailcfg.Endpoint, len(ms)) - ) - - buildNetmapLocked := func(myIdx int) *netmap.NetworkMap { - me := ms[myIdx] - nm := &netmap.NetworkMap{ - PrivateKey: me.privateKey, - NodeKey: me.privateKey.Public(), - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{netip.PrefixFrom(netaddr.IPv4(1, 0, 0, byte(myIdx+1)), 32)}, - }).View(), - } - for i, peer := range ms { - if i == myIdx { - continue - } - addrs := []netip.Prefix{netip.PrefixFrom(netaddr.IPv4(1, 0, 0, byte(i+1)), 32)} - peer := &tailcfg.Node{ - ID: tailcfg.NodeID(i + 1), - Name: fmt.Sprintf("node%d", i+1), - Key: peer.privateKey.Public(), - DiscoKey: peer.conn.DiscoPublicKey(), - Addresses: addrs, - AllowedIPs: addrs, - Endpoints: epFromTyped(eps[i]), - DERP: "127.3.3.40:1", - } - nm.Peers = append(nm.Peers, peer.View()) - } - - if mutateNetmap != nil { - mutateNetmap(myIdx, nm) - } - return nm - } - - updateEps := func(idx int, newEps []tailcfg.Endpoint) { - mu.Lock() - defer mu.Unlock() - - eps[idx] = newEps - - for i, m := range ms { - nm := buildNetmapLocked(i) - m.conn.SetNetworkMap(nm) - peerSet := make(set.Set[key.NodePublic], len(nm.Peers)) - for _, peer := range nm.Peers { - peerSet.Add(peer.Key()) - } - m.conn.UpdatePeers(peerSet) - wg, err := nmcfg.WGCfg(nm, logf, 0, "") - if err != nil { - // We're too far from the *testing.T to be graceful, - // blow up. Shouldn't happen anyway. - panic(fmt.Sprintf("failed to construct wgcfg from netmap: %v", err)) - } - if err := m.Reconfig(wg); err != nil { - if ctx.Err() != nil || errors.Is(err, errConnClosed) { - // shutdown race, don't care. - return - } - panic(fmt.Sprintf("device reconfig failed: %v", err)) - } - } - } - - var wg sync.WaitGroup - wg.Add(len(ms)) - for i := range ms { - go func(myIdx int) { - defer wg.Done() - - for { - select { - case <-ctx.Done(): - return - case eps := <-ms[myIdx].epCh: - logf("conn%d endpoints update", myIdx+1) - updateEps(myIdx, eps) - } - } - }(i) - } - - return func() { - cancel() - wg.Wait() - } -} - -func TestNewConn(t *testing.T) { - tstest.PanicOnLog() - tstest.ResourceCheck(t) - - epCh := make(chan string, 16) - epFunc := func(endpoints []tailcfg.Endpoint) { - for _, ep := range endpoints { - epCh <- ep.Addr.String() - } - } - - netMon, err := netmon.New(logger.WithPrefix(t.Logf, "... netmon: ")) - if err != nil { - t.Fatalf("netmon.New: %v", err) - } - defer netMon.Close() - - stunAddr, stunCleanupFn := stuntest.Serve(t) - defer stunCleanupFn() - - port := pickPort(t) - conn, err := NewConn(Options{ - Port: port, - DisablePortMapper: true, - EndpointsFunc: epFunc, - Logf: t.Logf, - NetMon: netMon, - Metrics: new(usermetric.Registry), - }) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - conn.SetDERPMap(stuntest.DERPMapOf(stunAddr.String())) - conn.SetPrivateKey(key.NewNode()) - - go func() { - pkts := make([][]byte, 1) - sizes := make([]int, 1) - eps := make([]wgconn.Endpoint, 1) - pkts[0] = make([]byte, 64<<10) - receiveIPv4 := conn.receiveIPv4() - for { - _, err := receiveIPv4(pkts, sizes, eps) - if err != nil { - return - } - } - }() - - timeout := time.After(10 * time.Second) - var endpoints []string - suffix := fmt.Sprintf(":%d", port) -collectEndpoints: - for { - select { - case ep := <-epCh: - t.Logf("TestNewConn: got endpoint: %v", ep) - endpoints = append(endpoints, ep) - if strings.HasSuffix(ep, suffix) { - break collectEndpoints - } - case <-timeout: - t.Fatalf("timeout with endpoints: %v", endpoints) - } - } -} - -func pickPort(t testing.TB) uint16 { - t.Helper() - conn, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer conn.Close() - return uint16(conn.LocalAddr().(*net.UDPAddr).Port) -} - -func TestPickDERPFallback(t *testing.T) { - tstest.PanicOnLog() - tstest.ResourceCheck(t) - - c := newConn(t.Logf) - dm := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: {}, - 2: {}, - 3: {}, - 4: {}, - 5: {}, - 6: {}, - 7: {}, - 8: {}, - }, - } - c.derpMap = dm - a := c.pickDERPFallback() - if a == 0 { - t.Fatalf("pickDERPFallback returned 0") - } - - // Test that it's consistent. - for range 50 { - b := c.pickDERPFallback() - if a != b { - t.Fatalf("got inconsistent %d vs %d values", a, b) - } - } - - // Test that that the pointer value of c is blended in and - // distribution over nodes works. - got := map[int]int{} - for range 50 { - c = newConn(t.Logf) - c.derpMap = dm - got[c.pickDERPFallback()]++ - } - t.Logf("distribution: %v", got) - if len(got) < 2 { - t.Errorf("expected more than 1 node; got %v", got) - } - - // Test that stickiness works. - const someNode = 123456 - c.myDerp = someNode - if got := c.pickDERPFallback(); got != someNode { - t.Errorf("not sticky: got %v; want %v", got, someNode) - } - - // TODO: test that disco-based clients changing to a new DERP - // region causes this fallback to also move, once disco clients - // have fixed DERP fallback logic. -} - -// TestDeviceStartStop exercises the startup and shutdown logic of -// wireguard-go, which is intimately intertwined with magicsock's own -// lifecycle. We seem to be good at generating deadlocks here, so if -// this test fails you should suspect a deadlock somewhere in startup -// or shutdown. It may be an infrequent flake, so run with -// -count=10000 to be sure. -func TestDeviceStartStop(t *testing.T) { - tstest.PanicOnLog() - tstest.ResourceCheck(t) - - netMon, err := netmon.New(logger.WithPrefix(t.Logf, "... netmon: ")) - if err != nil { - t.Fatalf("netmon.New: %v", err) - } - defer netMon.Close() - - conn, err := NewConn(Options{ - EndpointsFunc: func(eps []tailcfg.Endpoint) {}, - Logf: t.Logf, - NetMon: netMon, - Metrics: new(usermetric.Registry), - }) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - - tun := tuntest.NewChannelTUN() - wgLogger := wglog.NewLogger(t.Logf) - dev := wgcfg.NewDevice(tun.TUN(), conn.Bind(), wgLogger.DeviceLogger) - dev.Up() - dev.Close() -} - -// Exercise a code path in sendDiscoMessage if the connection has been closed. -func TestConnClosed(t *testing.T) { - mstun := &natlab.Machine{Name: "stun"} - m1 := &natlab.Machine{Name: "m1"} - m2 := &natlab.Machine{Name: "m2"} - inet := natlab.NewInternet() - sif := mstun.Attach("eth0", inet) - m1if := m1.Attach("eth0", inet) - m2if := m2.Attach("eth0", inet) - - d := &devices{ - m1: m1, - m1IP: m1if.V4(), - m2: m2, - m2IP: m2if.V4(), - stun: mstun, - stunIP: sif.V4(), - } - - logf, closeLogf := logger.LogfCloser(t.Logf) - defer closeLogf() - - derpMap, cleanup := runDERPAndStun(t, logf, d.stun, d.stunIP) - defer cleanup() - - ms1 := newMagicStack(t, logger.WithPrefix(logf, "conn1: "), d.m1, derpMap) - defer ms1.Close() - ms2 := newMagicStack(t, logger.WithPrefix(logf, "conn2: "), d.m2, derpMap) - defer ms2.Close() - - cleanup = meshStacks(t.Logf, nil, ms1, ms2) - defer cleanup() - - pkt := tuntest.Ping(ms2.IP(), ms1.IP()) - - if len(ms1.conn.activeDerp) == 0 { - t.Errorf("unexpected DERP empty got: %v want: >0", len(ms1.conn.activeDerp)) - } - - ms1.conn.Close() - ms2.conn.Close() - - // This should hit a c.closed conditional in sendDiscoMessage() and return immediately. - ms1.tun.Outbound <- pkt - select { - case <-ms2.tun.Inbound: - t.Error("unexpected response with connection closed") - case <-time.After(100 * time.Millisecond): - } - - if len(ms1.conn.activeDerp) > 0 { - t.Errorf("unexpected DERP active got: %v want:0", len(ms1.conn.activeDerp)) - } -} - -func makeNestable(t *testing.T) (logf logger.Logf, setT func(t *testing.T)) { - var mu sync.RWMutex - cur := t - - setT = func(t *testing.T) { - mu.Lock() - cur = t - mu.Unlock() - } - - logf = func(s string, args ...any) { - mu.RLock() - t := cur - - t.Helper() - t.Logf(s, args...) - mu.RUnlock() - } - - return logf, setT -} - -// localhostOnlyListener is a nettype.PacketListener that listens on -// localhost (127.0.0.1 or ::1, depending on the requested network) -// when asked to listen on the unspecified address. -// -// It's used in tests where we set up localhost-to-localhost -// communication, because if you listen on the unspecified address on -// macOS and Windows, you get an interactive firewall consent prompt -// to allow the binding, which breaks our CIs. -type localhostListener struct{} - -func (localhostListener) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) { - host, port, err := net.SplitHostPort(address) - if err != nil { - return nil, err - } - switch network { - case "udp4": - switch host { - case "", "0.0.0.0": - host = "127.0.0.1" - case "127.0.0.1": - default: - return nil, fmt.Errorf("localhostListener cannot be asked to listen on %q", address) - } - case "udp6": - switch host { - case "", "::": - host = "::1" - case "::1": - default: - return nil, fmt.Errorf("localhostListener cannot be asked to listen on %q", address) - } - } - var conf net.ListenConfig - return conf.ListenPacket(ctx, network, net.JoinHostPort(host, port)) -} - -func TestTwoDevicePing(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/11762") - l, ip := localhostListener{}, netaddr.IPv4(127, 0, 0, 1) - n := &devices{ - m1: l, - m1IP: ip, - m2: l, - m2IP: ip, - stun: l, - stunIP: ip, - } - testTwoDevicePing(t, n) -} - -func TestDiscokeyChange(t *testing.T) { - tstest.PanicOnLog() - tstest.ResourceCheck(t) - - derpMap, cleanup := runDERPAndStun(t, t.Logf, localhostListener{}, netaddr.IPv4(127, 0, 0, 1)) - defer cleanup() - - m1Key := key.NewNode() - m1 := newMagicStackWithKey(t, t.Logf, localhostListener{}, derpMap, m1Key) - defer m1.Close() - m2 := newMagicStack(t, t.Logf, localhostListener{}, derpMap) - defer m2.Close() - - var ( - mu sync.Mutex - // Start with some random discoKey that isn't actually m1's key, - // to simulate m2 coming up with knowledge of an old, expired - // discokey. We'll switch to the correct one later in the test. - m1DiscoKey = key.NewDisco().Public() - ) - setm1Key := func(idx int, nm *netmap.NetworkMap) { - if idx != 1 { - // only mutate m2's netmap - return - } - if len(nm.Peers) != 1 { - // m1 not in netmap yet. - return - } - mu.Lock() - defer mu.Unlock() - mut := nm.Peers[0].AsStruct() - mut.DiscoKey = m1DiscoKey - nm.Peers[0] = mut.View() - } - - cleanupMesh := meshStacks(t.Logf, setm1Key, m1, m2) - defer cleanupMesh() - - // Wait for both peers to know about each other. - for { - if s1 := m1.Status(); len(s1.Peer) != 1 { - time.Sleep(10 * time.Millisecond) - continue - } - if s2 := m2.Status(); len(s2.Peer) != 1 { - time.Sleep(10 * time.Millisecond) - continue - } - break - } - - mu.Lock() - m1DiscoKey = m1.conn.DiscoPublicKey() - mu.Unlock() - - // Manually trigger an endpoint update to meshStacks, so it hands - // m2 a new netmap. - m1.conn.mu.Lock() - m1.epCh <- m1.conn.lastEndpoints - m1.conn.mu.Unlock() - - cleanup = newPinger(t, t.Logf, m1, m2) - defer cleanup() - - mustDirect(t, t.Logf, m1, m2) - mustDirect(t, t.Logf, m2, m1) -} - -func TestActiveDiscovery(t *testing.T) { - tstest.ResourceCheck(t) - - t.Run("simple_internet", func(t *testing.T) { - t.Parallel() - mstun := &natlab.Machine{Name: "stun"} - m1 := &natlab.Machine{Name: "m1"} - m2 := &natlab.Machine{Name: "m2"} - inet := natlab.NewInternet() - sif := mstun.Attach("eth0", inet) - m1if := m1.Attach("eth0", inet) - m2if := m2.Attach("eth0", inet) - - n := &devices{ - m1: m1, - m1IP: m1if.V4(), - m2: m2, - m2IP: m2if.V4(), - stun: mstun, - stunIP: sif.V4(), - } - testActiveDiscovery(t, n) - }) - - t.Run("facing_easy_firewalls", func(t *testing.T) { - mstun := &natlab.Machine{Name: "stun"} - m1 := &natlab.Machine{ - Name: "m1", - PacketHandler: &natlab.Firewall{}, - } - m2 := &natlab.Machine{ - Name: "m2", - PacketHandler: &natlab.Firewall{}, - } - inet := natlab.NewInternet() - sif := mstun.Attach("eth0", inet) - m1if := m1.Attach("eth0", inet) - m2if := m2.Attach("eth0", inet) - - n := &devices{ - m1: m1, - m1IP: m1if.V4(), - m2: m2, - m2IP: m2if.V4(), - stun: mstun, - stunIP: sif.V4(), - } - testActiveDiscovery(t, n) - }) - - t.Run("facing_nats", func(t *testing.T) { - mstun := &natlab.Machine{Name: "stun"} - m1 := &natlab.Machine{ - Name: "m1", - PacketHandler: &natlab.Firewall{}, - } - nat1 := &natlab.Machine{ - Name: "nat1", - } - m2 := &natlab.Machine{ - Name: "m2", - PacketHandler: &natlab.Firewall{}, - } - nat2 := &natlab.Machine{ - Name: "nat2", - } - - inet := natlab.NewInternet() - lan1 := &natlab.Network{ - Name: "lan1", - Prefix4: netip.MustParsePrefix("192.168.0.0/24"), - } - lan2 := &natlab.Network{ - Name: "lan2", - Prefix4: netip.MustParsePrefix("192.168.1.0/24"), - } - - sif := mstun.Attach("eth0", inet) - nat1WAN := nat1.Attach("wan", inet) - nat1LAN := nat1.Attach("lan1", lan1) - nat2WAN := nat2.Attach("wan", inet) - nat2LAN := nat2.Attach("lan2", lan2) - m1if := m1.Attach("eth0", lan1) - m2if := m2.Attach("eth0", lan2) - lan1.SetDefaultGateway(nat1LAN) - lan2.SetDefaultGateway(nat2LAN) - - nat1.PacketHandler = &natlab.SNAT44{ - Machine: nat1, - ExternalInterface: nat1WAN, - Firewall: &natlab.Firewall{ - TrustedInterface: nat1LAN, - }, - } - nat2.PacketHandler = &natlab.SNAT44{ - Machine: nat2, - ExternalInterface: nat2WAN, - Firewall: &natlab.Firewall{ - TrustedInterface: nat2LAN, - }, - } - - n := &devices{ - m1: m1, - m1IP: m1if.V4(), - m2: m2, - m2IP: m2if.V4(), - stun: mstun, - stunIP: sif.V4(), - } - testActiveDiscovery(t, n) - }) -} - -type devices struct { - m1 nettype.PacketListener - m1IP netip.Addr - - m2 nettype.PacketListener - m2IP netip.Addr - - stun nettype.PacketListener - stunIP netip.Addr -} - -// newPinger starts continuously sending test packets from srcM to -// dstM, until cleanup is invoked to stop it. Each ping has 1 second -// to transit the network. It is a test failure to lose a ping. -func newPinger(t *testing.T, logf logger.Logf, src, dst *magicStack) (cleanup func()) { - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - one := func() bool { - // TODO(danderson): requiring exactly zero packet loss - // will probably be too strict for some tests we'd like to - // run (e.g. discovery switching to a new path on - // failure). Figure out what kind of thing would be - // acceptable to test instead of "every ping must - // transit". - pkt := tuntest.Ping(dst.IP(), src.IP()) - select { - case src.tun.Outbound <- pkt: - case <-ctx.Done(): - return false - } - select { - case <-dst.tun.Inbound: - return true - case <-time.After(10 * time.Second): - // Very generous timeout here because depending on - // magicsock setup races, the first handshake might get - // eaten by the receiving end (if wireguard-go hasn't been - // configured quite yet), so we have to wait for at least - // the first retransmit from wireguard before we declare - // failure. - t.Errorf("timed out waiting for ping to transit") - return true - case <-ctx.Done(): - // Try a little bit longer to consume the packet we're - // waiting for. This is to deal with shutdown races, where - // natlab may still be delivering a packet to us from a - // goroutine. - select { - case <-dst.tun.Inbound: - case <-time.After(time.Second): - } - return false - } - } - - cleanup = func() { - cancel() - <-done - } - - // Synchronously transit one ping to get things started. This is - // nice because it means that newPinger returning means we've - // worked through initial connectivity. - if !one() { - cleanup() - return - } - - go func() { - logf("sending ping stream from %s (%s) to %s (%s)", src, src.IP(), dst, dst.IP()) - defer close(done) - for one() { - } - }() - - return cleanup -} - -// testActiveDiscovery verifies that two magicStacks tied to the given -// devices can establish a direct p2p connection with each other. See -// TestActiveDiscovery for the various configurations of devices that -// get exercised. -func testActiveDiscovery(t *testing.T, d *devices) { - tstest.PanicOnLog() - - tlogf, setT := makeNestable(t) - setT(t) - - start := time.Now() - wlogf := func(msg string, args ...any) { - t.Helper() - msg = fmt.Sprintf("%s: %s", time.Since(start).Truncate(time.Microsecond), msg) - tlogf(msg, args...) - } - logf, closeLogf := logger.LogfCloser(wlogf) - defer closeLogf() - - derpMap, cleanup := runDERPAndStun(t, logf, d.stun, d.stunIP) - defer cleanup() - - m1 := newMagicStack(t, logger.WithPrefix(logf, "conn1: "), d.m1, derpMap) - defer m1.Close() - m2 := newMagicStack(t, logger.WithPrefix(logf, "conn2: "), d.m2, derpMap) - defer m2.Close() - - cleanup = meshStacks(logf, nil, m1, m2) - defer cleanup() - - m1IP := m1.IP() - m2IP := m2.IP() - logf("IPs: %s %s", m1IP, m2IP) - - cleanup = newPinger(t, logf, m1, m2) - defer cleanup() - - // Everything is now up and running, active discovery should find - // a direct path between our peers. Wait for it to switch away - // from DERP. - mustDirect(t, logf, m1, m2) - mustDirect(t, logf, m2, m1) - - logf("starting cleanup") -} - -func mustDirect(t *testing.T, logf logger.Logf, m1, m2 *magicStack) { - lastLog := time.Now().Add(-time.Minute) - // See https://github.com/tailscale/tailscale/issues/654 - // and https://github.com/tailscale/tailscale/issues/3247 for discussions of this deadline. - for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { - pst := m1.Status().Peer[m2.Public()] - if pst.CurAddr != "" { - logf("direct link %s->%s found with addr %s", m1, m2, pst.CurAddr) - return - } - if now := time.Now(); now.Sub(lastLog) > time.Second { - logf("no direct path %s->%s yet, addrs %v", m1, m2, pst.Addrs) - lastLog = now - } - } - t.Errorf("magicsock did not find a direct path from %s to %s", m1, m2) -} - -func testTwoDevicePing(t *testing.T, d *devices) { - tstest.PanicOnLog() - tstest.ResourceCheck(t) - - // This gets reassigned inside every test, so that the connections - // all log using the "current" t.Logf function. Sigh. - nestedLogf, setT := makeNestable(t) - - logf, closeLogf := logger.LogfCloser(nestedLogf) - defer closeLogf() - - derpMap, cleanup := runDERPAndStun(t, logf, d.stun, d.stunIP) - defer cleanup() - - m1 := newMagicStack(t, logf, d.m1, derpMap) - defer m1.Close() - m2 := newMagicStack(t, logf, d.m2, derpMap) - defer m2.Close() - - cleanupMesh := meshStacks(logf, nil, m1, m2) - defer cleanupMesh() - - // Wait for magicsock to be told about peers from meshStacks. - tstest.WaitFor(10*time.Second, func() error { - if p := m1.Status().Peer[m2.Public()]; p == nil || !p.InMagicSock { - return errors.New("m1 not ready") - } - if p := m2.Status().Peer[m1.Public()]; p == nil || !p.InMagicSock { - return errors.New("m2 not ready") - } - return nil - }) - - m1cfg := &wgcfg.Config{ - Name: "peer1", - PrivateKey: m1.privateKey, - Addresses: []netip.Prefix{netip.MustParsePrefix("1.0.0.1/32")}, - Peers: []wgcfg.Peer{ - { - PublicKey: m2.privateKey.Public(), - DiscoKey: m2.conn.DiscoPublicKey(), - AllowedIPs: []netip.Prefix{netip.MustParsePrefix("1.0.0.2/32")}, - }, - }, - } - m2cfg := &wgcfg.Config{ - Name: "peer2", - PrivateKey: m2.privateKey, - Addresses: []netip.Prefix{netip.MustParsePrefix("1.0.0.2/32")}, - Peers: []wgcfg.Peer{ - { - PublicKey: m1.privateKey.Public(), - DiscoKey: m1.conn.DiscoPublicKey(), - AllowedIPs: []netip.Prefix{netip.MustParsePrefix("1.0.0.1/32")}, - }, - }, - } - - if err := m1.Reconfig(m1cfg); err != nil { - t.Fatal(err) - } - if err := m2.Reconfig(m2cfg); err != nil { - t.Fatal(err) - } - - // In the normal case, pings succeed immediately. - // However, in the case of a handshake race, we need to retry. - // With very bad luck, we can need to retry multiple times. - allowedRetries := 3 - if cibuild.On() { - // Allow extra retries on small/flaky/loaded CI machines. - allowedRetries *= 2 - } - // Retries take 5s each. Add 1s for some processing time. - pingTimeout := 5*time.Second*time.Duration(allowedRetries) + time.Second - - // sendWithTimeout sends msg using send, checking that it is received unchanged from in. - // It resends once per second until the send succeeds, or pingTimeout time has elapsed. - sendWithTimeout := func(msg []byte, in chan []byte, send func()) error { - start := time.Now() - for time.Since(start) < pingTimeout { - send() - select { - case recv := <-in: - if !bytes.Equal(msg, recv) { - return errors.New("ping did not transit correctly") - } - return nil - case <-time.After(time.Second): - // try again - } - } - return errors.New("ping timed out") - } - - ping1 := func(t *testing.T) { - msg2to1 := tuntest.Ping(netip.MustParseAddr("1.0.0.1"), netip.MustParseAddr("1.0.0.2")) - send := func() { - m2.tun.Outbound <- msg2to1 - t.Log("ping1 sent") - } - in := m1.tun.Inbound - if err := sendWithTimeout(msg2to1, in, send); err != nil { - t.Error(err) - } - } - ping2 := func(t *testing.T) { - msg1to2 := tuntest.Ping(netip.MustParseAddr("1.0.0.2"), netip.MustParseAddr("1.0.0.1")) - send := func() { - m1.tun.Outbound <- msg1to2 - t.Log("ping2 sent") - } - in := m2.tun.Inbound - if err := sendWithTimeout(msg1to2, in, send); err != nil { - t.Error(err) - } - } - - m1.stats = connstats.NewStatistics(0, 0, nil) - defer m1.stats.Shutdown(context.Background()) - m1.conn.SetStatistics(m1.stats) - m2.stats = connstats.NewStatistics(0, 0, nil) - defer m2.stats.Shutdown(context.Background()) - m2.conn.SetStatistics(m2.stats) - - checkStats := func(t *testing.T, m *magicStack, wantConns []netlogtype.Connection) { - _, stats := m.stats.TestExtract() - for _, conn := range wantConns { - if _, ok := stats[conn]; ok { - return - } - } - t.Helper() - t.Errorf("missing any connection to %s from %s", wantConns, xmaps.Keys(stats)) - } - - addrPort := netip.MustParseAddrPort - m1Conns := []netlogtype.Connection{ - {Src: addrPort("1.0.0.2:0"), Dst: m2.conn.pconn4.LocalAddr().AddrPort()}, - {Src: addrPort("1.0.0.2:0"), Dst: addrPort("127.3.3.40:1")}, - } - m2Conns := []netlogtype.Connection{ - {Src: addrPort("1.0.0.1:0"), Dst: m1.conn.pconn4.LocalAddr().AddrPort()}, - {Src: addrPort("1.0.0.1:0"), Dst: addrPort("127.3.3.40:1")}, - } - - outerT := t - t.Run("ping 1.0.0.1", func(t *testing.T) { - setT(t) - defer setT(outerT) - ping1(t) - checkStats(t, m1, m1Conns) - checkStats(t, m2, m2Conns) - }) - - t.Run("ping 1.0.0.2", func(t *testing.T) { - setT(t) - defer setT(outerT) - ping2(t) - checkStats(t, m1, m1Conns) - checkStats(t, m2, m2Conns) - }) - - t.Run("ping 1.0.0.2 via SendPacket", func(t *testing.T) { - setT(t) - defer setT(outerT) - msg1to2 := tuntest.Ping(netip.MustParseAddr("1.0.0.2"), netip.MustParseAddr("1.0.0.1")) - send := func() { - if err := m1.tsTun.InjectOutbound(msg1to2); err != nil { - t.Fatal(err) - } - t.Log("SendPacket sent") - } - in := m2.tun.Inbound - if err := sendWithTimeout(msg1to2, in, send); err != nil { - t.Error(err) - } - checkStats(t, m1, m1Conns) - checkStats(t, m2, m2Conns) - }) - - t.Run("no-op dev1 reconfig", func(t *testing.T) { - setT(t) - defer setT(outerT) - if err := m1.Reconfig(m1cfg); err != nil { - t.Fatal(err) - } - ping1(t) - ping2(t) - checkStats(t, m1, m1Conns) - checkStats(t, m2, m2Conns) - }) - t.Run("compare-metrics-stats", func(t *testing.T) { - setT(t) - defer setT(outerT) - m1.conn.resetMetricsForTest() - m1.stats.TestExtract() - m2.conn.resetMetricsForTest() - m2.stats.TestExtract() - t.Logf("Metrics before: %s\n", m1.metrics.String()) - ping1(t) - ping2(t) - assertConnStatsAndUserMetricsEqual(t, m1) - assertConnStatsAndUserMetricsEqual(t, m2) - t.Logf("Metrics after: %s\n", m1.metrics.String()) - }) -} - -func (c *Conn) resetMetricsForTest() { - c.metrics.inboundBytesIPv4Total.Set(0) - c.metrics.inboundPacketsIPv4Total.Set(0) - c.metrics.outboundBytesIPv4Total.Set(0) - c.metrics.outboundPacketsIPv4Total.Set(0) - c.metrics.inboundBytesIPv6Total.Set(0) - c.metrics.inboundPacketsIPv6Total.Set(0) - c.metrics.outboundBytesIPv6Total.Set(0) - c.metrics.outboundPacketsIPv6Total.Set(0) - c.metrics.inboundBytesDERPTotal.Set(0) - c.metrics.inboundPacketsDERPTotal.Set(0) - c.metrics.outboundBytesDERPTotal.Set(0) - c.metrics.outboundPacketsDERPTotal.Set(0) -} - -func assertConnStatsAndUserMetricsEqual(t *testing.T, ms *magicStack) { - _, phys := ms.stats.TestExtract() - - physIPv4RxBytes := int64(0) - physIPv4TxBytes := int64(0) - physDERPRxBytes := int64(0) - physDERPTxBytes := int64(0) - physIPv4RxPackets := int64(0) - physIPv4TxPackets := int64(0) - physDERPRxPackets := int64(0) - physDERPTxPackets := int64(0) - for conn, count := range phys { - t.Logf("physconn src: %s, dst: %s", conn.Src.String(), conn.Dst.String()) - if conn.Dst.String() == "127.3.3.40:1" { - physDERPRxBytes += int64(count.RxBytes) - physDERPTxBytes += int64(count.TxBytes) - physDERPRxPackets += int64(count.RxPackets) - physDERPTxPackets += int64(count.TxPackets) - } else { - physIPv4RxBytes += int64(count.RxBytes) - physIPv4TxBytes += int64(count.TxBytes) - physIPv4RxPackets += int64(count.RxPackets) - physIPv4TxPackets += int64(count.TxPackets) - } - } - - metricIPv4RxBytes := ms.conn.metrics.inboundBytesIPv4Total.Value() - metricIPv4RxPackets := ms.conn.metrics.inboundPacketsIPv4Total.Value() - metricIPv4TxBytes := ms.conn.metrics.outboundBytesIPv4Total.Value() - metricIPv4TxPackets := ms.conn.metrics.outboundPacketsIPv4Total.Value() - - metricDERPRxBytes := ms.conn.metrics.inboundBytesDERPTotal.Value() - metricDERPRxPackets := ms.conn.metrics.inboundPacketsDERPTotal.Value() - metricDERPTxBytes := ms.conn.metrics.outboundBytesDERPTotal.Value() - metricDERPTxPackets := ms.conn.metrics.outboundPacketsDERPTotal.Value() - - c := qt.New(t) - c.Assert(physDERPRxBytes, qt.Equals, metricDERPRxBytes) - c.Assert(physDERPTxBytes, qt.Equals, metricDERPTxBytes) - c.Assert(physIPv4RxBytes, qt.Equals, metricIPv4RxBytes) - c.Assert(physIPv4TxBytes, qt.Equals, metricIPv4TxBytes) - c.Assert(physDERPRxPackets, qt.Equals, metricDERPRxPackets) - c.Assert(physDERPTxPackets, qt.Equals, metricDERPTxPackets) - c.Assert(physIPv4RxPackets, qt.Equals, metricIPv4RxPackets) - c.Assert(physIPv4TxPackets, qt.Equals, metricIPv4TxPackets) - - // Validate that the usermetrics and clientmetrics are in sync - // Note: the clientmetrics are global, this means that when they are registering with the - // wgengine, multiple in-process nodes used by this test will be updating the same metrics. This is why we need to multiply - // the metrics by 2 to get the expected value. - // TODO(kradalby): https://github.com/tailscale/tailscale/issues/13420 - c.Assert(metricSendUDP.Value(), qt.Equals, metricIPv4TxPackets*2) - c.Assert(metricRecvDataPacketsIPv4.Value(), qt.Equals, metricIPv4RxPackets*2) - c.Assert(metricRecvDataPacketsDERP.Value(), qt.Equals, metricDERPRxPackets*2) -} - -func TestDiscoMessage(t *testing.T) { - c := newConn(t.Logf) - c.privateKey = key.NewNode() - - peer1Pub := c.DiscoPublicKey() - peer1Priv := c.discoPrivate - n := &tailcfg.Node{ - Key: key.NewNode().Public(), - DiscoKey: peer1Pub, - } - ep := &endpoint{ - nodeID: 1, - publicKey: n.Key, - } - ep.disco.Store(&endpointDisco{ - key: n.DiscoKey, - short: n.DiscoKey.ShortString(), - }) - c.peerMap.upsertEndpoint(ep, key.DiscoPublic{}) - - const payload = "why hello" - - var nonce [24]byte - crand.Read(nonce[:]) - - pkt := peer1Pub.AppendTo([]byte("TS💬")) - - box := peer1Priv.Shared(c.discoPrivate.Public()).Seal([]byte(payload)) - pkt = append(pkt, box...) - got := c.handleDiscoMessage(pkt, netip.AddrPort{}, key.NodePublic{}, discoRXPathUDP) - if !got { - t.Error("failed to open it") - } -} - -// tests that having a endpoint.String prevents wireguard-go's -// log.Printf("%v") of its conn.Endpoint values from using reflect to -// walk into read mutex while they're being used and then causing data -// races. -func TestDiscoStringLogRace(t *testing.T) { - de := new(endpoint) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - fmt.Fprintf(io.Discard, "%v", de) - }() - go func() { - defer wg.Done() - de.mu.Lock() - }() - wg.Wait() -} - -func Test32bitAlignment(t *testing.T) { - // Need an associated conn with non-nil noteRecvActivity to - // trigger interesting work on the atomics in endpoint. - called := 0 - de := endpoint{ - c: &Conn{ - noteRecvActivity: func(key.NodePublic) { called++ }, - }, - } - - if off := unsafe.Offsetof(de.lastRecvWG); off%8 != 0 { - t.Fatalf("endpoint.lastRecvWG is not 8-byte aligned") - } - - de.noteRecvActivity(netip.AddrPort{}, mono.Now()) // verify this doesn't panic on 32-bit - if called != 1 { - t.Fatal("expected call to noteRecvActivity") - } - de.noteRecvActivity(netip.AddrPort{}, mono.Now()) - if called != 1 { - t.Error("expected no second call to noteRecvActivity") - } -} - -// newTestConn returns a new Conn. -func newTestConn(t testing.TB) *Conn { - t.Helper() - port := pickPort(t) - - netMon, err := netmon.New(logger.WithPrefix(t.Logf, "... netmon: ")) - if err != nil { - t.Fatalf("netmon.New: %v", err) - } - t.Cleanup(func() { netMon.Close() }) - - conn, err := NewConn(Options{ - NetMon: netMon, - HealthTracker: new(health.Tracker), - Metrics: new(usermetric.Registry), - DisablePortMapper: true, - Logf: t.Logf, - Port: port, - TestOnlyPacketListener: localhostListener{}, - EndpointsFunc: func(eps []tailcfg.Endpoint) { - t.Logf("endpoints: %q", eps) - }, - }) - if err != nil { - t.Fatal(err) - } - return conn -} - -// addTestEndpoint sets conn's network map to a single peer expected -// to receive packets from sendConn (or DERP), and returns that peer's -// nodekey and discokey. -func addTestEndpoint(tb testing.TB, conn *Conn, sendConn net.PacketConn) (key.NodePublic, key.DiscoPublic) { - // Give conn just enough state that it'll recognize sendConn as a - // valid peer and not fall through to the legacy magicsock - // codepath. - discoKey := key.DiscoPublicFromRaw32(mem.B([]byte{31: 1})) - nodeKey := key.NodePublicFromRaw32(mem.B([]byte{0: 'N', 1: 'K', 31: 0})) - conn.SetNetworkMap(&netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Key: nodeKey, - DiscoKey: discoKey, - Endpoints: eps(sendConn.LocalAddr().String()), - }, - }), - }) - conn.SetPrivateKey(key.NodePrivateFromRaw32(mem.B([]byte{0: 1, 31: 0}))) - _, err := conn.ParseEndpoint(nodeKey.UntypedHexString()) - if err != nil { - tb.Fatal(err) - } - conn.addValidDiscoPathForTest(nodeKey, netip.MustParseAddrPort(sendConn.LocalAddr().String())) - return nodeKey, discoKey -} - -func setUpReceiveFrom(tb testing.TB) (roundTrip func()) { - if b, ok := tb.(*testing.B); ok { - b.ReportAllocs() - } - - conn := newTestConn(tb) - tb.Cleanup(func() { conn.Close() }) - conn.logf = logger.Discard - - sendConn, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - tb.Fatal(err) - } - tb.Cleanup(func() { sendConn.Close() }) - - addTestEndpoint(tb, conn, sendConn) - - var dstAddr net.Addr = conn.pconn4.LocalAddr() - sendBuf := make([]byte, 1<<10) - for i := range sendBuf { - sendBuf[i] = 'x' - } - buffs := make([][]byte, 1) - buffs[0] = make([]byte, 2<<10) - sizes := make([]int, 1) - eps := make([]wgconn.Endpoint, 1) - receiveIPv4 := conn.receiveIPv4() - return func() { - if _, err := sendConn.WriteTo(sendBuf, dstAddr); err != nil { - tb.Fatalf("WriteTo: %v", err) - } - n, err := receiveIPv4(buffs, sizes, eps) - if err != nil { - tb.Fatal(err) - } - _ = n - _ = eps - } -} - -// goMajorVersion reports the major Go version and whether it is a Tailscale fork. -// If parsing fails, goMajorVersion returns 0, false. -func goMajorVersion(s string) (version int, isTS bool) { - if !strings.HasPrefix(s, "go1.") { - return 0, false - } - mm := s[len("go1."):] - var major, rest string - for _, sep := range []string{".", "rc", "beta", "-"} { - i := strings.Index(mm, sep) - if i > 0 { - major, rest = mm[:i], mm[i:] - break - } - } - if major == "" { - major = mm - } - n, err := strconv.Atoi(major) - if err != nil { - return 0, false - } - return n, strings.Contains(rest, "ts") -} - -func TestGoMajorVersion(t *testing.T) { - tests := []struct { - version string - wantN int - wantTS bool - }{ - {"go1.15.8", 15, false}, - {"go1.16rc1", 16, false}, - {"go1.16rc1", 16, false}, - {"go1.15.5-ts3bd89195a3", 15, true}, - {"go1.15", 15, false}, - {"go1.18-ts0d07ed810a", 18, true}, - } - - for _, tt := range tests { - n, ts := goMajorVersion(tt.version) - if tt.wantN != n || tt.wantTS != ts { - t.Errorf("goMajorVersion(%s) = %v, %v, want %v, %v", tt.version, n, ts, tt.wantN, tt.wantTS) - } - } - - // Ensure that the current Go version is parseable. - n, _ := goMajorVersion(runtime.Version()) - if n == 0 { - t.Fatalf("unable to parse %v", runtime.Version()) - } -} - -func TestReceiveFromAllocs(t *testing.T) { - // TODO(jwhited): we are back to nonzero alloc due to our use of x/net until - // https://github.com/golang/go/issues/45886 is implemented. - t.Skip("alloc tests are skipped until https://github.com/golang/go/issues/45886 is implemented and plumbed.") - if racebuild.On { - t.Skip("alloc tests are unreliable with -race") - } - // Go 1.16 and before: allow 3 allocs. - // Go 1.17: allow 2 allocs. - // Go 1.17, Tailscale fork: allow 1 alloc. - // Go 1.18+: allow 0 allocs. - // Go 2.0: allow -1 allocs (projected). - major, ts := goMajorVersion(runtime.Version()) - maxAllocs := 3 - switch { - case major == 17 && !ts: - maxAllocs = 2 - case major == 17 && ts: - maxAllocs = 1 - case major >= 18: - maxAllocs = 0 - } - t.Logf("allowing %d allocs for Go version %q", maxAllocs, runtime.Version()) - roundTrip := setUpReceiveFrom(t) - err := tstest.MinAllocsPerRun(t, uint64(maxAllocs), roundTrip) - if err != nil { - t.Fatal(err) - } -} - -func BenchmarkReceiveFrom(b *testing.B) { - roundTrip := setUpReceiveFrom(b) - for range b.N { - roundTrip() - } -} - -func BenchmarkReceiveFrom_Native(b *testing.B) { - b.ReportAllocs() - recvConn, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - b.Fatal(err) - } - defer recvConn.Close() - recvConnUDP := recvConn.(*net.UDPConn) - - sendConn, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - b.Fatal(err) - } - defer sendConn.Close() - - var dstAddr net.Addr = recvConn.LocalAddr() - sendBuf := make([]byte, 1<<10) - for i := range sendBuf { - sendBuf[i] = 'x' - } - - buf := make([]byte, 2<<10) - for range b.N { - if _, err := sendConn.WriteTo(sendBuf, dstAddr); err != nil { - b.Fatalf("WriteTo: %v", err) - } - if _, _, err := recvConnUDP.ReadFromUDP(buf); err != nil { - b.Fatalf("ReadFromUDP: %v", err) - } - } -} - -func nodeViews(v []*tailcfg.Node) []tailcfg.NodeView { - nv := make([]tailcfg.NodeView, len(v)) - for i, n := range v { - nv[i] = n.View() - } - return nv -} - -// Test that a netmap update where node changes its node key but -// doesn't change its disco key doesn't result in a broken state. -// -// https://github.com/tailscale/tailscale/issues/1391 -func TestSetNetworkMapChangingNodeKey(t *testing.T) { - conn := newTestConn(t) - t.Cleanup(func() { conn.Close() }) - var buf tstest.MemLogger - conn.logf = buf.Logf - - conn.SetPrivateKey(key.NodePrivateFromRaw32(mem.B([]byte{0: 1, 31: 0}))) - - discoKey := key.DiscoPublicFromRaw32(mem.B([]byte{31: 1})) - nodeKey1 := key.NodePublicFromRaw32(mem.B([]byte{0: 'N', 1: 'K', 2: '1', 31: 0})) - nodeKey2 := key.NodePublicFromRaw32(mem.B([]byte{0: 'N', 1: 'K', 2: '2', 31: 0})) - - conn.SetNetworkMap(&netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Key: nodeKey1, - DiscoKey: discoKey, - Endpoints: eps("192.168.1.2:345"), - }, - }), - }) - _, err := conn.ParseEndpoint(nodeKey1.UntypedHexString()) - if err != nil { - t.Fatal(err) - } - - for range 3 { - conn.SetNetworkMap(&netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 2, - Key: nodeKey2, - DiscoKey: discoKey, - Endpoints: eps("192.168.1.2:345"), - }, - }), - }) - } - - de, ok := conn.peerMap.endpointForNodeKey(nodeKey2) - if ok && de.publicKey != nodeKey2 { - t.Fatalf("discoEndpoint public key = %q; want %q", de.publicKey, nodeKey2) - } - deDisco := de.disco.Load() - if deDisco == nil { - t.Fatalf("discoEndpoint disco is nil") - } - if deDisco.key != discoKey { - t.Errorf("discoKey = %v; want %v", deDisco.key, discoKey) - } - if _, ok := conn.peerMap.endpointForNodeKey(nodeKey1); ok { - t.Errorf("didn't expect to find node for key1") - } - - log := buf.String() - wantSub := map[string]int{ - "magicsock: got updated network map; 1 peers": 2, - } - for sub, want := range wantSub { - got := strings.Count(log, sub) - if got != want { - t.Errorf("in log, count of substring %q = %v; want %v", sub, got, want) - } - } - if t.Failed() { - t.Logf("log output: %s", log) - } -} - -func TestRebindStress(t *testing.T) { - conn := newTestConn(t) - - var buf tstest.MemLogger - conn.logf = buf.Logf - - closed := false - t.Cleanup(func() { - if !closed { - conn.Close() - } - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - errc := make(chan error, 1) - go func() { - buffs := make([][]byte, 1) - sizes := make([]int, 1) - eps := make([]wgconn.Endpoint, 1) - buffs[0] = make([]byte, 1500) - receiveIPv4 := conn.receiveIPv4() - for { - _, err := receiveIPv4(buffs, sizes, eps) - if ctx.Err() != nil { - errc <- nil - return - } - if err != nil { - errc <- err - return - } - } - }() - - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - for range 2000 { - conn.Rebind() - } - }() - go func() { - defer wg.Done() - for range 2000 { - conn.Rebind() - } - }() - wg.Wait() - - cancel() - if err := conn.Close(); err != nil { - t.Fatal(err) - } - closed = true - - err := <-errc - if err != nil { - t.Fatalf("Got ReceiveIPv4 error: %v (is closed = %v). Log:\n%s", err, errors.Is(err, net.ErrClosed), buf.String()) - } -} - -func TestEndpointSetsEqual(t *testing.T) { - s := func(ports ...uint16) (ret []tailcfg.Endpoint) { - for _, port := range ports { - ret = append(ret, tailcfg.Endpoint{ - Addr: netip.AddrPortFrom(netip.Addr{}, port), - }) - } - return - } - tests := []struct { - a, b []tailcfg.Endpoint - want bool - }{ - { - want: true, - }, - { - a: s(1, 2, 3), - b: s(1, 2, 3), - want: true, - }, - { - a: s(1, 2), - b: s(2, 1), - want: true, - }, - { - a: s(1, 2), - b: s(2, 1, 1), - want: true, - }, - { - a: s(1, 2, 2), - b: s(2, 1), - want: true, - }, - { - a: s(1, 2, 2), - b: s(2, 1, 1), - want: true, - }, - { - a: s(1, 2, 2, 3), - b: s(2, 1, 1), - want: false, - }, - { - a: s(1, 2, 2), - b: s(2, 1, 1, 3), - want: false, - }, - } - for _, tt := range tests { - if got := endpointSetsEqual(tt.a, tt.b); got != tt.want { - t.Errorf("%q vs %q = %v; want %v", tt.a, tt.b, got, tt.want) - } - } - -} - -func TestBetterAddr(t *testing.T) { - const ms = time.Millisecond - al := func(ipps string, d time.Duration) addrQuality { - return addrQuality{AddrPort: netip.MustParseAddrPort(ipps), latency: d} - } - almtu := func(ipps string, d time.Duration, mtu tstun.WireMTU) addrQuality { - return addrQuality{AddrPort: netip.MustParseAddrPort(ipps), latency: d, wireMTU: mtu} - } - zero := addrQuality{} - - const ( - publicV4 = "1.2.3.4:555" - publicV4_2 = "5.6.7.8:999" - publicV6 = "[2001::5]:123" - - privateV4 = "10.0.0.2:123" - ) - - tests := []struct { - a, b addrQuality - want bool // whether a is better than b - }{ - {a: zero, b: zero, want: false}, - {a: al(publicV4, 5*ms), b: zero, want: true}, - {a: zero, b: al(publicV4, 5*ms), want: false}, - {a: al(publicV4, 5*ms), b: al(publicV4_2, 10*ms), want: true}, - {a: al(publicV4, 5*ms), b: al(publicV4, 10*ms), want: false}, // same IPPort - - // Don't prefer b to a if it's not substantially better. - {a: al(publicV4, 100*ms), b: al(publicV4_2, 100*ms), want: false}, - {a: al(publicV4, 100*ms), b: al(publicV4_2, 101*ms), want: false}, - {a: al(publicV4, 100*ms), b: al(publicV4_2, 103*ms), want: true}, - - // Latencies of zero don't result in a divide-by-zero - {a: al(publicV4, 0), b: al(publicV4_2, 0), want: false}, - - // Prefer private IPs to public IPs if roughly equivalent... - { - a: al(privateV4, 100*ms), - b: al(publicV4, 91*ms), - want: true, - }, - { - a: al(publicV4, 91*ms), - b: al(privateV4, 100*ms), - want: false, - }, - // ... but not if the private IP is slower. - { - a: al(privateV4, 100*ms), - b: al(publicV4, 30*ms), - want: false, - }, - { - a: al(publicV4, 30*ms), - b: al(privateV4, 100*ms), - want: true, - }, - - // Prefer IPv6 if roughly equivalent: - { - a: al(publicV6, 100*ms), - b: al(publicV4, 91*ms), - want: true, - }, - { - a: al(publicV4, 91*ms), - b: al(publicV6, 100*ms), - want: false, - }, - // But not if IPv4 is much faster: - { - a: al(publicV6, 100*ms), - b: al(publicV4, 30*ms), - want: false, - }, - { - a: al(publicV4, 30*ms), - b: al(publicV6, 100*ms), - want: true, - }, - // If addresses are equal, prefer larger MTU - { - a: almtu(publicV4, 30*ms, 1500), - b: almtu(publicV4, 30*ms, 0), - want: true, - }, - // Private IPs are preferred over public IPs even if the public - // IP is IPv6. - { - a: al("192.168.0.1:555", 100*ms), - b: al("[2001::5]:123", 101*ms), - want: true, - }, - { - a: al("[2001::5]:123", 101*ms), - b: al("192.168.0.1:555", 100*ms), - want: false, - }, - - // Link-local unicast addresses are preferred over other - // private IPs, but not as much as localhost addresses. - { - a: al("[fe80::ce8:474a:a27e:113b]:555", 101*ms), - b: al("[fd89:1a8a:8888:9999:aaaa:bbbb:cccc:dddd]:555", 100*ms), - want: true, - }, - { - a: al("[fe80::ce8:474a:a27e:113b]:555", 101*ms), - b: al("[::1]:555", 100*ms), - want: false, - }, - } - for i, tt := range tests { - got := betterAddr(tt.a, tt.b) - if got != tt.want { - t.Errorf("[%d] betterAddr(%+v, %+v) = %v; want %v", i, tt.a, tt.b, got, tt.want) - continue - } - gotBack := betterAddr(tt.b, tt.a) - if got && gotBack { - t.Errorf("[%d] betterAddr(%+v, %+v) and betterAddr(%+v, %+v) both unexpectedly true", i, tt.a, tt.b, tt.b, tt.a) - } - } - -} - -func epFromTyped(eps []tailcfg.Endpoint) (ret []netip.AddrPort) { - for _, ep := range eps { - ret = append(ret, ep.Addr) - } - return -} - -func eps(s ...string) []netip.AddrPort { - var eps []netip.AddrPort - for _, ep := range s { - eps = append(eps, netip.MustParseAddrPort(ep)) - } - return eps -} - -func TestStressSetNetworkMap(t *testing.T) { - t.Parallel() - - conn := newTestConn(t) - t.Cleanup(func() { conn.Close() }) - var buf tstest.MemLogger - conn.logf = buf.Logf - - conn.SetPrivateKey(key.NewNode()) - - const npeers = 5 - present := make([]bool, npeers) - allPeers := make([]*tailcfg.Node, npeers) - for i := range allPeers { - present[i] = true - allPeers[i] = &tailcfg.Node{ - ID: tailcfg.NodeID(i) + 1, - DiscoKey: randDiscoKey(), - Key: randNodeKey(), - Endpoints: eps(fmt.Sprintf("192.168.1.2:%d", i)), - } - } - - // Get a PRNG seed. If not provided, generate a new one to get extra coverage. - seed, err := strconv.ParseUint(os.Getenv("TS_STRESS_SET_NETWORK_MAP_SEED"), 10, 64) - if err != nil { - var buf [8]byte - crand.Read(buf[:]) - seed = binary.LittleEndian.Uint64(buf[:]) - } - t.Logf("TS_STRESS_SET_NETWORK_MAP_SEED=%d", seed) - prng := rand.New(rand.NewSource(int64(seed))) - - const iters = 1000 // approx 0.5s on an m1 mac - for range iters { - for j := 0; j < npeers; j++ { - // Randomize which peers are present. - if prng.Int()&1 == 0 { - present[j] = !present[j] - } - // Randomize some peer disco keys and node keys. - if prng.Int()&1 == 0 { - allPeers[j].DiscoKey = randDiscoKey() - } - if prng.Int()&1 == 0 { - allPeers[j].Key = randNodeKey() - } - } - // Clone existing peers into a new netmap. - peers := make([]*tailcfg.Node, 0, len(allPeers)) - for peerIdx, p := range allPeers { - if present[peerIdx] { - peers = append(peers, p.Clone()) - } - } - // Set the netmap. - conn.SetNetworkMap(&netmap.NetworkMap{ - Peers: nodeViews(peers), - }) - // Check invariants. - if err := conn.peerMap.validate(); err != nil { - t.Error(err) - } - } -} - -func randDiscoKey() (k key.DiscoPublic) { return key.NewDisco().Public() } -func randNodeKey() (k key.NodePublic) { return key.NewNode().Public() } - -// validate checks m for internal consistency and reports the first error encountered. -// It is used in tests only, so it doesn't need to be efficient. -func (m *peerMap) validate() error { - seenEps := make(map[*endpoint]bool) - for pub, pi := range m.byNodeKey { - if got := pi.ep.publicKey; got != pub { - return fmt.Errorf("byNodeKey[%v].publicKey = %v", pub, got) - } - if _, ok := seenEps[pi.ep]; ok { - return fmt.Errorf("duplicate endpoint present: %v", pi.ep.publicKey) - } - seenEps[pi.ep] = true - for ipp := range pi.ipPorts { - if got := m.byIPPort[ipp]; got != pi { - return fmt.Errorf("m.byIPPort[%v] = %v, want %v", ipp, got, pi) - } - } - } - if len(m.byNodeKey) != len(m.byNodeID) { - return fmt.Errorf("len(m.byNodeKey)=%d != len(m.byNodeID)=%d", len(m.byNodeKey), len(m.byNodeID)) - } - for nodeID, pi := range m.byNodeID { - ep := pi.ep - if pi2, ok := m.byNodeKey[ep.publicKey]; !ok { - return fmt.Errorf("nodeID %d in map with publicKey %v that's missing from map", nodeID, ep.publicKey) - } else if pi2 != pi { - return fmt.Errorf("nodeID %d in map with publicKey %v that points to different endpoint", nodeID, ep.publicKey) - } - } - - for ipp, pi := range m.byIPPort { - if !pi.ipPorts.Contains(ipp) { - return fmt.Errorf("ipPorts[%v] for %v is false", ipp, pi.ep.publicKey) - } - pi2 := m.byNodeKey[pi.ep.publicKey] - if pi != pi2 { - return fmt.Errorf("byNodeKey[%v]=%p doesn't match byIPPort[%v]=%p", pi, pi, pi.ep.publicKey, pi2) - } - } - - publicToDisco := make(map[key.NodePublic]key.DiscoPublic) - for disco, nodes := range m.nodesOfDisco { - for pub := range nodes { - if _, ok := m.byNodeKey[pub]; !ok { - return fmt.Errorf("nodesOfDisco refers to public key %v, which is not present in byNodeKey", pub) - } - if _, ok := publicToDisco[pub]; ok { - return fmt.Errorf("publicKey %v refers to multiple disco keys", pub) - } - publicToDisco[pub] = disco - } - } - - return nil -} - -func TestBlockForeverConnUnblocks(t *testing.T) { - c := newBlockForeverConn() - done := make(chan error, 1) - go func() { - defer close(done) - _, _, err := c.ReadFromUDPAddrPort(make([]byte, 1)) - done <- err - }() - time.Sleep(50 * time.Millisecond) // give ReadFrom time to get blocked - if err := c.Close(); err != nil { - t.Fatal(err) - } - timer := time.NewTimer(5 * time.Second) - defer timer.Stop() - select { - case err := <-done: - if err != net.ErrClosed { - t.Errorf("got %v; want net.ErrClosed", err) - } - case <-timer.C: - t.Fatal("timeout") - } -} - -func TestDiscoMagicMatches(t *testing.T) { - // Convert our disco magic number into a uint32 and uint16 to test - // against. We panic on an incorrect length here rather than try to be - // generic with our BPF instructions below. - // - // Note that BPF uses network byte order (big-endian) when loading data - // from a packet, so that is what we use to generate our magic numbers. - if len(disco.Magic) != 6 { - t.Fatalf("expected disco.Magic to be of length 6") - } - if m1 := binary.BigEndian.Uint32([]byte(disco.Magic[:4])); m1 != discoMagic1 { - t.Errorf("first 4 bytes of disco magic don't match, got %v want %v", discoMagic1, m1) - } - if m2 := binary.BigEndian.Uint16([]byte(disco.Magic[4:6])); m2 != discoMagic2 { - t.Errorf("last 2 bytes of disco magic don't match, got %v want %v", discoMagic2, m2) - } -} - -func TestRebindingUDPConn(t *testing.T) { - // Test that RebindingUDPConn can be re-bound to different connection - // types. - c := RebindingUDPConn{} - realConn, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer realConn.Close() - c.setConnLocked(realConn.(nettype.PacketConn), "udp4", 1) - c.setConnLocked(newBlockForeverConn(), "", 1) -} - -// https://github.com/tailscale/tailscale/issues/6680: don't ignore -// SetNetworkMap calls when there are no peers. (A too aggressive fast path was -// previously bailing out early, thinking there were no changes since all zero -// peers didn't change, but the netmap has non-peer info in it too we shouldn't discard) -func TestSetNetworkMapWithNoPeers(t *testing.T) { - var c Conn - knobs := &controlknobs.Knobs{} - c.logf = logger.Discard - c.controlKnobs = knobs // TODO(bradfitz): move silent disco bool to controlknobs - - for i := 1; i <= 3; i++ { - v := !debugEnableSilentDisco() - envknob.Setenv("TS_DEBUG_ENABLE_SILENT_DISCO", fmt.Sprint(v)) - nm := &netmap.NetworkMap{} - c.SetNetworkMap(nm) - t.Logf("ptr %d: %p", i, nm) - if c.lastFlags.heartbeatDisabled != v { - t.Fatalf("call %d: didn't store netmap", i) - } - } -} - -func TestBufferedDerpWritesBeforeDrop(t *testing.T) { - vv := bufferedDerpWritesBeforeDrop() - if vv < 32 { - t.Fatalf("got bufferedDerpWritesBeforeDrop=%d, which is < 32", vv) - } - t.Logf("bufferedDerpWritesBeforeDrop = %d", vv) -} - -// newWireguard starts up a new wireguard-go device attached to a test tun, and -// returns the device, tun and endpoint port. To add peers call device.IpcSet with UAPI instructions. -func newWireguard(t *testing.T, uapi string, aips []netip.Prefix) (*device.Device, *tuntest.ChannelTUN, uint16) { - wgtun := tuntest.NewChannelTUN() - wglogf := func(f string, args ...any) { - t.Logf("wg-go: "+f, args...) - } - wglog := device.Logger{ - Verbosef: func(string, ...any) {}, - Errorf: wglogf, - } - wgdev := wgcfg.NewDevice(wgtun.TUN(), wgconn.NewDefaultBind(), &wglog) - - if err := wgdev.IpcSet(uapi); err != nil { - t.Fatal(err) - } - - if err := wgdev.Up(); err != nil { - t.Fatal(err) - } - - var port uint16 - s, err := wgdev.IpcGet() - if err != nil { - t.Fatal(err) - } - for _, line := range strings.Split(s, "\n") { - line = strings.TrimSpace(line) - if len(line) == 0 { - continue - } - k, v, _ := strings.Cut(line, "=") - if k == "listen_port" { - p, err := strconv.ParseUint(v, 10, 16) - if err != nil { - panic(err) - } - port = uint16(p) - break - } - } - - return wgdev, wgtun, port -} - -func TestIsWireGuardOnlyPeer(t *testing.T) { - derpMap, cleanup := runDERPAndStun(t, t.Logf, localhostListener{}, netaddr.IPv4(127, 0, 0, 1)) - defer cleanup() - - tskey := key.NewNode() - tsaip := netip.MustParsePrefix("100.111.222.111/32") - - wgkey := key.NewNode() - wgaip := netip.MustParsePrefix("100.222.111.222/32") - - uapi := fmt.Sprintf("private_key=%s\npublic_key=%s\nallowed_ip=%s\n\n", - wgkey.UntypedHexString(), tskey.Public().UntypedHexString(), tsaip.String()) - wgdev, wgtun, port := newWireguard(t, uapi, []netip.Prefix{wgaip}) - defer wgdev.Close() - wgEp := netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), port) - - m := newMagicStackWithKey(t, t.Logf, localhostListener{}, derpMap, tskey) - defer m.Close() - - nm := &netmap.NetworkMap{ - Name: "ts", - PrivateKey: m.privateKey, - NodeKey: m.privateKey.Public(), - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{tsaip}, - }).View(), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Key: wgkey.Public(), - Endpoints: []netip.AddrPort{wgEp}, - IsWireGuardOnly: true, - Addresses: []netip.Prefix{wgaip}, - AllowedIPs: []netip.Prefix{wgaip}, - }, - }), - } - m.conn.SetNetworkMap(nm) - - cfg, err := nmcfg.WGCfg(nm, t.Logf, netmap.AllowSubnetRoutes, "") - if err != nil { - t.Fatal(err) - } - m.Reconfig(cfg) - - pbuf := tuntest.Ping(wgaip.Addr(), tsaip.Addr()) - m.tun.Outbound <- pbuf - - select { - case p := <-wgtun.Inbound: - if !bytes.Equal(p, pbuf) { - t.Errorf("got unexpected packet: %x", p) - } - case <-time.After(time.Second): - t.Fatal("no packet after 1s") - } -} - -func TestIsWireGuardOnlyPeerWithMasquerade(t *testing.T) { - derpMap, cleanup := runDERPAndStun(t, t.Logf, localhostListener{}, netaddr.IPv4(127, 0, 0, 1)) - defer cleanup() - - tskey := key.NewNode() - tsaip := netip.MustParsePrefix("100.111.222.111/32") - - wgkey := key.NewNode() - wgaip := netip.MustParsePrefix("10.64.0.1/32") - - // the ip that the wireguard peer has in allowed ips and expects as a masq source - masqip := netip.MustParsePrefix("10.64.0.2/32") - - uapi := fmt.Sprintf("private_key=%s\npublic_key=%s\nallowed_ip=%s\n\n", - wgkey.UntypedHexString(), tskey.Public().UntypedHexString(), masqip.String()) - wgdev, wgtun, port := newWireguard(t, uapi, []netip.Prefix{wgaip}) - defer wgdev.Close() - wgEp := netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), port) - - m := newMagicStackWithKey(t, t.Logf, localhostListener{}, derpMap, tskey) - defer m.Close() - - nm := &netmap.NetworkMap{ - Name: "ts", - PrivateKey: m.privateKey, - NodeKey: m.privateKey.Public(), - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{tsaip}, - }).View(), - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Key: wgkey.Public(), - Endpoints: []netip.AddrPort{wgEp}, - IsWireGuardOnly: true, - Addresses: []netip.Prefix{wgaip}, - AllowedIPs: []netip.Prefix{wgaip}, - SelfNodeV4MasqAddrForThisPeer: ptr.To(masqip.Addr()), - }, - }), - } - m.conn.SetNetworkMap(nm) - - cfg, err := nmcfg.WGCfg(nm, t.Logf, netmap.AllowSubnetRoutes, "") - if err != nil { - t.Fatal(err) - } - m.Reconfig(cfg) - - pbuf := tuntest.Ping(wgaip.Addr(), tsaip.Addr()) - m.tun.Outbound <- pbuf - - select { - case p := <-wgtun.Inbound: - - // TODO(raggi): move to a bytes.Equal based test later, once - // tuntest.Ping produces correct checksums! - - var pkt packet.Parsed - pkt.Decode(p) - if pkt.ICMP4Header().Type != packet.ICMP4EchoRequest { - t.Fatalf("unexpected packet: %x", p) - } - if pkt.Src.Addr() != masqip.Addr() { - t.Fatalf("bad source IP, got %s, want %s", pkt.Src.Addr(), masqip.Addr()) - } - if pkt.Dst.Addr() != wgaip.Addr() { - t.Fatalf("bad source IP, got %s, want %s", pkt.Src.Addr(), masqip.Addr()) - } - case <-time.After(time.Second): - t.Fatal("no packet after 1s") - } -} - -// applyNetworkMap is a test helper that sets the network map and -// configures WG. -func applyNetworkMap(t *testing.T, m *magicStack, nm *netmap.NetworkMap) { - t.Helper() - m.conn.SetNetworkMap(nm) - // Make sure we can't use v6 to avoid test failures. - m.conn.noV6.Store(true) - - // Turn the network map into a wireguard config (for the tailscale internal wireguard device). - cfg, err := nmcfg.WGCfg(nm, t.Logf, netmap.AllowSubnetRoutes, "") - if err != nil { - t.Fatal(err) - } - // Apply the wireguard config to the tailscale internal wireguard device. - if err := m.Reconfig(cfg); err != nil { - t.Fatal(err) - } -} - -func TestIsWireGuardOnlyPickEndpointByPing(t *testing.T) { - t.Skip("This test is flaky; see https://github.com/tailscale/tailscale/issues/8037") - - clock := &tstest.Clock{} - derpMap, cleanup := runDERPAndStun(t, t.Logf, localhostListener{}, netaddr.IPv4(127, 0, 0, 1)) - defer cleanup() - - // Create a TS client. - tskey := key.NewNode() - tsaip := netip.MustParsePrefix("100.111.222.111/32") - - // Create a WireGuard only client. - wgkey := key.NewNode() - wgaip := netip.MustParsePrefix("100.222.111.222/32") - - uapi := fmt.Sprintf("private_key=%s\npublic_key=%s\nallowed_ip=%s\n\n", - wgkey.UntypedHexString(), tskey.Public().UntypedHexString(), tsaip.String()) - - wgdev, wgtun, port := newWireguard(t, uapi, []netip.Prefix{wgaip}) - defer wgdev.Close() - wgEp := netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), port) - wgEp2 := netip.AddrPortFrom(netip.MustParseAddr("127.0.0.2"), port) - - m := newMagicStackWithKey(t, t.Logf, localhostListener{}, derpMap, tskey) - defer m.Close() - - pr := newPingResponder(t) - // Get a destination address which includes a port, so that UDP packets flow - // to the correct place, the mockPinger will use this to direct port-less - // pings to this place. - pingDest := pr.LocalAddr() - - // Create and start the pinger that is used for the - // wireguard only endpoint pings - p, closeP := mockPinger(t, clock, pingDest) - defer closeP() - m.conn.wgPinger.Set(p) - - // Create an IPv6 endpoint which should not receive any traffic. - v6, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.ParseIP("::"), Port: 0}) - if err != nil { - t.Fatal(err) - } - badEpRecv := make(chan []byte) - go func() { - defer v6.Close() - for { - b := make([]byte, 1500) - n, _, err := v6.ReadFrom(b) - if err != nil { - close(badEpRecv) - return - } - badEpRecv <- b[:n] - } - }() - wgEpV6 := netip.MustParseAddrPort(v6.LocalAddr().String()) - - nm := &netmap.NetworkMap{ - Name: "ts", - PrivateKey: m.privateKey, - NodeKey: m.privateKey.Public(), - SelfNode: (&tailcfg.Node{ - Addresses: []netip.Prefix{tsaip}, - }).View(), - Peers: nodeViews([]*tailcfg.Node{ - { - Key: wgkey.Public(), - Endpoints: []netip.AddrPort{wgEp, wgEp2, wgEpV6}, - IsWireGuardOnly: true, - Addresses: []netip.Prefix{wgaip}, - AllowedIPs: []netip.Prefix{wgaip}, - }, - }), - } - - applyNetworkMap(t, m, nm) - - buf := tuntest.Ping(wgaip.Addr(), tsaip.Addr()) - m.tun.Outbound <- buf - - select { - case p := <-wgtun.Inbound: - if !bytes.Equal(p, buf) { - t.Errorf("got unexpected packet: %x", p) - } - case <-badEpRecv: - t.Fatal("got packet on bad endpoint") - case <-time.After(5 * time.Second): - t.Fatal("no packet after 1s") - } - - pi, ok := m.conn.peerMap.byNodeKey[wgkey.Public()] - if !ok { - t.Fatal("wgkey doesn't exist in peer map") - } - - // Check that we got a valid address set on the first send - this - // will be randomly selected, but because we have noV6 set to true, - // it will be the IPv4 address. - if !pi.ep.bestAddr.Addr().IsValid() { - t.Fatal("bestaddr was nil") - } - - if pi.ep.trustBestAddrUntil.Before(mono.Now().Add(14 * time.Second)) { - t.Errorf("trustBestAddrUntil time wasn't set to 15 seconds in the future: got %v", pi.ep.trustBestAddrUntil) - } - - for ipp, state := range pi.ep.endpointState { - if ipp == wgEp { - if len(state.recentPongs) != 1 { - t.Errorf("IPv4 address did not have a recentPong entry: got %v, want %v", len(state.recentPongs), 1) - } - // Set the latency extremely low so we choose this endpoint during the next - // addrForSendLocked call. - state.recentPongs[state.recentPong].latency = time.Nanosecond - } - - if ipp == wgEp2 { - if len(state.recentPongs) != 1 { - t.Errorf("IPv4 address did not have a recentPong entry: got %v, want %v", len(state.recentPongs), 1) - } - // Set the latency extremely high so we dont choose endpoint during the next - // addrForSendLocked call. - state.recentPongs[state.recentPong].latency = time.Second - } - - if ipp == wgEpV6 && len(state.recentPongs) != 0 { - t.Fatal("IPv6 should not have recentPong: IPv6 is not useable") - } - } - - // Set trustBestAddrUnitl to now, so addrForSendLocked goes through the - // latency selection flow. - pi.ep.trustBestAddrUntil = mono.Now().Add(-time.Second) - - buf = tuntest.Ping(wgaip.Addr(), tsaip.Addr()) - m.tun.Outbound <- buf - - select { - case p := <-wgtun.Inbound: - if !bytes.Equal(p, buf) { - t.Errorf("got unexpected packet: %x", p) - } - case <-badEpRecv: - t.Fatal("got packet on bad endpoint") - case <-time.After(5 * time.Second): - t.Fatal("no packet after 1s") - } - - // Check that we have responded to a WireGuard only ping twice. - if pr.responseCount != 2 { - t.Fatal("pingresponder response count was not 2", pr.responseCount) - } - - pi, ok = m.conn.peerMap.byNodeKey[wgkey.Public()] - if !ok { - t.Fatal("wgkey doesn't exist in peer map") - } - - if !pi.ep.bestAddr.Addr().IsValid() { - t.Error("no bestAddr address was set") - } - - if pi.ep.bestAddr.Addr() != wgEp.Addr() { - t.Errorf("bestAddr was not set to the expected IPv4 address: got %v, want %v", pi.ep.bestAddr.Addr().String(), wgEp.Addr()) - } - - if pi.ep.trustBestAddrUntil.IsZero() { - t.Fatal("trustBestAddrUntil was not set") - } - - if pi.ep.trustBestAddrUntil.Before(mono.Now().Add(55 * time.Minute)) { - // Set to 55 minutes incase of sloooow tests. - t.Errorf("trustBestAddrUntil time wasn't set to an hour in the future: got %v", pi.ep.trustBestAddrUntil) - } -} - -// udpingPacketConn will convert potentially ICMP destination addrs to UDP -// destination addrs in WriteTo so that a test that is intending to send ICMP -// traffic will instead send UDP traffic, without the higher level Pinger being -// aware of this difference. -type udpingPacketConn struct { - net.PacketConn - // destPort will be configured by the test to be the peer expected to respond to a ping. - destPort uint16 -} - -func (u *udpingPacketConn) WriteTo(body []byte, dest net.Addr) (int, error) { - switch d := dest.(type) { - case *net.IPAddr: - udpAddr := &net.UDPAddr{ - IP: d.IP, - Port: int(u.destPort), - Zone: d.Zone, - } - return u.PacketConn.WriteTo(body, udpAddr) - } - return 0, fmt.Errorf("unimplemented udpingPacketConn for %T", dest) -} - -type mockListenPacketer struct { - conn4 net.PacketConn - conn6 net.PacketConn -} - -func (mlp *mockListenPacketer) ListenPacket(ctx context.Context, typ string, addr string) (net.PacketConn, error) { - switch typ { - case "ip4:icmp": - return mlp.conn4, nil - case "ip6:icmp": - return mlp.conn6, nil - } - return nil, fmt.Errorf("unimplemented ListenPacketForTesting for %s", typ) -} - -func mockPinger(t *testing.T, clock *tstest.Clock, dest net.Addr) (*ping.Pinger, func()) { - ctx := context.Background() - - dIPP := netip.MustParseAddrPort(dest.String()) - // In tests, we use UDP so that we can test without being root; this - // doesn't matter because we mock out the ICMP reply below to be a real - // ICMP echo reply packet. - conn4, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("net.ListenPacket: %v", err) - } - conn6, err := net.ListenPacket("udp6", "[::]:0") - if err != nil { - t.Fatalf("net.ListenPacket: %v", err) - } - - conn4 = &udpingPacketConn{ - PacketConn: conn4, - destPort: dIPP.Port(), - } - - conn6 = &udpingPacketConn{ - PacketConn: conn6, - destPort: dIPP.Port(), - } - - p := ping.New(ctx, t.Logf, &mockListenPacketer{conn4: conn4, conn6: conn6}) - - done := func() { - if err := p.Close(); err != nil { - t.Errorf("error on close: %v", err) - } - } - - return p, done -} - -type pingResponder struct { - net.PacketConn - running atomic.Bool - responseCount int -} - -func (p *pingResponder) start() { - buf := make([]byte, 1500) - for p.running.Load() { - n, addr, err := p.PacketConn.ReadFrom(buf) - if err != nil { - return - } - - m, err := icmp.ParseMessage(1, buf[:n]) - if err != nil { - panic("got a non-ICMP message:" + fmt.Sprintf("%x", m)) - } - - r := icmp.Message{ - Type: ipv4.ICMPTypeEchoReply, - Code: m.Code, - Body: m.Body, - } - - b, err := r.Marshal(nil) - if err != nil { - panic(err) - } - - if _, err := p.PacketConn.WriteTo(b, addr); err != nil { - panic(err) - } - p.responseCount++ - } -} - -func (p *pingResponder) stop() { - p.running.Store(false) - p.Close() -} - -func newPingResponder(t *testing.T) *pingResponder { - t.Helper() - // global binds should be both IPv4 and IPv6 (if our test platforms don't, - // we might need to bind two sockets instead) - conn, err := net.ListenPacket("udp", ":") - if err != nil { - t.Fatal(err) - } - pr := &pingResponder{PacketConn: conn} - pr.running.Store(true) - go pr.start() - t.Cleanup(pr.stop) - return pr -} - -func TestAddrForSendLockedForWireGuardOnly(t *testing.T) { - testTime := mono.Now() - secondPingTime := testTime.Add(10 * time.Second) - - type endpointDetails struct { - addrPort netip.AddrPort - latency time.Duration - } - - wgTests := []struct { - name string - sendInitialPing bool - validAddr bool - sendFollowUpPing bool - pingTime mono.Time - ep []endpointDetails - want netip.AddrPort - }{ - { - name: "no endpoints", - sendInitialPing: false, - validAddr: false, - sendFollowUpPing: false, - pingTime: testTime, - ep: []endpointDetails{}, - want: netip.AddrPort{}, - }, - { - name: "singular endpoint does not request ping", - sendInitialPing: false, - validAddr: true, - sendFollowUpPing: false, - pingTime: testTime, - ep: []endpointDetails{ - { - addrPort: netip.MustParseAddrPort("1.1.1.1:111"), - latency: 100 * time.Millisecond, - }, - }, - want: netip.MustParseAddrPort("1.1.1.1:111"), - }, - { - name: "ping sent within wireguardPingInterval should not request ping", - sendInitialPing: true, - validAddr: true, - sendFollowUpPing: false, - pingTime: testTime.Add(7 * time.Second), - ep: []endpointDetails{ - { - addrPort: netip.MustParseAddrPort("1.1.1.1:111"), - latency: 100 * time.Millisecond, - }, - { - addrPort: netip.MustParseAddrPort("[2345:0425:2CA1:0000:0000:0567:5673:23b5]:222"), - latency: 2000 * time.Millisecond, - }, - }, - want: netip.MustParseAddrPort("1.1.1.1:111"), - }, - { - name: "ping sent outside of wireguardPingInterval should request ping", - sendInitialPing: true, - validAddr: true, - sendFollowUpPing: true, - pingTime: testTime.Add(3 * time.Second), - ep: []endpointDetails{ - { - addrPort: netip.MustParseAddrPort("1.1.1.1:111"), - latency: 100 * time.Millisecond, - }, - { - addrPort: netip.MustParseAddrPort("[2345:0425:2CA1:0000:0000:0567:5673:23b5]:222"), - latency: 150 * time.Millisecond, - }, - }, - want: netip.MustParseAddrPort("1.1.1.1:111"), - }, - { - name: "choose lowest latency for useable IPv4 and IPv6", - sendInitialPing: true, - validAddr: true, - sendFollowUpPing: false, - pingTime: secondPingTime, - ep: []endpointDetails{ - { - addrPort: netip.MustParseAddrPort("1.1.1.1:111"), - latency: 100 * time.Millisecond, - }, - { - addrPort: netip.MustParseAddrPort("[2345:0425:2CA1:0000:0000:0567:5673:23b5]:222"), - latency: 10 * time.Millisecond, - }, - }, - want: netip.MustParseAddrPort("[2345:0425:2CA1:0000:0000:0567:5673:23b5]:222"), - }, - { - name: "choose IPv6 address when latency is the same for v4 and v6", - sendInitialPing: true, - validAddr: true, - sendFollowUpPing: false, - pingTime: secondPingTime, - ep: []endpointDetails{ - { - addrPort: netip.MustParseAddrPort("1.1.1.1:111"), - latency: 100 * time.Millisecond, - }, - { - addrPort: netip.MustParseAddrPort("[1::1]:567"), - latency: 100 * time.Millisecond, - }, - }, - want: netip.MustParseAddrPort("[1::1]:567"), - }, - } - - for _, test := range wgTests { - t.Run(test.name, func(t *testing.T) { - endpoint := &endpoint{ - isWireguardOnly: true, - endpointState: map[netip.AddrPort]*endpointState{}, - c: &Conn{ - logf: t.Logf, - noV4: atomic.Bool{}, - noV6: atomic.Bool{}, - }, - } - - for _, epd := range test.ep { - endpoint.endpointState[epd.addrPort] = &endpointState{} - } - udpAddr, _, shouldPing := endpoint.addrForSendLocked(testTime) - if udpAddr.IsValid() != test.validAddr { - t.Errorf("udpAddr validity is incorrect; got %v, want %v", udpAddr.IsValid(), test.validAddr) - } - if shouldPing != test.sendInitialPing { - t.Errorf("addrForSendLocked did not indiciate correct ping state; got %v, want %v", shouldPing, test.sendInitialPing) - } - - // Update the endpointState to simulate a ping having been - // sent and a pong received. - for _, epd := range test.ep { - state, ok := endpoint.endpointState[epd.addrPort] - if !ok { - t.Errorf("addr does not exist in endpoint state map") - } - state.lastPing = test.pingTime - - latency, ok := state.latencyLocked() - if ok { - t.Errorf("latency was set for %v: %v", epd.addrPort, latency) - } - state.recentPongs = append(state.recentPongs, pongReply{ - latency: epd.latency, - }) - state.recentPong = 0 - } - - udpAddr, _, shouldPing = endpoint.addrForSendLocked(secondPingTime) - if udpAddr != test.want { - t.Errorf("udpAddr returned is not expected: got %v, want %v", udpAddr, test.want) - } - if shouldPing != test.sendFollowUpPing { - t.Errorf("addrForSendLocked did not indiciate correct ping state; got %v, want %v", shouldPing, test.sendFollowUpPing) - } - if endpoint.bestAddr.AddrPort != test.want { - t.Errorf("bestAddr.AddrPort is not as expected: got %v, want %v", endpoint.bestAddr.AddrPort, test.want) - } - }) - } -} - -func TestAddrForPingSizeLocked(t *testing.T) { - testTime := mono.Now() - - validUdpAddr := netip.MustParseAddrPort("1.1.1.1:111") - validDerpAddr := netip.MustParseAddrPort("2.2.2.2:222") - - pingTests := []struct { - desc string - size int // size of ping payload - mtu tstun.WireMTU // The MTU of the path to bestAddr, if any - bestAddr bool // If the endpoint should have a valid bestAddr - bestAddrTrusted bool // If the bestAddr has not yet expired - wantUDP bool // Non-zero UDP addr means send to UDP; zero means start discovery - wantDERP bool // Non-zero DERP addr means send to DERP - }{ - { - desc: "ping_size_0_and_invalid_UDP_addr_should_start_discovery_and_send_to_DERP", - size: 0, - bestAddr: false, - bestAddrTrusted: false, - wantUDP: false, - wantDERP: true, - }, - { - desc: "ping_size_0_and_valid_trusted_UDP_addr_should_send_to_UDP_and_not_send_to_DERP", - size: 0, - bestAddr: true, - bestAddrTrusted: true, - wantUDP: true, - wantDERP: false, - }, - { - desc: "ping_size_0_and_valid_but_expired_UDP_addr_should_send_to_both_UDP_and_DERP", - size: 0, - bestAddr: true, - bestAddrTrusted: false, - wantUDP: true, - wantDERP: true, - }, - { - desc: "ping_size_too_big_for_trusted_UDP_addr_should_start_discovery_and_send_to_DERP", - size: pktLenToPingSize(1501, validUdpAddr.Addr().Is6()), - mtu: 1500, - bestAddr: true, - bestAddrTrusted: true, - wantUDP: false, - wantDERP: true, - }, - { - desc: "ping_size_too_big_for_untrusted_UDP_addr_should_start_discovery_and_send_to_DERP", - size: pktLenToPingSize(1501, validUdpAddr.Addr().Is6()), - mtu: 1500, - bestAddr: true, - bestAddrTrusted: false, - wantUDP: false, - wantDERP: true, - }, - { - desc: "ping_size_small_enough_for_trusted_UDP_addr_should_send_to_UDP_and_not_DERP", - size: pktLenToPingSize(1500, validUdpAddr.Addr().Is6()), - mtu: 1500, - bestAddr: true, - bestAddrTrusted: true, - wantUDP: true, - wantDERP: false, - }, - { - desc: "ping_size_small_enough_for_untrusted_UDP_addr_should_send_to_UDP_and_DERP", - size: pktLenToPingSize(1500, validUdpAddr.Addr().Is6()), - mtu: 1500, - bestAddr: true, - bestAddrTrusted: false, - wantUDP: true, - wantDERP: true, - }, - } - - for _, test := range pingTests { - t.Run(test.desc, func(t *testing.T) { - bestAddr := addrQuality{wireMTU: test.mtu} - if test.bestAddr { - bestAddr.AddrPort = validUdpAddr - } - ep := &endpoint{ - derpAddr: validDerpAddr, - bestAddr: bestAddr, - } - if test.bestAddrTrusted { - ep.trustBestAddrUntil = testTime.Add(1 * time.Second) - } - - udpAddr, derpAddr := ep.addrForPingSizeLocked(testTime, test.size) - - if test.wantUDP && !udpAddr.IsValid() { - t.Errorf("%s: udpAddr returned is not valid, won't be sent to UDP address", test.desc) - } - if !test.wantUDP && udpAddr.IsValid() { - t.Errorf("%s: udpAddr returned is valid, discovery will not start", test.desc) - } - if test.wantDERP && !derpAddr.IsValid() { - t.Errorf("%s: derpAddr returned is not valid, won't be sent to DERP", test.desc) - } - if !test.wantDERP && derpAddr.IsValid() { - t.Errorf("%s: derpAddr returned is valid, will be sent to DERP", test.desc) - } - }) - } -} - -func TestMaybeSetNearestDERP(t *testing.T) { - derpMap := &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - RegionID: 1, - RegionCode: "test", - Nodes: []*tailcfg.DERPNode{ - { - Name: "t1", - RegionID: 1, - HostName: "test-node.unused", - IPv4: "127.0.0.1", - IPv6: "none", - }, - }, - }, - 21: { - RegionID: 21, - RegionCode: "tor", - Nodes: []*tailcfg.DERPNode{ - { - Name: "21b", - RegionID: 21, - HostName: "tor.test-node.unused", - IPv4: "127.0.0.1", - IPv6: "none", - }, - }, - }, - 31: { - RegionID: 31, - RegionCode: "fallback", - Nodes: []*tailcfg.DERPNode{ - { - Name: "31b", - RegionID: 31, - HostName: "fallback.test-node.unused", - IPv4: "127.0.0.1", - IPv6: "none", - }, - }, - }, - }, - } - - // Ensure that our fallback code always picks a deterministic value. - tstest.Replace(t, &pickDERPFallbackForTests, func() int { return 31 }) - - // Actually test this code path. - tstest.Replace(t, &checkControlHealthDuringNearestDERPInTests, true) - - testCases := []struct { - name string - old int - reportDERP int - connectedToControl bool - want int - }{ - { - name: "connected_with_report_derp", - old: 1, - reportDERP: 21, - connectedToControl: true, - want: 21, - }, - { - name: "not_connected_with_report_derp", - old: 1, - reportDERP: 21, - connectedToControl: false, - want: 1, // no change - }, - { - name: "not_connected_with_report_derp_and_no_current", - old: 0, // no current DERP - reportDERP: 21, // have new DERP - connectedToControl: false, // not connected... - want: 21, // ... but want to change to new DERP - }, - { - name: "not_connected_with_fallback_and_no_current", - old: 0, // no current DERP - reportDERP: 0, // no new DERP - connectedToControl: false, // not connected... - want: 31, // ... but we fallback to deterministic value - }, - { - name: "connected_no_derp", - old: 1, - reportDERP: 0, - connectedToControl: true, - want: 1, // no change - }, - { - name: "connected_no_derp_fallback", - old: 0, - reportDERP: 0, - connectedToControl: true, - want: 31, // deterministic fallback - }, - } - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - ht := new(health.Tracker) - c := newConn(t.Logf) - c.myDerp = tt.old - c.derpMap = derpMap - c.health = ht - - report := &netcheck.Report{PreferredDERP: tt.reportDERP} - - oldConnected := ht.GetInPollNetMap() - if tt.connectedToControl != oldConnected { - if tt.connectedToControl { - ht.GotStreamedMapResponse() - t.Cleanup(ht.SetOutOfPollNetMap) - } else { - ht.SetOutOfPollNetMap() - t.Cleanup(ht.GotStreamedMapResponse) - } - } - - got := c.maybeSetNearestDERP(report) - if got != tt.want { - t.Errorf("got new DERP region %d, want %d", got, tt.want) - } - }) - } -} - -func TestMaybeRebindOnError(t *testing.T) { - tstest.PanicOnLog() - tstest.ResourceCheck(t) - - err := fmt.Errorf("outer err: %w", syscall.EPERM) - - t.Run("darwin-rebind", func(t *testing.T) { - conn := newTestConn(t) - defer conn.Close() - rebound := conn.maybeRebindOnError("darwin", err) - if !rebound { - t.Errorf("darwin should rebind on syscall.EPERM") - } - }) - - t.Run("linux-not-rebind", func(t *testing.T) { - conn := newTestConn(t) - defer conn.Close() - rebound := conn.maybeRebindOnError("linux", err) - if rebound { - t.Errorf("linux should not rebind on syscall.EPERM") - } - }) - - t.Run("no-frequent-rebind", func(t *testing.T) { - conn := newTestConn(t) - defer conn.Close() - conn.lastEPERMRebind.Store(time.Now().Add(-1 * time.Second)) - rebound := conn.maybeRebindOnError("darwin", err) - if rebound { - t.Errorf("darwin should not rebind on syscall.EPERM within 5 seconds of last") - } - }) -} - -func TestNetworkDownSendErrors(t *testing.T) { - netMon := must.Get(netmon.New(t.Logf)) - defer netMon.Close() - - reg := new(usermetric.Registry) - conn := must.Get(NewConn(Options{ - DisablePortMapper: true, - Logf: t.Logf, - NetMon: netMon, - Metrics: reg, - })) - defer conn.Close() - - conn.SetNetworkUp(false) - if err := conn.Send([][]byte{{00}}, &lazyEndpoint{}); err == nil { - t.Error("expected error, got nil") - } - resp := httptest.NewRecorder() - reg.Handler(resp, new(http.Request)) - if !strings.Contains(resp.Body.String(), `tailscaled_outbound_dropped_packets_total{reason="error"} 1`) { - t.Errorf("expected NetworkDown to increment packet dropped metric; got %q", resp.Body.String()) - } -} diff --git a/wgengine/magicsock/magicsock_unix_test.go b/wgengine/magicsock/magicsock_unix_test.go deleted file mode 100644 index b0700a8ebe870..0000000000000 --- a/wgengine/magicsock/magicsock_unix_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build unix - -package magicsock - -import ( - "net" - "syscall" - "testing" - - "tailscale.com/types/nettype" -) - -func TestTrySetSocketBuffer(t *testing.T) { - c, err := net.ListenPacket("udp", ":0") - if err != nil { - t.Fatal(err) - } - defer c.Close() - - rc, err := c.(*net.UDPConn).SyscallConn() - if err != nil { - t.Fatal(err) - } - - getBufs := func() (int, int) { - var rcv, snd int - rc.Control(func(fd uintptr) { - rcv, err = syscall.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_RCVBUF) - if err != nil { - t.Errorf("getsockopt(SO_RCVBUF): %v", err) - } - snd, err = syscall.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_SNDBUF) - if err != nil { - t.Errorf("getsockopt(SO_SNDBUF): %v", err) - } - }) - return rcv, snd - } - - curRcv, curSnd := getBufs() - - trySetSocketBuffer(c.(nettype.PacketConn), t.Logf) - - newRcv, newSnd := getBufs() - - if curRcv > newRcv { - t.Errorf("SO_RCVBUF decreased: %v -> %v", curRcv, newRcv) - } - if curSnd > newSnd { - t.Errorf("SO_SNDBUF decreased: %v -> %v", curSnd, newSnd) - } - - // On many systems we may not increase the value, particularly running as a - // regular user, so log the information for manual verification. - t.Logf("SO_RCVBUF: %v -> %v", curRcv, newRcv) - t.Logf("SO_SNDBUF: %v -> %v", curRcv, newRcv) -} diff --git a/wgengine/magicsock/magicsock_windows.go b/wgengine/magicsock/magicsock_windows.go index fe2a80e0ba951..96375f580fe05 100644 --- a/wgengine/magicsock/magicsock_windows.go +++ b/wgengine/magicsock/magicsock_windows.go @@ -9,9 +9,9 @@ import ( "net" "unsafe" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/nettype" "golang.org/x/sys/windows" - "tailscale.com/types/logger" - "tailscale.com/types/nettype" ) func trySetUDPSocketOptions(pconn nettype.PacketConn, logf logger.Logf) { diff --git a/wgengine/magicsock/peermap.go b/wgengine/magicsock/peermap.go index e1c7db1f6c632..52bbb2a64170b 100644 --- a/wgengine/magicsock/peermap.go +++ b/wgengine/magicsock/peermap.go @@ -6,9 +6,9 @@ package magicsock import ( "net/netip" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/util/set" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/util/set" ) // peerInfo is all the information magicsock tracks about a particular diff --git a/wgengine/magicsock/peermtu.go b/wgengine/magicsock/peermtu.go index b675bf409cfa4..e09549854aca4 100644 --- a/wgengine/magicsock/peermtu.go +++ b/wgengine/magicsock/peermtu.go @@ -8,9 +8,9 @@ package magicsock import ( "errors" + "github.com/sagernet/tailscale/disco" + "github.com/sagernet/tailscale/net/tstun" "golang.org/x/sys/unix" - "tailscale.com/disco" - "tailscale.com/net/tstun" ) // Peer path MTU routines shared by platforms that implement it. diff --git a/wgengine/magicsock/peermtu_stubs.go b/wgengine/magicsock/peermtu_stubs.go index e4f8038a42f21..f0ffe0d7f8d65 100644 --- a/wgengine/magicsock/peermtu_stubs.go +++ b/wgengine/magicsock/peermtu_stubs.go @@ -5,7 +5,7 @@ package magicsock -import "tailscale.com/disco" +import "github.com/sagernet/tailscale/disco" func (c *Conn) DontFragSetting() (bool, error) { return false, nil diff --git a/wgengine/magicsock/rebinding_conn.go b/wgengine/magicsock/rebinding_conn.go index c27abbadc9ced..e40b75dfaead7 100644 --- a/wgengine/magicsock/rebinding_conn.go +++ b/wgengine/magicsock/rebinding_conn.go @@ -11,9 +11,9 @@ import ( "sync/atomic" "syscall" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/types/nettype" "golang.org/x/net/ipv6" - "tailscale.com/net/netaddr" - "tailscale.com/types/nettype" ) // RebindingUDPConn is a UDP socket that can be re-bound. diff --git a/wgengine/mem_ios.go b/wgengine/mem_ios.go index cc266ea3aadc8..cf867cfd4c734 100644 --- a/wgengine/mem_ios.go +++ b/wgengine/mem_ios.go @@ -4,7 +4,7 @@ package wgengine import ( - "github.com/tailscale/wireguard-go/device" + "github.com/sagernet/wireguard-go/device" ) // iOS has a very restrictive memory limit on network extensions. diff --git a/wgengine/netlog/logger.go b/wgengine/netlog/logger.go index 3a696b246df54..5060691694f7e 100644 --- a/wgengine/netlog/logger.go +++ b/wgengine/netlog/logger.go @@ -16,18 +16,18 @@ import ( "sync" "time" - "tailscale.com/health" - "tailscale.com/logpolicy" - "tailscale.com/logtail" - "tailscale.com/net/connstats" - "tailscale.com/net/netmon" - "tailscale.com/net/sockstats" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/logid" - "tailscale.com/types/netlogtype" - "tailscale.com/util/multierr" - "tailscale.com/wgengine/router" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/logpolicy" + "github.com/sagernet/tailscale/logtail" + "github.com/sagernet/tailscale/net/connstats" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/types/netlogtype" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/wgengine/router" ) // pollPeriod specifies how often to poll for network traffic. diff --git a/wgengine/netstack/gro/gro.go b/wgengine/netstack/gro/gro.go index b268534eb46c8..6e04202355aa6 100644 --- a/wgengine/netstack/gro/gro.go +++ b/wgengine/netstack/gro/gro.go @@ -6,14 +6,15 @@ package gro import ( "bytes" - "github.com/tailscale/wireguard-go/tun" - "gvisor.dev/gvisor/pkg/buffer" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/header" - "gvisor.dev/gvisor/pkg/tcpip/header/parse" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "tailscale.com/net/packet" - "tailscale.com/types/ipproto" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/checksum" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/header/parse" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/types/ipproto" ) // RXChecksumOffload validates IPv4, TCP, and UDP header checksums in p, @@ -39,7 +40,7 @@ func RXChecksumOffload(p *packet.Parsed) *stack.PacketBuffer { if csumStart < header.IPv4MinimumSize || csumStart > header.IPv4MaximumHeaderSize || len(buf) < csumStart { return nil } - if ^tun.Checksum(buf[:csumStart], 0) != 0 { + if ^checksum.Checksum(buf[:csumStart], 0) != 0 { return nil } pn = header.IPv4ProtocolNumber @@ -79,12 +80,12 @@ func RXChecksumOffload(p *packet.Parsed) *stack.PacketBuffer { if p.IPProto == ipproto.TCP || p.IPProto == ipproto.UDP { lenForPseudo := len(buf) - csumStart - csum := tun.PseudoHeaderChecksum( - uint8(p.IPProto), - p.Src.Addr().AsSlice(), - p.Dst.Addr().AsSlice(), + csum := header.PseudoHeaderChecksum( + tcpip.TransportProtocolNumber(p.IPProto), + tcpip.AddrFromSlice(p.Src.Addr().AsSlice()), + tcpip.AddrFromSlice(p.Dst.Addr().AsSlice()), uint16(lenForPseudo)) - csum = tun.Checksum(buf[csumStart:], csum) + csum = checksum.Checksum(buf[csumStart:], csum) if ^csum != 0 { return nil } diff --git a/wgengine/netstack/gro/gro_default.go b/wgengine/netstack/gro/gro_default.go index f92ee15ecac15..c844302e00956 100644 --- a/wgengine/netstack/gro/gro_default.go +++ b/wgengine/netstack/gro/gro_default.go @@ -8,9 +8,9 @@ package gro import ( "sync" - "gvisor.dev/gvisor/pkg/tcpip/stack" - nsgro "gvisor.dev/gvisor/pkg/tcpip/stack/gro" - "tailscale.com/net/packet" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + nsgro "github.com/sagernet/gvisor/pkg/tcpip/stack/gro" + "github.com/sagernet/tailscale/net/packet" ) var ( diff --git a/wgengine/netstack/gro/gro_ios.go b/wgengine/netstack/gro/gro_ios.go index 627b42d7e5cfd..7cedb71d4c87b 100644 --- a/wgengine/netstack/gro/gro_ios.go +++ b/wgengine/netstack/gro/gro_ios.go @@ -6,8 +6,8 @@ package gro import ( - "gvisor.dev/gvisor/pkg/tcpip/stack" - "tailscale.com/net/packet" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/tailscale/net/packet" ) type GRO struct{} diff --git a/wgengine/netstack/gro/gro_test.go b/wgengine/netstack/gro/gro_test.go deleted file mode 100644 index 1eb200a05134c..0000000000000 --- a/wgengine/netstack/gro/gro_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package gro - -import ( - "bytes" - "net/netip" - "testing" - - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/header" - "tailscale.com/net/packet" -) - -func Test_RXChecksumOffload(t *testing.T) { - payloadLen := 100 - - tcpFields := &header.TCPFields{ - SrcPort: 1, - DstPort: 1, - SeqNum: 1, - AckNum: 1, - DataOffset: 20, - Flags: header.TCPFlagAck | header.TCPFlagPsh, - WindowSize: 3000, - } - tcp4 := make([]byte, 20+20+payloadLen) - ipv4H := header.IPv4(tcp4) - ipv4H.Encode(&header.IPv4Fields{ - SrcAddr: tcpip.AddrFromSlice(netip.MustParseAddr("192.0.2.1").AsSlice()), - DstAddr: tcpip.AddrFromSlice(netip.MustParseAddr("192.0.2.2").AsSlice()), - Protocol: uint8(header.TCPProtocolNumber), - TTL: 64, - TotalLength: uint16(len(tcp4)), - }) - ipv4H.SetChecksum(^ipv4H.CalculateChecksum()) - tcpH := header.TCP(tcp4[20:]) - tcpH.Encode(tcpFields) - pseudoCsum := header.PseudoHeaderChecksum(header.TCPProtocolNumber, ipv4H.SourceAddress(), ipv4H.DestinationAddress(), uint16(20+payloadLen)) - tcpH.SetChecksum(^tcpH.CalculateChecksum(pseudoCsum)) - - tcp6ExtHeader := make([]byte, 40+8+20+payloadLen) - ipv6H := header.IPv6(tcp6ExtHeader) - ipv6H.Encode(&header.IPv6Fields{ - SrcAddr: tcpip.AddrFromSlice(netip.MustParseAddr("2001:db8::1").AsSlice()), - DstAddr: tcpip.AddrFromSlice(netip.MustParseAddr("2001:db8::2").AsSlice()), - TransportProtocol: 60, // really next header; destination options ext header - HopLimit: 64, - PayloadLength: uint16(8 + 20 + payloadLen), - }) - tcp6ExtHeader[40] = uint8(header.TCPProtocolNumber) // next header - tcp6ExtHeader[41] = 0 // length of ext header in 8-octet units, exclusive of first 8 octets. - // 42-47 options and padding - tcpH = header.TCP(tcp6ExtHeader[48:]) - tcpH.Encode(tcpFields) - pseudoCsum = header.PseudoHeaderChecksum(header.TCPProtocolNumber, ipv6H.SourceAddress(), ipv6H.DestinationAddress(), uint16(20+payloadLen)) - tcpH.SetChecksum(^tcpH.CalculateChecksum(pseudoCsum)) - - tcp4InvalidCsum := make([]byte, len(tcp4)) - copy(tcp4InvalidCsum, tcp4) - at := 20 + 16 - tcp4InvalidCsum[at] = ^tcp4InvalidCsum[at] - - tcp6ExtHeaderInvalidCsum := make([]byte, len(tcp6ExtHeader)) - copy(tcp6ExtHeaderInvalidCsum, tcp6ExtHeader) - at = 40 + 8 + 16 - tcp6ExtHeaderInvalidCsum[at] = ^tcp6ExtHeaderInvalidCsum[at] - - tests := []struct { - name string - input []byte - wantPB bool - }{ - { - "tcp4 packet valid csum", - tcp4, - true, - }, - { - "tcp6 with ext header valid csum", - tcp6ExtHeader, - true, - }, - { - "tcp4 packet invalid csum", - tcp4InvalidCsum, - false, - }, - { - "tcp6 with ext header invalid csum", - tcp6ExtHeaderInvalidCsum, - false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := &packet.Parsed{} - p.Decode(tt.input) - got := RXChecksumOffload(p) - if tt.wantPB != (got != nil) { - t.Fatalf("wantPB = %v != (got != nil): %v", tt.wantPB, got != nil) - } - if tt.wantPB { - gotBuf := got.ToBuffer() - if !bytes.Equal(tt.input, gotBuf.Flatten()) { - t.Fatal("output packet unequal to input") - } - } - }) - } -} diff --git a/wgengine/netstack/link_endpoint.go b/wgengine/netstack/link_endpoint.go index 485d829a3b8e5..43d5402d1f196 100644 --- a/wgengine/netstack/link_endpoint.go +++ b/wgengine/netstack/link_endpoint.go @@ -7,12 +7,12 @@ import ( "context" "sync" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/header" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "tailscale.com/net/packet" - "tailscale.com/types/ipproto" - "tailscale.com/wgengine/netstack/gro" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/wgengine/netstack/gro" ) type queue struct { diff --git a/wgengine/netstack/netstack.go b/wgengine/netstack/netstack.go index 20eac06e6b8fd..4ca8bec54c403 100644 --- a/wgengine/netstack/netstack.go +++ b/wgengine/netstack/netstack.go @@ -20,41 +20,41 @@ import ( "sync/atomic" "time" - "github.com/tailscale/wireguard-go/conn" - "gvisor.dev/gvisor/pkg/refs" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" - "gvisor.dev/gvisor/pkg/tcpip/header" - "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" - "gvisor.dev/gvisor/pkg/tcpip/network/ipv6" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "gvisor.dev/gvisor/pkg/tcpip/transport/icmp" - "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" - "gvisor.dev/gvisor/pkg/tcpip/transport/udp" - "gvisor.dev/gvisor/pkg/waiter" - "tailscale.com/envknob" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/metrics" - "tailscale.com/net/dns" - "tailscale.com/net/ipset" - "tailscale.com/net/netaddr" - "tailscale.com/net/packet" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tsdial" - "tailscale.com/net/tstun" - "tailscale.com/proxymap" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/types/ipproto" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/nettype" - "tailscale.com/util/clientmetric" - "tailscale.com/version" - "tailscale.com/wgengine" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/magicsock" - "tailscale.com/wgengine/netstack/gro" + "github.com/sagernet/gvisor/pkg/refs" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/gvisor/pkg/waiter" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/ipn/ipnlocal" + "github.com/sagernet/tailscale/metrics" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/net/ipset" + "github.com/sagernet/tailscale/net/netaddr" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/proxymap" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/nettype" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/wgengine" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/magicsock" + "github.com/sagernet/tailscale/wgengine/netstack/gro" + "github.com/sagernet/wireguard-go/conn" ) const debugPackets = false diff --git a/wgengine/netstack/netstack_export.go b/wgengine/netstack/netstack_export.go new file mode 100644 index 0000000000000..a89e32f81c7b6 --- /dev/null +++ b/wgengine/netstack/netstack_export.go @@ -0,0 +1,7 @@ +package netstack + +import "github.com/sagernet/gvisor/pkg/tcpip/stack" + +func (ns *Impl) ExportIPStack() *stack.Stack { + return ns.ipstack +} diff --git a/wgengine/netstack/netstack_tcpbuf_default.go b/wgengine/netstack/netstack_tcpbuf_default.go index 3640964ffe399..84d1a99881534 100644 --- a/wgengine/netstack/netstack_tcpbuf_default.go +++ b/wgengine/netstack/netstack_tcpbuf_default.go @@ -6,7 +6,7 @@ package netstack import ( - "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" ) const ( diff --git a/wgengine/netstack/netstack_tcpbuf_ios.go b/wgengine/netstack/netstack_tcpbuf_ios.go index a4210c9ac7517..cc0fbd7611d09 100644 --- a/wgengine/netstack/netstack_tcpbuf_ios.go +++ b/wgengine/netstack/netstack_tcpbuf_ios.go @@ -6,7 +6,7 @@ package netstack import ( - "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" ) const ( diff --git a/wgengine/netstack/netstack_test.go b/wgengine/netstack/netstack_test.go deleted file mode 100644 index a46dcf9dd6fc9..0000000000000 --- a/wgengine/netstack/netstack_test.go +++ /dev/null @@ -1,1016 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netstack - -import ( - "context" - "fmt" - "maps" - "net" - "net/netip" - "runtime" - "testing" - "time" - - "gvisor.dev/gvisor/pkg/buffer" - "gvisor.dev/gvisor/pkg/tcpip" - "gvisor.dev/gvisor/pkg/tcpip/header" - "gvisor.dev/gvisor/pkg/tcpip/stack" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/store/mem" - "tailscale.com/metrics" - "tailscale.com/net/packet" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tsdial" - "tailscale.com/net/tstun" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/ipproto" - "tailscale.com/types/logid" - "tailscale.com/wgengine" - "tailscale.com/wgengine/filter" -) - -// TestInjectInboundLeak tests that injectInbound doesn't leak memory. -// See https://github.com/tailscale/tailscale/issues/3762 -func TestInjectInboundLeak(t *testing.T) { - tunDev := tstun.NewFake() - dialer := new(tsdial.Dialer) - logf := func(format string, args ...any) { - if !t.Failed() { - t.Logf(format, args...) - } - } - sys := new(tsd.System) - eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ - Tun: tunDev, - Dialer: dialer, - SetSubsystem: sys.Set, - HealthTracker: sys.HealthTracker(), - Metrics: sys.UserMetricsRegistry(), - }) - if err != nil { - t.Fatal(err) - } - defer eng.Close() - sys.Set(eng) - sys.Set(new(mem.Store)) - - tunWrap := sys.Tun.Get() - lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - t.Fatal(err) - } - - ns, err := Create(logf, tunWrap, eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) - if err != nil { - t.Fatal(err) - } - defer ns.Close() - ns.ProcessLocalIPs = true - if err := ns.Start(lb); err != nil { - t.Fatalf("Start: %v", err) - } - ns.atomicIsLocalIPFunc.Store(func(netip.Addr) bool { return true }) - - pkt := &packet.Parsed{} - const N = 10_000 - ms0 := getMemStats() - for range N { - outcome, _ := ns.injectInbound(pkt, tunWrap, nil) - if outcome != filter.DropSilently { - t.Fatalf("got outcome %v; want DropSilently", outcome) - } - } - ms1 := getMemStats() - if grew := int64(ms1.HeapObjects) - int64(ms0.HeapObjects); grew >= N { - t.Fatalf("grew by %v (which is too much and >= the %v packets we sent)", grew, N) - } -} - -func getMemStats() (ms runtime.MemStats) { - runtime.GC() - runtime.ReadMemStats(&ms) - return -} - -func makeNetstack(tb testing.TB, config func(*Impl)) *Impl { - tunDev := tstun.NewFake() - sys := &tsd.System{} - sys.Set(new(mem.Store)) - dialer := new(tsdial.Dialer) - logf := tstest.WhileTestRunningLogger(tb) - eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ - Tun: tunDev, - Dialer: dialer, - SetSubsystem: sys.Set, - HealthTracker: sys.HealthTracker(), - Metrics: sys.UserMetricsRegistry(), - }) - if err != nil { - tb.Fatal(err) - } - tb.Cleanup(func() { eng.Close() }) - sys.Set(eng) - - ns, err := Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) - if err != nil { - tb.Fatal(err) - } - tb.Cleanup(func() { ns.Close() }) - - lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0) - if err != nil { - tb.Fatalf("NewLocalBackend: %v", err) - } - - ns.atomicIsLocalIPFunc.Store(func(netip.Addr) bool { return true }) - if config != nil { - config(ns) - } - if err := ns.Start(lb); err != nil { - tb.Fatalf("Start: %v", err) - } - return ns -} - -func TestShouldHandlePing(t *testing.T) { - srcIP := netip.AddrFrom4([4]byte{1, 2, 3, 4}) - - t.Run("ICMP4", func(t *testing.T) { - dst := netip.MustParseAddr("5.6.7.8") - icmph := packet.ICMP4Header{ - IP4Header: packet.IP4Header{ - IPProto: ipproto.ICMPv4, - Src: srcIP, - Dst: dst, - }, - Type: packet.ICMP4EchoRequest, - Code: packet.ICMP4NoCode, - } - _, payload := packet.ICMPEchoPayload(nil) - icmpPing := packet.Generate(icmph, payload) - pkt := &packet.Parsed{} - pkt.Decode(icmpPing) - - impl := makeNetstack(t, func(impl *Impl) { - impl.ProcessSubnets = true - }) - pingDst, ok := impl.shouldHandlePing(pkt) - if !ok { - t.Errorf("expected shouldHandlePing==true") - } - if pingDst != dst { - t.Errorf("got dst %s; want %s", pingDst, dst) - } - }) - - t.Run("ICMP6-no-via", func(t *testing.T) { - dst := netip.MustParseAddr("2a09:8280:1::4169") - icmph := packet.ICMP6Header{ - IP6Header: packet.IP6Header{ - IPProto: ipproto.ICMPv6, - Src: srcIP, - Dst: dst, - }, - Type: packet.ICMP6EchoRequest, - Code: packet.ICMP6NoCode, - } - _, payload := packet.ICMPEchoPayload(nil) - icmpPing := packet.Generate(icmph, payload) - pkt := &packet.Parsed{} - pkt.Decode(icmpPing) - - impl := makeNetstack(t, func(impl *Impl) { - impl.ProcessSubnets = true - }) - pingDst, ok := impl.shouldHandlePing(pkt) - - // Expect that we handle this since it's going out onto the - // network. - if !ok { - t.Errorf("expected shouldHandlePing==true") - } - if pingDst != dst { - t.Errorf("got dst %s; want %s", pingDst, dst) - } - }) - - t.Run("ICMP6-tailscale-addr", func(t *testing.T) { - dst := netip.MustParseAddr("fd7a:115c:a1e0:ab12::1") - icmph := packet.ICMP6Header{ - IP6Header: packet.IP6Header{ - IPProto: ipproto.ICMPv6, - Src: srcIP, - Dst: dst, - }, - Type: packet.ICMP6EchoRequest, - Code: packet.ICMP6NoCode, - } - _, payload := packet.ICMPEchoPayload(nil) - icmpPing := packet.Generate(icmph, payload) - pkt := &packet.Parsed{} - pkt.Decode(icmpPing) - - impl := makeNetstack(t, func(impl *Impl) { - impl.ProcessSubnets = true - }) - _, ok := impl.shouldHandlePing(pkt) - - // We don't handle this because it's a Tailscale IP and not 4via6 - if ok { - t.Errorf("expected shouldHandlePing==false") - } - }) - - // Handle pings for 4via6 addresses regardless of ProcessSubnets - for _, subnets := range []bool{true, false} { - t.Run("ICMP6-4via6-ProcessSubnets-"+fmt.Sprint(subnets), func(t *testing.T) { - // The 4via6 route 10.1.1.0/24 siteid 7, and then the IP - // 10.1.1.9 within that route. - dst := netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:7:a01:109") - expectedPingDst := netip.MustParseAddr("10.1.1.9") - icmph := packet.ICMP6Header{ - IP6Header: packet.IP6Header{ - IPProto: ipproto.ICMPv6, - Src: srcIP, - Dst: dst, - }, - Type: packet.ICMP6EchoRequest, - Code: packet.ICMP6NoCode, - } - _, payload := packet.ICMPEchoPayload(nil) - icmpPing := packet.Generate(icmph, payload) - pkt := &packet.Parsed{} - pkt.Decode(icmpPing) - - impl := makeNetstack(t, func(impl *Impl) { - impl.ProcessSubnets = subnets - }) - pingDst, ok := impl.shouldHandlePing(pkt) - - // Handled due to being 4via6 - if !ok { - t.Errorf("expected shouldHandlePing==true") - } else if pingDst != expectedPingDst { - t.Errorf("got dst %s; want %s", pingDst, expectedPingDst) - } - }) - } -} - -// looksLikeATailscaleSelfAddress reports whether addr looks like -// a Tailscale self address, for tests. -func looksLikeATailscaleSelfAddress(addr netip.Addr) bool { - return addr.Is4() && tsaddr.IsTailscaleIP(addr) || - addr.Is6() && tsaddr.Tailscale4To6Range().Contains(addr) -} - -func TestShouldProcessInbound(t *testing.T) { - testCases := []struct { - name string - pkt *packet.Parsed - afterStart func(*Impl) // optional; after Impl.Start is called - beforeStart func(*Impl) // optional; before Impl.Start is called - want bool - runOnGOOS string - }{ - { - name: "ipv6-via", - pkt: &packet.Parsed{ - IPVersion: 6, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("100.101.102.103:1234"), - - // $ tailscale debug via 7 10.1.1.9/24 - // fd7a:115c:a1e0:b1a:0:7:a01:109/120 - Dst: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:7:a01:109]:5678"), - TCPFlags: packet.TCPSyn, - }, - afterStart: func(i *Impl) { - prefs := ipn.NewPrefs() - prefs.AdvertiseRoutes = []netip.Prefix{ - // $ tailscale debug via 7 10.1.1.0/24 - // fd7a:115c:a1e0:b1a:0:7:a01:100/120 - netip.MustParsePrefix("fd7a:115c:a1e0:b1a:0:7:a01:100/120"), - } - i.lb.Start(ipn.Options{ - UpdatePrefs: prefs, - }) - i.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress) - }, - beforeStart: func(i *Impl) { - // This should be handled even if we're - // otherwise not processing local IPs or - // subnets. - i.ProcessLocalIPs = false - i.ProcessSubnets = false - }, - want: true, - }, - { - name: "ipv6-via-not-advertised", - pkt: &packet.Parsed{ - IPVersion: 6, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("100.101.102.103:1234"), - - // $ tailscale debug via 7 10.1.1.9/24 - // fd7a:115c:a1e0:b1a:0:7:a01:109/120 - Dst: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:7:a01:109]:5678"), - TCPFlags: packet.TCPSyn, - }, - afterStart: func(i *Impl) { - prefs := ipn.NewPrefs() - prefs.AdvertiseRoutes = []netip.Prefix{ - // tailscale debug via 7 10.1.2.0/24 - // fd7a:115c:a1e0:b1a:0:7:a01:200/120 - netip.MustParsePrefix("fd7a:115c:a1e0:b1a:0:7:a01:200/120"), - } - i.lb.Start(ipn.Options{ - UpdatePrefs: prefs, - }) - }, - want: false, - }, - { - name: "tailscale-ssh-enabled", - pkt: &packet.Parsed{ - IPVersion: 4, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("100.101.102.103:1234"), - Dst: netip.MustParseAddrPort("100.101.102.104:22"), - TCPFlags: packet.TCPSyn, - }, - afterStart: func(i *Impl) { - prefs := ipn.NewPrefs() - prefs.RunSSH = true - i.lb.Start(ipn.Options{ - UpdatePrefs: prefs, - }) - i.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool { - return addr.String() == "100.101.102.104" // Dst, above - }) - }, - want: true, - runOnGOOS: "linux", - }, - { - name: "tailscale-ssh-disabled", - pkt: &packet.Parsed{ - IPVersion: 4, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("100.101.102.103:1234"), - Dst: netip.MustParseAddrPort("100.101.102.104:22"), - TCPFlags: packet.TCPSyn, - }, - afterStart: func(i *Impl) { - prefs := ipn.NewPrefs() - prefs.RunSSH = false // default, but to be explicit - i.lb.Start(ipn.Options{ - UpdatePrefs: prefs, - }) - i.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool { - return addr.String() == "100.101.102.104" // Dst, above - }) - }, - want: false, - }, - { - name: "process-local-ips", - pkt: &packet.Parsed{ - IPVersion: 4, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("100.101.102.103:1234"), - Dst: netip.MustParseAddrPort("100.101.102.104:4567"), - TCPFlags: packet.TCPSyn, - }, - afterStart: func(i *Impl) { - i.ProcessLocalIPs = true - i.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool { - return addr.String() == "100.101.102.104" // Dst, above - }) - }, - want: true, - }, - { - name: "process-subnets", - pkt: &packet.Parsed{ - IPVersion: 4, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("100.101.102.103:1234"), - Dst: netip.MustParseAddrPort("10.1.2.3:4567"), - TCPFlags: packet.TCPSyn, - }, - beforeStart: func(i *Impl) { - i.ProcessSubnets = true - }, - afterStart: func(i *Impl) { - // For testing purposes, assume all Tailscale - // IPs are local; the Dst above is something - // not in that range. - i.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress) - }, - want: true, - }, - { - name: "peerapi-port-subnet-router", // see #6235 - pkt: &packet.Parsed{ - IPVersion: 4, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("100.101.102.103:1234"), - Dst: netip.MustParseAddrPort("10.0.0.23:5555"), - TCPFlags: packet.TCPSyn, - }, - beforeStart: func(i *Impl) { - // As if we were running on Linux where netstack isn't used. - i.ProcessSubnets = false - i.atomicIsLocalIPFunc.Store(func(netip.Addr) bool { return false }) - }, - afterStart: func(i *Impl) { - prefs := ipn.NewPrefs() - prefs.AdvertiseRoutes = []netip.Prefix{ - netip.MustParsePrefix("10.0.0.1/24"), - } - i.lb.Start(ipn.Options{ - UpdatePrefs: prefs, - }) - - // Set the PeerAPI port to the Dst port above. - i.peerapiPort4Atomic.Store(5555) - i.peerapiPort6Atomic.Store(5555) - }, - want: false, - }, - - // TODO(andrew): test PeerAPI - // TODO(andrew): test TCP packets without the SYN flag set - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if tc.runOnGOOS != "" && runtime.GOOS != tc.runOnGOOS { - t.Skipf("skipping on GOOS=%v", runtime.GOOS) - } - impl := makeNetstack(t, tc.beforeStart) - if tc.afterStart != nil { - tc.afterStart(impl) - } - - got := impl.shouldProcessInbound(tc.pkt, nil) - if got != tc.want { - t.Errorf("got shouldProcessInbound()=%v; want %v", got, tc.want) - } else { - t.Logf("OK: shouldProcessInbound() = %v", got) - } - }) - } -} - -func tcp4syn(tb testing.TB, src, dst netip.Addr, sport, dport uint16) []byte { - ip := header.IPv4(make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize)) - ip.Encode(&header.IPv4Fields{ - Protocol: uint8(header.TCPProtocolNumber), - TotalLength: header.IPv4MinimumSize + header.TCPMinimumSize, - TTL: 64, - SrcAddr: tcpip.AddrFrom4Slice(src.AsSlice()), - DstAddr: tcpip.AddrFrom4Slice(dst.AsSlice()), - }) - ip.SetChecksum(^ip.CalculateChecksum()) - if !ip.IsChecksumValid() { - tb.Fatal("test broken; packet has incorrect IP checksum") - } - - tcp := header.TCP(ip[header.IPv4MinimumSize:]) - tcp.Encode(&header.TCPFields{ - SrcPort: sport, - DstPort: dport, - SeqNum: 0, - DataOffset: header.TCPMinimumSize, - Flags: header.TCPFlagSyn, - WindowSize: 65535, - Checksum: 0, - }) - xsum := header.PseudoHeaderChecksum( - header.TCPProtocolNumber, - tcpip.AddrFrom4Slice(src.AsSlice()), - tcpip.AddrFrom4Slice(dst.AsSlice()), - uint16(header.TCPMinimumSize), - ) - tcp.SetChecksum(^tcp.CalculateChecksum(xsum)) - if !tcp.IsChecksumValid(tcpip.AddrFrom4Slice(src.AsSlice()), tcpip.AddrFrom4Slice(dst.AsSlice()), 0, 0) { - tb.Fatal("test broken; packet has incorrect TCP checksum") - } - - return ip -} - -// makeHangDialer returns a dialer that notifies the returned channel when a -// connection is dialed and then hangs until the test finishes. -func makeHangDialer(tb testing.TB) (func(context.Context, string, string) (net.Conn, error), chan struct{}) { - done := make(chan struct{}) - tb.Cleanup(func() { - close(done) - }) - - gotConn := make(chan struct{}, 1) - fn := func(ctx context.Context, network, address string) (net.Conn, error) { - // Signal that we have a new connection - tb.Logf("hangDialer: called with network=%q address=%q", network, address) - select { - case gotConn <- struct{}{}: - default: - } - - // Hang until the test is done. - select { - case <-ctx.Done(): - tb.Logf("context done") - case <-done: - tb.Logf("function completed") - } - return nil, fmt.Errorf("canceled") - } - return fn, gotConn -} - -// TestTCPForwardLimits verifies that the limits on the TCP forwarder work in a -// success case (i.e. when we don't hit the limit). -func TestTCPForwardLimits(t *testing.T) { - envknob.Setenv("TS_DEBUG_NETSTACK", "true") - impl := makeNetstack(t, func(impl *Impl) { - impl.ProcessSubnets = true - }) - - dialFn, gotConn := makeHangDialer(t) - impl.forwardDialFunc = dialFn - - prefs := ipn.NewPrefs() - prefs.AdvertiseRoutes = []netip.Prefix{ - // This is the TEST-NET-1 IP block for use in documentation, - // and should never actually be routable. - netip.MustParsePrefix("192.0.2.0/24"), - } - impl.lb.Start(ipn.Options{ - UpdatePrefs: prefs, - }) - impl.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress) - - // Inject an "outbound" packet that's going to an IP address that times - // out. We need to re-parse from a byte slice so that the internal - // buffer in the packet.Parsed type is filled out. - client := netip.MustParseAddr("100.101.102.103") - destAddr := netip.MustParseAddr("192.0.2.1") - pkt := tcp4syn(t, client, destAddr, 1234, 4567) - var parsed packet.Parsed - parsed.Decode(pkt) - - // When injecting this packet, we want the outcome to be "drop - // silently", which indicates that netstack is processing the - // packet and not delivering it to the host system. - if resp, _ := impl.injectInbound(&parsed, impl.tundev, nil); resp != filter.DropSilently { - t.Errorf("got filter outcome %v, want filter.DropSilently", resp) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Wait until we have an in-flight outgoing connection. - select { - case <-ctx.Done(): - t.Fatalf("timed out waiting for connection") - case <-gotConn: - t.Logf("got connection in progress") - } - - // Inject another packet, which will be deduplicated and thus not - // increment our counter. - parsed.Decode(pkt) - if resp, _ := impl.injectInbound(&parsed, impl.tundev, nil); resp != filter.DropSilently { - t.Errorf("got filter outcome %v, want filter.DropSilently", resp) - } - - // Verify that we now have a single in-flight address in our map. - impl.mu.Lock() - inFlight := maps.Clone(impl.connsInFlightByClient) - impl.mu.Unlock() - - if got, ok := inFlight[client]; !ok || got != 1 { - t.Errorf("expected 1 in-flight connection for %v, got: %v", client, inFlight) - } - - // Get the expvar statistics and verify that we're exporting the - // correct metric. - metrics := impl.ExpVar().(*metrics.Set) - - const metricName = "gauge_tcp_forward_in_flight" - if v := metrics.Get(metricName).String(); v != "1" { - t.Errorf("got metric %q=%s, want 1", metricName, v) - } -} - -// TestTCPForwardLimits_PerClient verifies that the per-client limit for TCP -// forwarding works. -func TestTCPForwardLimits_PerClient(t *testing.T) { - envknob.Setenv("TS_DEBUG_NETSTACK", "true") - - // Set our test override limits during this test. - tstest.Replace(t, &maxInFlightConnectionAttemptsForTest, 2) - tstest.Replace(t, &maxInFlightConnectionAttemptsPerClientForTest, 1) - - impl := makeNetstack(t, func(impl *Impl) { - impl.ProcessSubnets = true - }) - - dialFn, gotConn := makeHangDialer(t) - impl.forwardDialFunc = dialFn - - prefs := ipn.NewPrefs() - prefs.AdvertiseRoutes = []netip.Prefix{ - // This is the TEST-NET-1 IP block for use in documentation, - // and should never actually be routable. - netip.MustParsePrefix("192.0.2.0/24"), - } - impl.lb.Start(ipn.Options{ - UpdatePrefs: prefs, - }) - impl.atomicIsLocalIPFunc.Store(looksLikeATailscaleSelfAddress) - - // Inject an "outbound" packet that's going to an IP address that times - // out. We need to re-parse from a byte slice so that the internal - // buffer in the packet.Parsed type is filled out. - client := netip.MustParseAddr("100.101.102.103") - destAddr := netip.MustParseAddr("192.0.2.1") - - // Helpers - var port uint16 = 1234 - mustInjectPacket := func() { - pkt := tcp4syn(t, client, destAddr, port, 4567) - port++ // to avoid deduplication based on endpoint - - var parsed packet.Parsed - parsed.Decode(pkt) - - // When injecting this packet, we want the outcome to be "drop - // silently", which indicates that netstack is processing the - // packet and not delivering it to the host system. - if resp, _ := impl.injectInbound(&parsed, impl.tundev, nil); resp != filter.DropSilently { - t.Fatalf("got filter outcome %v, want filter.DropSilently", resp) - } - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - waitPacket := func() { - select { - case <-ctx.Done(): - t.Fatalf("timed out waiting for connection") - case <-gotConn: - t.Logf("got connection in progress") - } - } - - // Inject the packet to start the TCP forward and wait until we have an - // in-flight outgoing connection. - mustInjectPacket() - waitPacket() - - // Verify that we now have a single in-flight address in our map. - impl.mu.Lock() - inFlight := maps.Clone(impl.connsInFlightByClient) - impl.mu.Unlock() - - if got, ok := inFlight[client]; !ok || got != 1 { - t.Errorf("expected 1 in-flight connection for %v, got: %v", client, inFlight) - } - - metrics := impl.ExpVar().(*metrics.Set) - - // One client should have reached the limit at this point. - if v := metrics.Get("gauge_tcp_forward_in_flight_per_client_limit_reached").String(); v != "1" { - t.Errorf("got limit reached expvar metric=%s, want 1", v) - } - - // Inject another packet, and verify that we've incremented our - // "dropped" metrics since this will have been dropped. - mustInjectPacket() - - // expvar metric - const metricName = "counter_tcp_forward_max_in_flight_per_client_drop" - if v := metrics.Get(metricName).String(); v != "1" { - t.Errorf("got expvar metric %q=%s, want 1", metricName, v) - } - - // client metric - if v := metricPerClientForwardLimit.Value(); v != 1 { - t.Errorf("got clientmetric limit metric=%d, want 1", v) - } -} - -// TestHandleLocalPackets tests the handleLocalPackets function, ensuring that -// we are properly deciding to handle packets that are destined for "local" -// IPs–addresses that are either for this node, or that it is responsible for. -// -// See, e.g. #11304 -func TestHandleLocalPackets(t *testing.T) { - var ( - selfIP4 = netip.MustParseAddr("100.64.1.2") - selfIP6 = netip.MustParseAddr("fd7a:115c:a1e0::123") - ) - - impl := makeNetstack(t, func(impl *Impl) { - impl.ProcessSubnets = false - impl.ProcessLocalIPs = false - impl.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool { - return addr == selfIP4 || addr == selfIP6 - }) - }) - - prefs := ipn.NewPrefs() - prefs.AdvertiseRoutes = []netip.Prefix{ - // $ tailscale debug via 7 10.1.1.0/24 - // fd7a:115c:a1e0:b1a:0:7:a01:100/120 - netip.MustParsePrefix("fd7a:115c:a1e0:b1a:0:7:a01:100/120"), - } - _, err := impl.lb.EditPrefs(&ipn.MaskedPrefs{ - Prefs: *prefs, - AdvertiseRoutesSet: true, - }) - if err != nil { - t.Fatalf("EditPrefs: %v", err) - } - - t.Run("ShouldHandleServiceIP", func(t *testing.T) { - pkt := &packet.Parsed{ - IPVersion: 4, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("127.0.0.1:9999"), - Dst: netip.MustParseAddrPort("100.100.100.100:53"), - TCPFlags: packet.TCPSyn, - } - resp, _ := impl.handleLocalPackets(pkt, impl.tundev, nil) - if resp != filter.DropSilently { - t.Errorf("got filter outcome %v, want filter.DropSilently", resp) - } - }) - t.Run("ShouldHandle4via6", func(t *testing.T) { - pkt := &packet.Parsed{ - IPVersion: 6, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("[::1]:1234"), - - // This is an IP in the above 4via6 subnet that this node handles. - // $ tailscale debug via 7 10.1.1.9/24 - // fd7a:115c:a1e0:b1a:0:7:a01:109/120 - Dst: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:7:a01:109]:5678"), - TCPFlags: packet.TCPSyn, - } - resp, _ := impl.handleLocalPackets(pkt, impl.tundev, nil) - - // DropSilently is the outcome we expected, since we actually - // handled this packet by injecting it into netstack, which - // will handle creating the TCP forwarder. We drop it so we - // don't process the packet outside of netstack. - if resp != filter.DropSilently { - t.Errorf("got filter outcome %v, want filter.DropSilently", resp) - } - }) - t.Run("OtherNonHandled", func(t *testing.T) { - pkt := &packet.Parsed{ - IPVersion: 6, - IPProto: ipproto.TCP, - Src: netip.MustParseAddrPort("[::1]:1234"), - - // This IP is *not* in the above 4via6 route - // $ tailscale debug via 99 10.1.1.9/24 - // fd7a:115c:a1e0:b1a:0:63:a01:109/120 - Dst: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:63:a01:109]:5678"), - TCPFlags: packet.TCPSyn, - } - resp, _ := impl.handleLocalPackets(pkt, impl.tundev, nil) - - // Accept means that handleLocalPackets does not handle this - // packet, we "accept" it to continue further processing, - // instead of dropping because it was already handled. - if resp != filter.Accept { - t.Errorf("got filter outcome %v, want filter.Accept", resp) - } - }) -} - -func TestShouldSendToHost(t *testing.T) { - var ( - selfIP4 = netip.MustParseAddr("100.64.1.2") - selfIP6 = netip.MustParseAddr("fd7a:115c:a1e0::123") - ) - - makeTestNetstack := func(tb testing.TB) *Impl { - impl := makeNetstack(tb, func(impl *Impl) { - impl.ProcessSubnets = false - impl.ProcessLocalIPs = false - impl.atomicIsLocalIPFunc.Store(func(addr netip.Addr) bool { - return addr == selfIP4 || addr == selfIP6 - }) - }) - - prefs := ipn.NewPrefs() - prefs.AdvertiseRoutes = []netip.Prefix{ - // $ tailscale debug via 7 10.1.1.0/24 - // fd7a:115c:a1e0:b1a:0:7:a01:100/120 - netip.MustParsePrefix("fd7a:115c:a1e0:b1a:0:7:a01:100/120"), - } - _, err := impl.lb.EditPrefs(&ipn.MaskedPrefs{ - Prefs: *prefs, - AdvertiseRoutesSet: true, - }) - if err != nil { - tb.Fatalf("EditPrefs: %v", err) - } - return impl - } - - testCases := []struct { - name string - src, dst netip.AddrPort - want bool - }{ - // Reply from service IP to localhost should be sent to host, - // not over WireGuard. - { - name: "from_service_ip_to_localhost", - src: netip.AddrPortFrom(serviceIP, 53), - dst: netip.MustParseAddrPort("127.0.0.1:9999"), - want: true, - }, - { - name: "from_service_ip_to_localhost_v6", - src: netip.AddrPortFrom(serviceIPv6, 53), - dst: netip.MustParseAddrPort("[::1]:9999"), - want: true, - }, - // A reply from the local IP to a remote host isn't sent to the - // host, but rather over WireGuard. - { - name: "local_ip_to_remote", - src: netip.AddrPortFrom(selfIP4, 12345), - dst: netip.MustParseAddrPort("100.64.99.88:7777"), - want: false, - }, - { - name: "local_ip_to_remote_v6", - src: netip.AddrPortFrom(selfIP6, 12345), - dst: netip.MustParseAddrPort("[fd7a:115:a1e0::99]:7777"), - want: false, - }, - // A reply from a 4via6 address to a remote host isn't sent to - // the local host, but rather over WireGuard. See: - // https://github.com/tailscale/tailscale/issues/12448 - { - name: "4via6_to_remote", - - // $ tailscale debug via 7 10.1.1.99/24 - // fd7a:115c:a1e0:b1a:0:7:a01:163/120 - src: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:7:a01:163]:12345"), - dst: netip.MustParseAddrPort("[fd7a:115:a1e0::99]:7777"), - want: false, - }, - // However, a reply from a 4via6 address to the local Tailscale - // IP for this host *is* sent to the local host. See: - // https://github.com/tailscale/tailscale/issues/11304 - { - name: "4via6_to_local", - - // $ tailscale debug via 7 10.1.1.99/24 - // fd7a:115c:a1e0:b1a:0:7:a01:163/120 - src: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:7:a01:163]:12345"), - dst: netip.AddrPortFrom(selfIP6, 7777), - want: true, - }, - // Traffic from a 4via6 address that we're not handling to - // either the local Tailscale IP or a remote host is sent - // outbound. - // - // In most cases, we won't see this type of traffic in the - // shouldSendToHost function, but let's confirm. - { - name: "other_4via6_to_local", - - // $ tailscale debug via 4444 10.1.1.88/24 - // fd7a:115c:a1e0:b1a:0:7:a01:163/120 - src: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:115c:a01:158]:12345"), - dst: netip.AddrPortFrom(selfIP6, 7777), - want: false, - }, - { - name: "other_4via6_to_remote", - - // $ tailscale debug via 4444 10.1.1.88/24 - // fd7a:115c:a1e0:b1a:0:7:a01:163/120 - src: netip.MustParseAddrPort("[fd7a:115c:a1e0:b1a:0:115c:a01:158]:12345"), - dst: netip.MustParseAddrPort("[fd7a:115:a1e0::99]:7777"), - want: false, - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - var pkt *stack.PacketBuffer - if tt.src.Addr().Is4() { - pkt = makeUDP4PacketBuffer(tt.src, tt.dst) - } else { - pkt = makeUDP6PacketBuffer(tt.src, tt.dst) - } - - ns := makeTestNetstack(t) - if got := ns.shouldSendToHost(pkt); got != tt.want { - t.Errorf("shouldSendToHost returned %v, want %v", got, tt.want) - } - }) - } -} - -func makeUDP4PacketBuffer(src, dst netip.AddrPort) *stack.PacketBuffer { - if !src.Addr().Is4() || !dst.Addr().Is4() { - panic("src and dst must be IPv4") - } - - data := []byte("hello world\n") - - packetLen := header.IPv4MinimumSize + header.UDPMinimumSize - pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ - ReserveHeaderBytes: packetLen, - Payload: buffer.MakeWithData(data), - }) - - // Initialize the UDP header. - udp := header.UDP(pkt.TransportHeader().Push(header.UDPMinimumSize)) - pkt.TransportProtocolNumber = header.UDPProtocolNumber - - length := uint16(pkt.Size()) - udp.Encode(&header.UDPFields{ - SrcPort: src.Port(), - DstPort: dst.Port(), - Length: length, - }) - - // Add IP header - ipHdr := header.IPv4(pkt.NetworkHeader().Push(header.IPv4MinimumSize)) - pkt.NetworkProtocolNumber = header.IPv4ProtocolNumber - ipHdr.Encode(&header.IPv4Fields{ - TotalLength: uint16(packetLen), - Protocol: uint8(header.UDPProtocolNumber), - SrcAddr: tcpip.AddrFrom4(src.Addr().As4()), - DstAddr: tcpip.AddrFrom4(dst.Addr().As4()), - Checksum: 0, - }) - - return pkt -} - -func makeUDP6PacketBuffer(src, dst netip.AddrPort) *stack.PacketBuffer { - if !src.Addr().Is6() || !dst.Addr().Is6() { - panic("src and dst must be IPv6") - } - data := []byte("hello world\n") - - packetLen := header.IPv6MinimumSize + header.UDPMinimumSize - pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ - ReserveHeaderBytes: packetLen, - Payload: buffer.MakeWithData(data), - }) - - srcAddr := tcpip.AddrFrom16(src.Addr().As16()) - dstAddr := tcpip.AddrFrom16(dst.Addr().As16()) - - // Add IP header - ipHdr := header.IPv6(pkt.NetworkHeader().Push(header.IPv6MinimumSize)) - pkt.NetworkProtocolNumber = header.IPv6ProtocolNumber - ipHdr.Encode(&header.IPv6Fields{ - SrcAddr: srcAddr, - DstAddr: dstAddr, - PayloadLength: uint16(header.UDPMinimumSize + len(data)), - TransportProtocol: header.UDPProtocolNumber, - HopLimit: 64, - }) - - // Initialize the UDP header. - udp := header.UDP(pkt.TransportHeader().Push(header.UDPMinimumSize)) - pkt.TransportProtocolNumber = header.UDPProtocolNumber - - length := uint16(pkt.Size()) - udp.Encode(&header.UDPFields{ - SrcPort: src.Port(), - DstPort: dst.Port(), - Length: length, - }) - - // Calculate the UDP pseudo-header checksum. - xsum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, srcAddr, dstAddr, uint16(len(udp))) - udp.SetChecksum(^udp.CalculateChecksum(xsum)) - - return pkt -} diff --git a/wgengine/netstack/netstack_userping.go b/wgengine/netstack/netstack_userping.go index ee635bd877dca..b89aa9a349bea 100644 --- a/wgengine/netstack/netstack_userping.go +++ b/wgengine/netstack/netstack_userping.go @@ -13,7 +13,7 @@ import ( "runtime" "time" - "tailscale.com/version/distro" + "github.com/sagernet/tailscale/version/distro" ) // setAmbientCapsRaw is non-nil on Linux for Synology, to run ping with diff --git a/wgengine/netstack/netstack_userping_test.go b/wgengine/netstack/netstack_userping_test.go deleted file mode 100644 index a179f74673469..0000000000000 --- a/wgengine/netstack/netstack_userping_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package netstack - -import ( - "net/netip" - "testing" -) - -func TestWindowsPingOutputIsSuccess(t *testing.T) { - tests := []struct { - name string - ip string - out string - want bool - }{ - { - name: "success", - ip: "10.0.0.1", - want: true, - out: `Pinging 10.0.0.1 with 32 bytes of data: -Reply from 10.0.0.1: bytes=32 time=7ms TTL=64 - -Ping statistics for 10.0.0.1: - Packets: Sent = 1, Received = 1, Lost = 0 (0% loss), -Approximate round trip times in milli-seconds: - Minimum = 7ms, Maximum = 7ms, Average = 7ms -`, - }, - { - name: "success_sub_millisecond", - ip: "10.0.0.1", - want: true, - out: `Pinging 10.0.0.1 with 32 bytes of data: -Reply from 10.0.0.1: bytes=32 time<1ms TTL=64 - -Ping statistics for 10.0.0.1: - Packets: Sent = 1, Received = 1, Lost = 0 (0% loss), -Approximate round trip times in milli-seconds: - Minimum = 7ms, Maximum = 7ms, Average = 7ms -`, - }, - { - name: "success_german", - ip: "10.0.0.1", - want: true, - out: `Ping wird ausgeführt für 10.0.0.1 mit 32 Bytes Daten: -Antwort von from 10.0.0.1: Bytes=32 Zeit=7ms TTL=64 - -Ping-Statistik für 10.0.0.1: - Pakete: Gesendet = 4, Empfangen = 4, Verloren = 0 (0% Verlust), -Ca. Zeitangaben in Millisek.: - Minimum = 7ms, Maximum = 7ms, Mittelwert = 7ms -`, - }, - { - name: "unreachable", - ip: "10.0.0.6", - want: false, - out: `Pinging 10.0.0.6 with 32 bytes of data: -Reply from 10.0.108.189: Destination host unreachable - -Ping statistics for 10.0.0.6: - Packets: Sent = 1, Received = 1, Lost = 0 (0% loss), -`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := windowsPingOutputIsSuccess(netip.MustParseAddr(tt.ip), []byte(tt.out)) - if got != tt.want { - t.Errorf("got %v; want %v", got, tt.want) - } - }) - } -} diff --git a/wgengine/pendopen.go b/wgengine/pendopen.go index 7db07c685aa75..c2f4204e00803 100644 --- a/wgengine/pendopen.go +++ b/wgengine/pendopen.go @@ -11,13 +11,13 @@ import ( "time" "github.com/gaissmai/bart" - "tailscale.com/net/flowtrack" - "tailscale.com/net/packet" - "tailscale.com/net/tstun" - "tailscale.com/types/ipproto" - "tailscale.com/types/lazy" - "tailscale.com/util/mak" - "tailscale.com/wgengine/filter" + "github.com/sagernet/tailscale/net/flowtrack" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/lazy" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/wgengine/filter" ) const tcpTimeoutBeforeDebug = 5 * time.Second diff --git a/wgengine/router/callback.go b/wgengine/router/callback.go index 1d90912778226..f5a88e559860b 100644 --- a/wgengine/router/callback.go +++ b/wgengine/router/callback.go @@ -6,7 +6,7 @@ package router import ( "sync" - "tailscale.com/net/dns" + "github.com/sagernet/tailscale/net/dns" ) // CallbackRouter is an implementation of both Router and dns.OSConfigurator. diff --git a/wgengine/router/consolidating_router.go b/wgengine/router/consolidating_router.go index 10c4825d8856a..8eb794ea71280 100644 --- a/wgengine/router/consolidating_router.go +++ b/wgengine/router/consolidating_router.go @@ -4,8 +4,8 @@ package router import ( + "github.com/sagernet/tailscale/types/logger" "go4.org/netipx" - "tailscale.com/types/logger" ) // ConsolidatingRoutes wraps a Router with logic that consolidates Routes diff --git a/wgengine/router/consolidating_router_test.go b/wgengine/router/consolidating_router_test.go deleted file mode 100644 index 871682d1346bc..0000000000000 --- a/wgengine/router/consolidating_router_test.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package router - -import ( - "log" - "net/netip" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestConsolidateRoutes(t *testing.T) { - parseRoutes := func(routes ...string) []netip.Prefix { - parsed := make([]netip.Prefix, 0, len(routes)) - for _, routeString := range routes { - route, err := netip.ParsePrefix(routeString) - if err != nil { - t.Fatal(err) - } - parsed = append(parsed, route) - } - return parsed - } - - tests := []struct { - name string - cfg *Config - want *Config - }{ - { - "nil cfg", - nil, - nil, - }, - { - "single route", - &Config{Routes: parseRoutes("10.0.0.0/32")}, - &Config{Routes: parseRoutes("10.0.0.0/32")}, - }, - { - "two routes from different families", - &Config{Routes: parseRoutes("10.0.0.0/32", "2603:1030:c02::/47")}, - &Config{Routes: parseRoutes("10.0.0.0/32", "2603:1030:c02::/47")}, - }, - { - "two disjoint routes", - &Config{Routes: parseRoutes("10.0.0.0/32", "10.0.2.0/32")}, - &Config{Routes: parseRoutes("10.0.0.0/32", "10.0.2.0/32")}, - }, - { - "two overlapping routes", - &Config{Routes: parseRoutes("10.0.0.0/32", "10.0.0.0/31")}, - &Config{Routes: parseRoutes("10.0.0.0/31")}, - }, - } - - cr := &consolidatingRouter{logf: log.Printf} - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got := cr.consolidateRoutes(test.cfg) - if diff := cmp.Diff(got, test.want); diff != "" { - t.Errorf("wrong result; (-got+want):%v", diff) - } - }) - } -} diff --git a/wgengine/router/ifconfig_windows.go b/wgengine/router/ifconfig_windows.go index 40e9dc6e0cdfd..7f5728a831b53 100644 --- a/wgengine/router/ifconfig_windows.go +++ b/wgengine/router/ifconfig_windows.go @@ -14,15 +14,14 @@ import ( "sort" "time" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tstun" - "tailscale.com/util/multierr" - "tailscale.com/wgengine/winnet" - ole "github.com/go-ole/go-ole" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/wgengine/winnet" + "github.com/sagernet/wireguard-go/tun" "go4.org/netipx" "golang.org/x/sys/windows" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" diff --git a/wgengine/router/ifconfig_windows_test.go b/wgengine/router/ifconfig_windows_test.go deleted file mode 100644 index 11b98d1d77d98..0000000000000 --- a/wgengine/router/ifconfig_windows_test.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package router - -import ( - "fmt" - "math/rand" - "net/netip" - "reflect" - "strings" - "testing" - - "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" -) - -func randIP() netip.Addr { - b := byte(rand.Intn(3)) - return netip.AddrFrom4([4]byte{b, b, b, b}) -} - -func randRouteData() *routeData { - return &routeData{ - RouteData: winipcfg.RouteData{ - Destination: netip.PrefixFrom(randIP(), rand.Intn(30)+1), - NextHop: randIP(), - Metric: uint32(rand.Intn(3)), - }, - } -} - -type W = winipcfg.RouteData - -func TestRouteLess(t *testing.T) { - type D = routeData - ipnet := netip.MustParsePrefix - tests := []struct { - ri, rj *routeData - want bool - }{ - { - ri: &D{RouteData: W{Metric: 1}}, - rj: &D{RouteData: W{Metric: 2}}, - want: true, - }, - { - ri: &D{RouteData: W{Destination: ipnet("1.1.0.0/16"), Metric: 2}}, - rj: &D{RouteData: W{Destination: ipnet("2.2.0.0/16"), Metric: 1}}, - want: true, - }, - { - ri: &D{RouteData: W{Destination: ipnet("1.1.0.0/16"), Metric: 1}}, - rj: &D{RouteData: W{Destination: ipnet("2.2.0.0/16"), Metric: 1}}, - want: true, - }, - { - ri: &D{RouteData: W{Destination: ipnet("1.1.0.0/32"), Metric: 2}}, - rj: &D{RouteData: W{Destination: ipnet("1.1.0.0/16"), Metric: 1}}, - want: true, - }, - { - ri: &D{RouteData: W{Destination: ipnet("1.1.0.0/32"), Metric: 1}}, - rj: &D{RouteData: W{Destination: ipnet("1.1.0.0/16"), Metric: 1}}, - want: true, - }, - { - ri: &D{RouteData: W{Destination: ipnet("1.1.0.0/16"), Metric: 1, NextHop: netip.MustParseAddr("3.3.3.3")}}, - rj: &D{RouteData: W{Destination: ipnet("1.1.0.0/16"), Metric: 1, NextHop: netip.MustParseAddr("4.4.4.4")}}, - want: true, - }, - } - for i, tt := range tests { - got := tt.ri.Less(tt.rj) - if got != tt.want { - t.Errorf("%v. less = %v; want %v", i, got, tt.want) - } - back := tt.rj.Less(tt.ri) - if back && got { - t.Errorf("%v. less both ways", i) - } - } -} - -func TestRouteDataLessConsistent(t *testing.T) { - for range 10000 { - ri := randRouteData() - rj := randRouteData() - if ri.Less(rj) && rj.Less(ri) { - t.Fatalf("both compare less to each other:\n\t%#v\nand\n\t%#v", ri, rj) - } - } -} - -func nets(cidrs ...string) (ret []netip.Prefix) { - for _, s := range cidrs { - ret = append(ret, netip.MustParsePrefix(s)) - } - return -} - -func nilIfEmpty[E any](s []E) []E { - if len(s) == 0 { - return nil - } - return s -} - -func TestDeltaNets(t *testing.T) { - tests := []struct { - a, b []netip.Prefix - wantAdd, wantDel []netip.Prefix - }{ - { - a: nets("1.2.3.4/24", "1.2.3.4/31", "1.2.3.3/32", "10.0.1.1/32", "100.0.1.1/32"), - b: nets("10.0.1.1/32", "100.0.2.1/32", "1.2.3.3/32", "1.2.3.4/24"), - wantAdd: nets("100.0.2.1/32"), - wantDel: nets("1.2.3.4/31", "100.0.1.1/32"), - }, - { - a: nets("fe80::99d0:ec2d:b2e7:536b/64", "100.84.36.11/32"), - b: nets("100.84.36.11/32"), - wantDel: nets("fe80::99d0:ec2d:b2e7:536b/64"), - }, - { - a: nets("100.84.36.11/32", "fe80::99d0:ec2d:b2e7:536b/64"), - b: nets("100.84.36.11/32"), - wantDel: nets("fe80::99d0:ec2d:b2e7:536b/64"), - }, - { - a: nets("100.84.36.11/32", "fe80::99d0:ec2d:b2e7:536b/64"), - b: nets("100.84.36.11/32"), - wantDel: nets("fe80::99d0:ec2d:b2e7:536b/64"), - }, - } - for i, tt := range tests { - add, del := deltaNets(tt.a, tt.b) - if !reflect.DeepEqual(nilIfEmpty(add), nilIfEmpty(tt.wantAdd)) { - t.Errorf("[%d] add:\n got: %v\n want: %v\n", i, add, tt.wantAdd) - } - if !reflect.DeepEqual(nilIfEmpty(del), nilIfEmpty(tt.wantDel)) { - t.Errorf("[%d] del:\n got: %v\n want: %v\n", i, del, tt.wantDel) - } - } -} - -func formatRouteData(rds []*routeData) string { - var b strings.Builder - for _, rd := range rds { - b.WriteString(fmt.Sprintf("%+v", rd)) - } - return b.String() -} - -func equalRouteDatas(a, b []*routeData) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i].Compare(b[i]) != 0 { - return false - } - } - return true -} - -func ipnet4(ip string, bits int) netip.Prefix { - return netip.PrefixFrom(netip.MustParseAddr(ip), bits) -} - -func TestFilterRoutes(t *testing.T) { - var h0 netip.Addr - - in := []*routeData{ - // LinkLocal and Loopback routes. - {RouteData: W{ipnet4("169.254.0.0", 16), h0, 1}}, - {RouteData: W{ipnet4("169.254.255.255", 32), h0, 1}}, - {RouteData: W{ipnet4("127.0.0.0", 8), h0, 1}}, - {RouteData: W{ipnet4("127.255.255.255", 32), h0, 1}}, - // Local LAN routes. - {RouteData: W{ipnet4("192.168.0.0", 24), h0, 1}}, - {RouteData: W{ipnet4("192.168.0.255", 32), h0, 1}}, - {RouteData: W{ipnet4("192.168.1.0", 25), h0, 1}}, - {RouteData: W{ipnet4("192.168.1.127", 32), h0, 1}}, - // Some random other route. - {RouteData: W{ipnet4("192.168.2.23", 32), h0, 1}}, - // Our own tailscale address. - {RouteData: W{ipnet4("100.100.100.100", 32), h0, 1}}, - // Other tailscale addresses. - {RouteData: W{ipnet4("100.100.100.101", 32), h0, 1}}, - {RouteData: W{ipnet4("100.100.100.102", 32), h0, 1}}, - } - want := []*routeData{ - {RouteData: W{ipnet4("169.254.0.0", 16), h0, 1}}, - {RouteData: W{ipnet4("127.0.0.0", 8), h0, 1}}, - {RouteData: W{ipnet4("192.168.0.0", 24), h0, 1}}, - {RouteData: W{ipnet4("192.168.1.0", 25), h0, 1}}, - {RouteData: W{ipnet4("192.168.2.23", 32), h0, 1}}, - {RouteData: W{ipnet4("100.100.100.101", 32), h0, 1}}, - {RouteData: W{ipnet4("100.100.100.102", 32), h0, 1}}, - } - - got := filterRoutes(in, mustCIDRs("100.100.100.100/32")) - if !equalRouteDatas(got, want) { - t.Errorf("\ngot: %v\n want: %v\n", formatRouteData(got), formatRouteData(want)) - } -} - -func TestDeltaRouteData(t *testing.T) { - var h0 netip.Addr - h1 := netip.MustParseAddr("99.99.99.99") - h2 := netip.MustParseAddr("99.99.9.99") - - a := []*routeData{ - {RouteData: W{ipnet4("1.2.3.4", 32), h0, 1}}, - {RouteData: W{ipnet4("1.2.3.4", 24), h1, 2}}, - {RouteData: W{ipnet4("1.2.3.4", 24), h2, 1}}, - {RouteData: W{ipnet4("1.2.3.5", 32), h0, 1}}, - } - b := []*routeData{ - {RouteData: W{ipnet4("1.2.3.5", 32), h0, 1}}, - {RouteData: W{ipnet4("1.2.3.4", 24), h1, 2}}, - {RouteData: W{ipnet4("1.2.3.4", 24), h2, 2}}, - } - add, del := deltaRouteData(a, b) - - wantAdd := []*routeData{ - {RouteData: W{ipnet4("1.2.3.4", 24), h2, 2}}, - } - wantDel := []*routeData{ - {RouteData: W{ipnet4("1.2.3.4", 32), h0, 1}}, - {RouteData: W{ipnet4("1.2.3.4", 24), h2, 1}}, - } - - if !equalRouteDatas(add, wantAdd) { - t.Errorf("add:\n got: %v\n want: %v\n", formatRouteData(add), formatRouteData(wantAdd)) - } - if !equalRouteDatas(del, wantDel) { - t.Errorf("del:\n got: %v\n want: %v\n", formatRouteData(del), formatRouteData(wantDel)) - } -} diff --git a/wgengine/router/router.go b/wgengine/router/router.go index 42300897830d9..5e2d1c80b59ba 100644 --- a/wgengine/router/router.go +++ b/wgengine/router/router.go @@ -9,11 +9,11 @@ import ( "net/netip" "reflect" - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" - "tailscale.com/types/preftype" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/preftype" + "github.com/sagernet/wireguard-go/tun" ) // Router is responsible for managing the system network stack. diff --git a/wgengine/router/router_darwin.go b/wgengine/router/router_darwin.go index 73e394b0465b3..2e2e992d0f5eb 100644 --- a/wgengine/router/router_darwin.go +++ b/wgengine/router/router_darwin.go @@ -4,10 +4,10 @@ package router import ( - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" ) func newUserspaceRouter(logf logger.Logf, tundev tun.Device, netMon *netmon.Monitor, health *health.Tracker) (Router, error) { diff --git a/wgengine/router/router_default.go b/wgengine/router/router_default.go index 1e675d1fc4d42..5078ad0b94c18 100644 --- a/wgengine/router/router_default.go +++ b/wgengine/router/router_default.go @@ -9,10 +9,10 @@ import ( "fmt" "runtime" - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" ) func newUserspaceRouter(logf logger.Logf, tunDev tun.Device, netMon *netmon.Monitor, health *health.Tracker) (Router, error) { diff --git a/wgengine/router/router_fake.go b/wgengine/router/router_fake.go index 549867ecaa342..e8c7d15a839dd 100644 --- a/wgengine/router/router_fake.go +++ b/wgengine/router/router_fake.go @@ -4,7 +4,7 @@ package router import ( - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/logger" ) // NewFake returns a Router that does nothing when called and always diff --git a/wgengine/router/router_freebsd.go b/wgengine/router/router_freebsd.go index 40523b4fd43ec..3bbae8ffdd6cf 100644 --- a/wgengine/router/router_freebsd.go +++ b/wgengine/router/router_freebsd.go @@ -4,10 +4,10 @@ package router import ( - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" ) // For now this router only supports the userspace WireGuard implementations. diff --git a/wgengine/router/router_linux.go b/wgengine/router/router_linux.go index 2af73e26d2f28..1da59d6b976ea 100644 --- a/wgengine/router/router_linux.go +++ b/wgengine/router/router_linux.go @@ -16,20 +16,20 @@ import ( "syscall" "time" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/opt" + "github.com/sagernet/tailscale/types/preftype" + "github.com/sagernet/tailscale/util/linuxfw" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/tailscale/version/distro" + "github.com/sagernet/wireguard-go/tun" "github.com/tailscale/netlink" - "github.com/tailscale/wireguard-go/tun" "go4.org/netipx" "golang.org/x/sys/unix" "golang.org/x/time/rate" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" - "tailscale.com/types/opt" - "tailscale.com/types/preftype" - "tailscale.com/util/linuxfw" - "tailscale.com/util/multierr" - "tailscale.com/version/distro" ) const ( diff --git a/wgengine/router/router_linux_test.go b/wgengine/router/router_linux_test.go deleted file mode 100644 index dce69550d909a..0000000000000 --- a/wgengine/router/router_linux_test.go +++ /dev/null @@ -1,1233 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package router - -import ( - "errors" - "fmt" - "math/rand" - "net/netip" - "os" - "reflect" - "regexp" - "slices" - "sort" - "strings" - "sync" - "sync/atomic" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/tailscale/netlink" - "github.com/tailscale/wireguard-go/tun" - "go4.org/netipx" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/net/tsaddr" - "tailscale.com/tstest" - "tailscale.com/types/logger" - "tailscale.com/util/linuxfw" -) - -func TestRouterStates(t *testing.T) { - basic := ` -ip rule add -4 pref 5210 fwmark 0x80000/0xff0000 table main -ip rule add -4 pref 5230 fwmark 0x80000/0xff0000 table default -ip rule add -4 pref 5250 fwmark 0x80000/0xff0000 type unreachable -ip rule add -4 pref 5270 table 52 -ip rule add -6 pref 5210 fwmark 0x80000/0xff0000 table main -ip rule add -6 pref 5230 fwmark 0x80000/0xff0000 table default -ip rule add -6 pref 5250 fwmark 0x80000/0xff0000 type unreachable -ip rule add -6 pref 5270 table 52 -` - states := []struct { - name string - in *Config - want string - }{ - { - name: "no config", - in: nil, - want: ` -up` + basic, - }, - { - name: "local addr only", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.103/10"), - NetfilterMode: netfilterOff, - }, - want: ` -up -ip addr add 100.101.102.103/10 dev tailscale0` + basic, - }, - - { - name: "addr and routes", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.103/10"), - Routes: mustCIDRs("100.100.100.100/32", "192.168.16.0/24"), - NetfilterMode: netfilterOff, - }, - want: ` -up -ip addr add 100.101.102.103/10 dev tailscale0 -ip route add 100.100.100.100/32 dev tailscale0 table 52 -ip route add 192.168.16.0/24 dev tailscale0 table 52` + basic, - }, - - { - name: "addr and routes and subnet routes", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.103/10"), - Routes: mustCIDRs("100.100.100.100/32", "192.168.16.0/24"), - SubnetRoutes: mustCIDRs("200.0.0.0/8"), - NetfilterMode: netfilterOff, - }, - want: ` -up -ip addr add 100.101.102.103/10 dev tailscale0 -ip route add 100.100.100.100/32 dev tailscale0 table 52 -ip route add 192.168.16.0/24 dev tailscale0 table 52` + basic, - }, - - { - name: "addr and routes and subnet routes with netfilter", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"), - SubnetRoutes: mustCIDRs("200.0.0.0/8"), - SNATSubnetRoutes: true, - StatefulFiltering: true, - NetfilterMode: netfilterOn, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 10.0.0.0/8 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic + - `v4/filter/FORWARD -j ts-forward -v4/filter/INPUT -j ts-input -v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -m conntrack ! --ctstate ESTABLISHED,RELATED -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v4/nat/POSTROUTING -j ts-postrouting -v4/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE -v6/filter/FORWARD -j ts-forward -v6/filter/INPUT -j ts-input -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -m conntrack ! --ctstate ESTABLISHED,RELATED -j DROP -v6/filter/ts-forward -o tailscale0 -j ACCEPT -v6/nat/POSTROUTING -j ts-postrouting -v6/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE -`, - }, - { - name: "addr and routes and subnet routes with netfilter but no stateful filtering", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"), - SubnetRoutes: mustCIDRs("200.0.0.0/8"), - SNATSubnetRoutes: true, - StatefulFiltering: false, - NetfilterMode: netfilterOn, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 10.0.0.0/8 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic + - `v4/filter/FORWARD -j ts-forward -v4/filter/INPUT -j ts-input -v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v4/nat/POSTROUTING -j ts-postrouting -v4/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE -v6/filter/FORWARD -j ts-forward -v6/filter/INPUT -j ts-input -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -j ACCEPT -v6/nat/POSTROUTING -j ts-postrouting -v6/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE -`, - }, - { - name: "addr and routes with netfilter", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"), - NetfilterMode: netfilterOn, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 10.0.0.0/8 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic + - `v4/filter/FORWARD -j ts-forward -v4/filter/INPUT -j ts-input -v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v4/nat/POSTROUTING -j ts-postrouting -v6/filter/FORWARD -j ts-forward -v6/filter/INPUT -j ts-input -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -j ACCEPT -v6/nat/POSTROUTING -j ts-postrouting -`, - }, - - { - name: "addr and routes and subnet routes with netfilter but no SNAT", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"), - SubnetRoutes: mustCIDRs("200.0.0.0/8"), - SNATSubnetRoutes: false, - NetfilterMode: netfilterOn, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 10.0.0.0/8 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic + - `v4/filter/FORWARD -j ts-forward -v4/filter/INPUT -j ts-input -v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v4/nat/POSTROUTING -j ts-postrouting -v6/filter/FORWARD -j ts-forward -v6/filter/INPUT -j ts-input -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -j ACCEPT -v6/nat/POSTROUTING -j ts-postrouting -`, - }, - { - name: "addr and routes with netfilter", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"), - NetfilterMode: netfilterOn, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 10.0.0.0/8 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic + - `v4/filter/FORWARD -j ts-forward -v4/filter/INPUT -j ts-input -v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v4/nat/POSTROUTING -j ts-postrouting -v6/filter/FORWARD -j ts-forward -v6/filter/INPUT -j ts-input -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -j ACCEPT -v6/nat/POSTROUTING -j ts-postrouting -`, - }, - - { - name: "addr and routes with half netfilter", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"), - NetfilterMode: netfilterNoDivert, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 10.0.0.0/8 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic + - `v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -j ACCEPT -`, - }, - { - name: "addr and routes with netfilter2", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"), - NetfilterMode: netfilterOn, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 10.0.0.0/8 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic + - `v4/filter/FORWARD -j ts-forward -v4/filter/INPUT -j ts-input -v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v4/nat/POSTROUTING -j ts-postrouting -v6/filter/FORWARD -j ts-forward -v6/filter/INPUT -j ts-input -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -j ACCEPT -v6/nat/POSTROUTING -j ts-postrouting -`, - }, - { - name: "addr, routes, and local routes with netfilter", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "0.0.0.0/0"), - LocalRoutes: mustCIDRs("10.0.0.0/8"), - NetfilterMode: netfilterOn, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 0.0.0.0/0 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52 -ip route add throw 10.0.0.0/8 table 52` + basic + - `v4/filter/FORWARD -j ts-forward -v4/filter/INPUT -j ts-input -v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP -v4/filter/ts-forward -o tailscale0 -j ACCEPT -v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT -v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN -v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP -v4/nat/POSTROUTING -j ts-postrouting -v6/filter/FORWARD -j ts-forward -v6/filter/INPUT -j ts-input -v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000 -v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT -v6/filter/ts-forward -o tailscale0 -j ACCEPT -v6/nat/POSTROUTING -j ts-postrouting -`, - }, - { - name: "addr, routes, and local routes with no netfilter", - in: &Config{ - LocalAddrs: mustCIDRs("100.101.102.104/10"), - Routes: mustCIDRs("100.100.100.100/32", "0.0.0.0/0"), - LocalRoutes: mustCIDRs("10.0.0.0/8", "192.168.0.0/24"), - NetfilterMode: netfilterOff, - }, - want: ` -up -ip addr add 100.101.102.104/10 dev tailscale0 -ip route add 0.0.0.0/0 dev tailscale0 table 52 -ip route add 100.100.100.100/32 dev tailscale0 table 52 -ip route add throw 10.0.0.0/8 table 52 -ip route add throw 192.168.0.0/24 table 52` + basic, - }, - } - - mon, err := netmon.New(logger.Discard) - if err != nil { - t.Fatal(err) - } - mon.Start() - defer mon.Close() - - fake := NewFakeOS(t) - ht := new(health.Tracker) - router, err := newUserspaceRouterAdvanced(t.Logf, "tailscale0", mon, fake, ht) - router.(*linuxRouter).nfr = fake.nfr - if err != nil { - t.Fatalf("failed to create router: %v", err) - } - if err := router.Up(); err != nil { - t.Fatalf("failed to up router: %v", err) - } - - testState := func(t *testing.T, i int) { - t.Helper() - if err := router.Set(states[i].in); err != nil { - t.Fatalf("failed to set router config: %v", err) - } - got := fake.String() - want := adjustFwmask(t, strings.TrimSpace(states[i].want)) - if diff := cmp.Diff(got, want); diff != "" { - t.Fatalf("unexpected OS state (-got+want):\n%s", diff) - } - } - - for i, state := range states { - t.Run(state.name, func(t *testing.T) { testState(t, i) }) - } - - // Cycle through a bunch of states in pseudorandom order, to - // verify that we transition cleanly from state to state no matter - // the order. - for randRun := 0; randRun < 5*len(states); randRun++ { - i := rand.Intn(len(states)) - state := states[i] - t.Run(state.name, func(t *testing.T) { testState(t, i) }) - } -} - -type fakeIPTablesRunner struct { - t *testing.T - ipt4 map[string][]string - ipt6 map[string][]string - //we always assume ipv6 and ipv6 nat are enabled when testing -} - -func newIPTablesRunner(t *testing.T) linuxfw.NetfilterRunner { - return &fakeIPTablesRunner{ - t: t, - ipt4: map[string][]string{ - "filter/INPUT": nil, - "filter/OUTPUT": nil, - "filter/FORWARD": nil, - "nat/PREROUTING": nil, - "nat/OUTPUT": nil, - "nat/POSTROUTING": nil, - }, - ipt6: map[string][]string{ - "filter/INPUT": nil, - "filter/OUTPUT": nil, - "filter/FORWARD": nil, - "nat/PREROUTING": nil, - "nat/OUTPUT": nil, - "nat/POSTROUTING": nil, - }, - } -} - -func insertRule(n *fakeIPTablesRunner, curIPT map[string][]string, chain, newRule string) error { - // Get current rules for filter/ts-input chain with according IP version - curTSInputRules, ok := curIPT[chain] - if !ok { - n.t.Fatalf("no %s chain exists", chain) - return fmt.Errorf("no %s chain exists", chain) - } - - // Add new rule to top of filter/ts-input - curTSInputRules = append(curTSInputRules, "") - copy(curTSInputRules[1:], curTSInputRules) - curTSInputRules[0] = newRule - curIPT[chain] = curTSInputRules - return nil -} - -func insertRuleAt(n *fakeIPTablesRunner, curIPT map[string][]string, chain string, pos int, newRule string) { - rules, ok := curIPT[chain] - if !ok { - n.t.Fatalf("no %s chain exists", chain) - } - - // If the given position is after the end of the chain, error. - if pos > len(rules) { - n.t.Fatalf("position %d > len(chain %s) %d", pos, chain, len(chain)) - } - - // Insert the rule at the given position - rules = slices.Insert(rules, pos, newRule) - curIPT[chain] = rules -} - -func appendRule(n *fakeIPTablesRunner, curIPT map[string][]string, chain, newRule string) error { - // Get current rules for filter/ts-input chain with according IP version - curTSInputRules, ok := curIPT[chain] - if !ok { - n.t.Fatalf("no %s chain exists", chain) - return fmt.Errorf("no %s chain exists", chain) - } - - // Add new rule to end of filter/ts-input - curTSInputRules = append(curTSInputRules, newRule) - curIPT[chain] = curTSInputRules - return nil -} - -func deleteRule(n *fakeIPTablesRunner, curIPT map[string][]string, chain, delRule string) error { - // Get current rules for filter/ts-input chain with according IP version - curTSInputRules, ok := curIPT[chain] - if !ok { - n.t.Fatalf("no %s chain exists", chain) - return fmt.Errorf("no %s chain exists", chain) - } - - // Remove rule from filter/ts-input - for i, rule := range curTSInputRules { - if rule == delRule { - curTSInputRules = append(curTSInputRules[:i], curTSInputRules[i+1:]...) - break - } - } - curIPT[chain] = curTSInputRules - return nil -} - -func (n *fakeIPTablesRunner) AddLoopbackRule(addr netip.Addr) error { - curIPT := n.ipt4 - if addr.Is6() { - curIPT = n.ipt6 - } - newRule := fmt.Sprintf("-i lo -s %s -j ACCEPT", addr.String()) - - return insertRule(n, curIPT, "filter/ts-input", newRule) -} - -func (n *fakeIPTablesRunner) AddBase(tunname string) error { - if err := n.addBase4(tunname); err != nil { - return err - } - if n.HasIPV6() { - if err := n.addBase6(tunname); err != nil { - return err - } - } - return nil -} - -func (n *fakeIPTablesRunner) AddDNATRule(origDst, dst netip.Addr) error { - return errors.New("not implemented") -} - -func (n *fakeIPTablesRunner) DNATWithLoadBalancer(netip.Addr, []netip.Addr) error { - return errors.New("not implemented") -} - -func (n *fakeIPTablesRunner) EnsureSNATForDst(src, dst netip.Addr) error { - return errors.New("not implemented") -} - -func (n *fakeIPTablesRunner) DNATNonTailscaleTraffic(exemptInterface string, dst netip.Addr) error { - return errors.New("not implemented") -} -func (n *fakeIPTablesRunner) EnsurePortMapRuleForSvc(svc, tun string, targetIP netip.Addr, pm linuxfw.PortMap) error { - return errors.New("not implemented") -} - -func (n *fakeIPTablesRunner) DeletePortMapRuleForSvc(svc, tun string, targetIP netip.Addr, pm linuxfw.PortMap) error { - return errors.New("not implemented") -} - -func (n *fakeIPTablesRunner) DeleteSvc(svc, tun string, targetIPs []netip.Addr, pm []linuxfw.PortMap) error { - return errors.New("not implemented") -} - -func (n *fakeIPTablesRunner) ClampMSSToPMTU(tun string, addr netip.Addr) error { - return errors.New("not implemented") -} - -func (n *fakeIPTablesRunner) addBase4(tunname string) error { - curIPT := n.ipt4 - newRules := []struct{ chain, rule string }{ - {"filter/ts-input", fmt.Sprintf("! -i %s -s %s -j RETURN", tunname, tsaddr.ChromeOSVMRange().String())}, - {"filter/ts-input", fmt.Sprintf("! -i %s -s %s -j DROP", tunname, tsaddr.CGNATRange().String())}, - {"filter/ts-forward", fmt.Sprintf("-i %s -j MARK --set-mark %s/%s", tunname, linuxfw.TailscaleSubnetRouteMark, linuxfw.TailscaleFwmarkMask)}, - {"filter/ts-forward", fmt.Sprintf("-m mark --mark %s/%s -j ACCEPT", linuxfw.TailscaleSubnetRouteMark, linuxfw.TailscaleFwmarkMask)}, - {"filter/ts-forward", fmt.Sprintf("-o %s -s %s -j DROP", tunname, tsaddr.CGNATRange().String())}, - {"filter/ts-forward", fmt.Sprintf("-o %s -j ACCEPT", tunname)}, - } - for _, rule := range newRules { - if err := appendRule(n, curIPT, rule.chain, rule.rule); err != nil { - return fmt.Errorf("add rule %q to chain %q: %w", rule.rule, rule.chain, err) - } - } - return nil -} - -func (n *fakeIPTablesRunner) addBase6(tunname string) error { - curIPT := n.ipt6 - newRules := []struct{ chain, rule string }{ - {"filter/ts-forward", fmt.Sprintf("-i %s -j MARK --set-mark %s/%s", tunname, linuxfw.TailscaleSubnetRouteMark, linuxfw.TailscaleFwmarkMask)}, - {"filter/ts-forward", fmt.Sprintf("-m mark --mark %s/%s -j ACCEPT", linuxfw.TailscaleSubnetRouteMark, linuxfw.TailscaleFwmarkMask)}, - {"filter/ts-forward", fmt.Sprintf("-o %s -j ACCEPT", tunname)}, - } - for _, rule := range newRules { - if err := appendRule(n, curIPT, rule.chain, rule.rule); err != nil { - return fmt.Errorf("add rule %q to chain %q: %w", rule.rule, rule.chain, err) - } - } - return nil -} - -func (n *fakeIPTablesRunner) DelLoopbackRule(addr netip.Addr) error { - curIPT := n.ipt4 - if addr.Is6() { - curIPT = n.ipt6 - } - - delRule := fmt.Sprintf("-i lo -s %s -j ACCEPT", addr.String()) - - return deleteRule(n, curIPT, "filter/ts-input", delRule) -} - -func (n *fakeIPTablesRunner) AddHooks() error { - newRules := []struct{ chain, rule string }{ - {"filter/INPUT", "-j ts-input"}, - {"filter/FORWARD", "-j ts-forward"}, - {"nat/POSTROUTING", "-j ts-postrouting"}, - } - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - for _, r := range newRules { - if err := insertRule(n, ipt, r.chain, r.rule); err != nil { - return err - } - } - } - return nil -} - -func (n *fakeIPTablesRunner) DelHooks(logf logger.Logf) error { - delRules := []struct{ chain, rule string }{ - {"filter/INPUT", "-j ts-input"}, - {"filter/FORWARD", "-j ts-forward"}, - {"nat/POSTROUTING", "-j ts-postrouting"}, - } - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - for _, r := range delRules { - if err := deleteRule(n, ipt, r.chain, r.rule); err != nil { - return err - } - } - } - return nil -} - -func (n *fakeIPTablesRunner) AddChains() error { - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - for _, chain := range []string{"filter/ts-input", "filter/ts-forward", "nat/ts-postrouting"} { - ipt[chain] = nil - } - } - return nil -} - -func (n *fakeIPTablesRunner) DelChains() error { - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - for chain := range ipt { - if strings.HasPrefix(chain, "filter/ts-") || strings.HasPrefix(chain, "nat/ts-") { - delete(ipt, chain) - } - } - } - return nil -} - -func (n *fakeIPTablesRunner) DelBase() error { - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - for _, chain := range []string{"filter/ts-input", "filter/ts-forward", "nat/ts-postrouting"} { - ipt[chain] = nil - } - } - return nil -} - -func (n *fakeIPTablesRunner) AddSNATRule() error { - newRule := fmt.Sprintf("-m mark --mark %s/%s -j MASQUERADE", linuxfw.TailscaleSubnetRouteMark, linuxfw.TailscaleFwmarkMask) - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - if err := appendRule(n, ipt, "nat/ts-postrouting", newRule); err != nil { - return err - } - } - return nil -} - -func (n *fakeIPTablesRunner) DelSNATRule() error { - delRule := fmt.Sprintf("-m mark --mark %s/%s -j MASQUERADE", linuxfw.TailscaleSubnetRouteMark, linuxfw.TailscaleFwmarkMask) - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - if err := deleteRule(n, ipt, "nat/ts-postrouting", delRule); err != nil { - return err - } - } - return nil -} - -func (n *fakeIPTablesRunner) AddStatefulRule(tunname string) error { - newRule := fmt.Sprintf("-o %s -m conntrack ! --ctstate ESTABLISHED,RELATED -j DROP", tunname) - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - // Mimic the real runner and insert after the 'accept all' rule - wantRule := fmt.Sprintf("-o %s -j ACCEPT", tunname) - - const chain = "filter/ts-forward" - pos := slices.Index(ipt[chain], wantRule) - if pos < 0 { - n.t.Fatalf("no rule %q in chain %s", wantRule, chain) - } - - insertRuleAt(n, ipt, chain, pos, newRule) - } - return nil -} - -func (n *fakeIPTablesRunner) DelStatefulRule(tunname string) error { - delRule := fmt.Sprintf("-o %s -m conntrack ! --ctstate ESTABLISHED,RELATED -j DROP", tunname) - for _, ipt := range []map[string][]string{n.ipt4, n.ipt6} { - if err := deleteRule(n, ipt, "filter/ts-forward", delRule); err != nil { - return err - } - } - return nil -} - -// buildMagicsockPortRule builds a fake rule to use in AddMagicsockPortRule and -// DelMagicsockPortRule below. -func buildMagicsockPortRule(port uint16) string { - return fmt.Sprintf("-p udp --dport %v -j ACCEPT", port) -} - -// AddMagicsockPortRule implements the NetfilterRunner interface, but stores -// rules in fakeIPTablesRunner's internal maps rather than actually calling out -// to iptables. This is mainly to test the linux router implementation. -func (n *fakeIPTablesRunner) AddMagicsockPortRule(port uint16, network string) error { - var ipt map[string][]string - switch network { - case "udp4": - ipt = n.ipt4 - case "udp6": - ipt = n.ipt6 - default: - return fmt.Errorf("unsupported network %s", network) - } - - rule := buildMagicsockPortRule(port) - - if err := appendRule(n, ipt, "filter/ts-input", rule); err != nil { - return err - } - - return nil -} - -// DelMagicsockPortRule implements the NetfilterRunner interface, but removes -// rules from fakeIPTablesRunner's internal maps rather than actually calling -// out to iptables. This is mainly to test the linux router implementation. -func (n *fakeIPTablesRunner) DelMagicsockPortRule(port uint16, network string) error { - var ipt map[string][]string - switch network { - case "udp4": - ipt = n.ipt4 - case "udp6": - ipt = n.ipt6 - default: - return fmt.Errorf("unsupported network %s", network) - } - - rule := buildMagicsockPortRule(port) - - if err := deleteRule(n, ipt, "filter/ts-input", rule); err != nil { - return err - } - - return nil -} - -func (n *fakeIPTablesRunner) HasIPV6() bool { return true } -func (n *fakeIPTablesRunner) HasIPV6NAT() bool { return true } -func (n *fakeIPTablesRunner) HasIPV6Filter() bool { return true } - -// fakeOS implements commandRunner and provides v4 and v6 -// netfilterRunners, but captures changes without touching the OS. -type fakeOS struct { - t *testing.T - up bool - ips []string - routes []string - rules []string - //This test tests on the router level, so we will not bother - //with using iptables or nftables, chose the simpler one. - nfr linuxfw.NetfilterRunner -} - -func NewFakeOS(t *testing.T) *fakeOS { - return &fakeOS{ - t: t, - nfr: newIPTablesRunner(t), - } -} - -var errExec = errors.New("execution failed") - -func (o *fakeOS) String() string { - var b strings.Builder - if o.up { - b.WriteString("up\n") - } else { - b.WriteString("down\n") - } - - for _, ip := range o.ips { - fmt.Fprintf(&b, "ip addr add %s\n", ip) - } - - for _, route := range o.routes { - fmt.Fprintf(&b, "ip route add %s\n", route) - } - - for _, rule := range o.rules { - fmt.Fprintf(&b, "ip rule add %s\n", rule) - } - - var chains []string - for chain := range o.nfr.(*fakeIPTablesRunner).ipt4 { - chains = append(chains, chain) - } - sort.Strings(chains) - for _, chain := range chains { - for _, rule := range o.nfr.(*fakeIPTablesRunner).ipt4[chain] { - fmt.Fprintf(&b, "v4/%s %s\n", chain, rule) - } - } - - chains = nil - for chain := range o.nfr.(*fakeIPTablesRunner).ipt6 { - chains = append(chains, chain) - } - sort.Strings(chains) - for _, chain := range chains { - for _, rule := range o.nfr.(*fakeIPTablesRunner).ipt6[chain] { - fmt.Fprintf(&b, "v6/%s %s\n", chain, rule) - } - } - - return b.String()[:len(b.String())-1] -} - -func (o *fakeOS) run(args ...string) error { - unexpected := func() error { - o.t.Errorf("unexpected invocation %q", strings.Join(args, " ")) - return errors.New("unrecognized invocation") - } - if args[0] != "ip" { - return unexpected() - } - - if len(args) == 2 && args[1] == "rule" { - // naked invocation of `ip rule` is a feature test. Return - // successfully. - return nil - } - - family := "" - rest := strings.Join(args[3:], " ") - if args[1] == "-4" || args[1] == "-6" { - family = args[1] - copy(args[1:], args[2:]) - args = args[:len(args)-1] - rest = family + " " + strings.Join(args[3:], " ") - } - - var l *[]string - switch args[1] { - case "link": - got := strings.Join(args[2:], " ") - switch got { - case "set dev tailscale0 up": - o.up = true - case "set dev tailscale0 down": - o.up = false - default: - return unexpected() - } - return nil - case "addr": - l = &o.ips - case "route": - l = &o.routes - case "rule": - l = &o.rules - default: - return unexpected() - } - - switch args[2] { - case "add": - for _, el := range *l { - if el == rest { - o.t.Errorf("can't add %q, already present", rest) - return errors.New("already exists") - } - } - *l = append(*l, rest) - sort.Strings(*l) - case "del": - found := false - for i, el := range *l { - if el == rest { - found = true - *l = append((*l)[:i], (*l)[i+1:]...) - break - } - } - if !found { - o.t.Logf("note: can't delete %q, not present", rest) - // 'ip rule del' exits with code 2 when a row is - // missing. We don't want to consider that an error, - // for cleanup purposes. - - // TODO(apenwarr): this is a hack. - // I'd like to return an exec.ExitError(2) here, but - // I can't, because the ExitCode is implemented in - // os.ProcessState, which is an opaque object I can't - // instantiate or modify. Go's 75 levels of abstraction - // between me and an 8-bit int are really paying off - // here, as you can see. - return errors.New("exitcode:2") - } - default: - return unexpected() - } - - return nil -} - -func (o *fakeOS) output(args ...string) ([]byte, error) { - want := "ip rule list priority 10000" - got := strings.Join(args, " ") - if got != want { - o.t.Errorf("unexpected command that wants output: %v", got) - return nil, errExec - } - - var ret []string - for _, rule := range o.rules { - if strings.Contains(rule, "10000") { - ret = append(ret, rule) - } - } - return []byte(strings.Join(ret, "\n")), nil -} - -var tunTestNum int64 - -func createTestTUN(t *testing.T) tun.Device { - const minimalMTU = 1280 - tunName := fmt.Sprintf("tuntest%d", atomic.AddInt64(&tunTestNum, 1)) - tun, err := tun.CreateTUN(tunName, minimalMTU) - if err != nil { - t.Fatalf("CreateTUN(%q): %v", tunName, err) - } - return tun -} - -type linuxTest struct { - tun tun.Device - mon *netmon.Monitor - r *linuxRouter - logOutput tstest.MemLogger -} - -func (lt *linuxTest) Close() error { - if lt.tun != nil { - lt.tun.Close() - } - if lt.mon != nil { - lt.mon.Close() - } - return nil -} - -func newLinuxRootTest(t *testing.T) *linuxTest { - if os.Getuid() != 0 { - t.Skip("test requires root") - } - - lt := new(linuxTest) - lt.tun = createTestTUN(t) - - logf := lt.logOutput.Logf - - mon, err := netmon.New(logger.Discard) - if err != nil { - lt.Close() - t.Fatal(err) - } - mon.Start() - lt.mon = mon - - r, err := newUserspaceRouter(logf, lt.tun, mon, nil) - if err != nil { - lt.Close() - t.Fatal(err) - } - lr := r.(*linuxRouter) - if err := lr.upInterface(); err != nil { - lt.Close() - t.Fatal(err) - } - lt.r = lr - return lt -} - -func TestDelRouteIdempotent(t *testing.T) { - lt := newLinuxRootTest(t) - defer lt.Close() - - for _, s := range []string{ - "192.0.2.0/24", // RFC 5737 - "2001:DB8::/32", // RFC 3849 - } { - cidr := netip.MustParsePrefix(s) - if err := lt.r.addRoute(cidr); err != nil { - t.Error(err) - continue - } - for i := range 2 { - if err := lt.r.delRoute(cidr); err != nil { - t.Errorf("delRoute(i=%d): %v", i, err) - } - } - } - - if t.Failed() { - out := lt.logOutput.String() - t.Logf("Log output:\n%s", out) - } -} - -func TestAddRemoveRules(t *testing.T) { - lt := newLinuxRootTest(t) - defer lt.Close() - r := lt.r - - step := func(name string, f func() error) { - t.Logf("Doing %v ...", name) - if err := f(); err != nil { - t.Fatalf("%s: %v", name, err) - } - rules, err := netlink.RuleList(netlink.FAMILY_ALL) - if err != nil { - t.Fatal(err) - } - for _, r := range rules { - if r.Priority >= 5000 && r.Priority <= 5999 { - t.Logf("Rule: %+v", r) - } - } - - } - - step("init_del_and_add", r.addIPRules) - step("dup_add", r.justAddIPRules) - step("del", r.delIPRules) - step("dup_del", r.delIPRules) - -} - -func TestDebugListLinks(t *testing.T) { - links, err := netlink.LinkList() - if err != nil { - t.Fatal(err) - } - for _, ln := range links { - t.Logf("Link: %+v", ln) - } -} - -func TestDebugListRoutes(t *testing.T) { - // We need to pass a non-nil route to RouteListFiltered, along - // with the netlink.RT_FILTER_TABLE bit set in the filter - // mask, otherwise it ignores non-main routes. - filter := &netlink.Route{} - routes, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_TABLE) - if err != nil { - t.Fatal(err) - } - for _, r := range routes { - t.Logf("Route: %+v", r) - } -} - -var famName = map[int]string{ - netlink.FAMILY_ALL: "all", - netlink.FAMILY_V4: "v4", - netlink.FAMILY_V6: "v6", -} - -func TestDebugListRules(t *testing.T) { - for _, fam := range []int{netlink.FAMILY_V4, netlink.FAMILY_V6, netlink.FAMILY_ALL} { - t.Run(famName[fam], func(t *testing.T) { - rules, err := netlink.RuleList(fam) - if err != nil { - t.Skipf("skip; RuleList fails with: %v", err) - } - for _, r := range rules { - t.Logf("Rule: %+v", r) - } - }) - } -} - -func TestCheckIPRuleSupportsV6(t *testing.T) { - err := linuxfw.CheckIPRuleSupportsV6(t.Logf) - if err != nil && os.Getuid() != 0 { - t.Skipf("skipping, error when not root: %v", err) - } - // Just log it. For interactive testing only. - // Some machines running our tests might not have IPv6. - t.Logf("Got: %v", err) -} - -func TestBusyboxParseVersion(t *testing.T) { - input := `BusyBox v1.34.1 (2022-09-01 16:10:29 UTC) multi-call binary. -BusyBox is copyrighted by many authors between 1998-2015. -Licensed under GPLv2. See source distribution for detailed -copyright notices. - -Usage: busybox [function [arguments]...] - or: busybox --list[-full] - or: busybox --show SCRIPT - or: busybox --install [-s] [DIR] - or: function [arguments]... - - BusyBox is a multi-call binary that combines many common Unix - utilities into a single executable. Most people will create a - link to busybox for each function they wish to use and BusyBox - will act like whatever it was invoked as. -` - - v1, v2, v3, err := busyboxParseVersion(input) - if err != nil { - t.Fatalf("busyboxParseVersion() failed: %v", err) - } - - if got, want := fmt.Sprintf("%d.%d.%d", v1, v2, v3), "1.34.1"; got != want { - t.Errorf("version = %q, want %q", got, want) - } -} - -func TestCIDRDiff(t *testing.T) { - pfx := func(p ...string) []netip.Prefix { - var ret []netip.Prefix - for _, s := range p { - ret = append(ret, netip.MustParsePrefix(s)) - } - return ret - } - tests := []struct { - old []netip.Prefix - new []netip.Prefix - wantAdd []netip.Prefix - wantDel []netip.Prefix - final []netip.Prefix - }{ - { - old: nil, - new: pfx("1.1.1.1/32"), - wantAdd: pfx("1.1.1.1/32"), - final: pfx("1.1.1.1/32"), - }, - { - old: pfx("1.1.1.1/32"), - new: pfx("1.1.1.1/32"), - final: pfx("1.1.1.1/32"), - }, - { - old: pfx("1.1.1.1/32", "2.3.4.5/32"), - new: pfx("1.1.1.1/32"), - wantDel: pfx("2.3.4.5/32"), - final: pfx("1.1.1.1/32"), - }, - { - old: pfx("1.1.1.1/32", "2.3.4.5/32"), - new: pfx("1.0.0.0/32", "3.4.5.6/32"), - wantDel: pfx("1.1.1.1/32", "2.3.4.5/32"), - wantAdd: pfx("1.0.0.0/32", "3.4.5.6/32"), - final: pfx("1.0.0.0/32", "3.4.5.6/32"), - }, - } - for _, tc := range tests { - om := make(map[netip.Prefix]bool) - for _, p := range tc.old { - om[p] = true - } - var added []netip.Prefix - var deleted []netip.Prefix - fm, err := cidrDiff("test", om, tc.new, func(p netip.Prefix) error { - if len(deleted) > 0 { - t.Error("delete called before add") - } - added = append(added, p) - return nil - }, func(p netip.Prefix) error { - deleted = append(deleted, p) - return nil - }, t.Logf) - if err != nil { - t.Fatal(err) - } - slices.SortFunc(added, netipx.ComparePrefix) - slices.SortFunc(deleted, netipx.ComparePrefix) - if !reflect.DeepEqual(added, tc.wantAdd) { - t.Errorf("added = %v, want %v", added, tc.wantAdd) - } - if !reflect.DeepEqual(deleted, tc.wantDel) { - t.Errorf("deleted = %v, want %v", deleted, tc.wantDel) - } - - // Check that the final state is correct. - if len(fm) != len(tc.final) { - t.Fatalf("final state = %v, want %v", fm, tc.final) - } - for _, p := range tc.final { - if !fm[p] { - t.Errorf("final state = %v, want %v", fm, tc.final) - } - } - } -} - -var ( - fwmaskSupported bool - fwmaskSupportedOnce sync.Once - fwmaskAdjustRe = regexp.MustCompile(`(?m)(fwmark 0x[0-9a-f]+)/0x[0-9a-f]+`) -) - -// adjustFwmask removes the "/0xmask" string from fwmask stanzas if the -// installed 'ip' binary does not support that format. -func adjustFwmask(t *testing.T, s string) string { - t.Helper() - fwmaskSupportedOnce.Do(func() { - fwmaskSupported, _ = ipCmdSupportsFwmask() - }) - if fwmaskSupported { - return s - } - - return fwmaskAdjustRe.ReplaceAllString(s, "$1") -} diff --git a/wgengine/router/router_openbsd.go b/wgengine/router/router_openbsd.go index 6fdd47ac94c0e..04e4b1d4b8a8d 100644 --- a/wgengine/router/router_openbsd.go +++ b/wgengine/router/router_openbsd.go @@ -10,12 +10,12 @@ import ( "net/netip" "os/exec" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/wireguard-go/tun" "go4.org/netipx" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" - "tailscale.com/util/set" ) // For now this router only supports the WireGuard userspace implementation. diff --git a/wgengine/router/router_test.go b/wgengine/router/router_test.go deleted file mode 100644 index 8842173d7e4b4..0000000000000 --- a/wgengine/router/router_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package router - -import ( - "net/netip" - "reflect" - "testing" - - "tailscale.com/types/preftype" -) - -//lint:ignore U1000 used in Windows/Linux tests only -func mustCIDRs(ss ...string) []netip.Prefix { - var ret []netip.Prefix - for _, s := range ss { - ret = append(ret, netip.MustParsePrefix(s)) - } - return ret -} - -func TestConfigEqual(t *testing.T) { - testedFields := []string{ - "LocalAddrs", "Routes", "LocalRoutes", "NewMTU", - "SubnetRoutes", "SNATSubnetRoutes", "StatefulFiltering", - "NetfilterMode", "NetfilterKind", - } - configType := reflect.TypeFor[Config]() - configFields := []string{} - for i := range configType.NumField() { - configFields = append(configFields, configType.Field(i).Name) - } - if !reflect.DeepEqual(configFields, testedFields) { - t.Errorf("Config.Equal check might be out of sync\nfields: %q\nhandled: %q\n", - configFields, testedFields) - } - - nets := func(strs ...string) (ns []netip.Prefix) { - for _, s := range strs { - n, err := netip.ParsePrefix(s) - if err != nil { - panic(err) - } - ns = append(ns, n) - } - return ns - } - tests := []struct { - a, b *Config - want bool - }{ - { - nil, - nil, - true, - }, - { - &Config{}, - nil, - false, - }, - { - nil, - &Config{}, - false, - }, - { - &Config{}, - &Config{}, - true, - }, - - { - &Config{LocalAddrs: nets("100.1.27.82/32")}, - &Config{LocalAddrs: nets("100.2.19.82/32")}, - false, - }, - { - &Config{LocalAddrs: nets("100.1.27.82/32")}, - &Config{LocalAddrs: nets("100.1.27.82/32")}, - true, - }, - - { - &Config{Routes: nets("100.1.27.0/24")}, - &Config{Routes: nets("100.2.19.0/24")}, - false, - }, - { - &Config{Routes: nets("100.2.19.0/24")}, - &Config{Routes: nets("100.2.19.0/24")}, - true, - }, - - { - &Config{LocalRoutes: nets("100.1.27.0/24")}, - &Config{LocalRoutes: nets("100.2.19.0/24")}, - false, - }, - { - &Config{LocalRoutes: nets("100.1.27.0/24")}, - &Config{LocalRoutes: nets("100.1.27.0/24")}, - true, - }, - - { - &Config{SubnetRoutes: nets("100.1.27.0/24")}, - &Config{SubnetRoutes: nets("100.2.19.0/24")}, - false, - }, - { - &Config{SubnetRoutes: nets("100.1.27.0/24")}, - &Config{SubnetRoutes: nets("100.1.27.0/24")}, - true, - }, - - { - &Config{SNATSubnetRoutes: false}, - &Config{SNATSubnetRoutes: true}, - false, - }, - { - &Config{SNATSubnetRoutes: false}, - &Config{SNATSubnetRoutes: false}, - true, - }, - { - &Config{StatefulFiltering: false}, - &Config{StatefulFiltering: true}, - false, - }, - { - &Config{StatefulFiltering: false}, - &Config{StatefulFiltering: false}, - true, - }, - - { - &Config{NetfilterMode: preftype.NetfilterOff}, - &Config{NetfilterMode: preftype.NetfilterNoDivert}, - false, - }, - { - &Config{NetfilterMode: preftype.NetfilterNoDivert}, - &Config{NetfilterMode: preftype.NetfilterNoDivert}, - true, - }, - { - &Config{NewMTU: 0}, - &Config{NewMTU: 0}, - true, - }, - { - &Config{NewMTU: 1280}, - &Config{NewMTU: 0}, - false, - }, - } - for i, tt := range tests { - got := tt.a.Equal(tt.b) - if got != tt.want { - t.Errorf("%d. Equal = %v; want %v", i, got, tt.want) - } - } -} diff --git a/wgengine/router/router_userspace_bsd.go b/wgengine/router/router_userspace_bsd.go index 0b7e4f36aa6e5..2dddec0efadfe 100644 --- a/wgengine/router/router_userspace_bsd.go +++ b/wgengine/router/router_userspace_bsd.go @@ -12,13 +12,13 @@ import ( "os/exec" "runtime" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/wireguard-go/tun" "go4.org/netipx" - "tailscale.com/health" - "tailscale.com/net/netmon" - "tailscale.com/net/tsaddr" - "tailscale.com/types/logger" - "tailscale.com/version" ) type userspaceBSDRouter struct { diff --git a/wgengine/router/router_windows.go b/wgengine/router/router_windows.go index 64163660d7640..b48f02cc1be1b 100644 --- a/wgengine/router/router_windows.go +++ b/wgengine/router/router_windows.go @@ -19,14 +19,14 @@ import ( "syscall" "time" - "github.com/tailscale/wireguard-go/tun" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/logtail/backoff" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/wireguard-go/tun" "golang.org/x/sys/windows" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" - "tailscale.com/health" - "tailscale.com/logtail/backoff" - "tailscale.com/net/dns" - "tailscale.com/net/netmon" - "tailscale.com/types/logger" ) type winRouter struct { diff --git a/wgengine/router/router_windows_test.go b/wgengine/router/router_windows_test.go deleted file mode 100644 index 9989ddbc735a6..0000000000000 --- a/wgengine/router/router_windows_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package router - -import ( - "path/filepath" - "testing" -) - -func TestGetNetshPath(t *testing.T) { - ft := &firewallTweaker{ - logf: t.Logf, - } - path := ft.getNetshPath() - if !filepath.IsAbs(path) { - t.Errorf("expected absolute path for netsh.exe: %q", path) - } -} diff --git a/wgengine/userspace.go b/wgengine/userspace.go index 81f8000e0d557..dab0948305513 100644 --- a/wgengine/userspace.go +++ b/wgengine/userspace.go @@ -18,48 +18,48 @@ import ( "sync" "time" - "github.com/tailscale/wireguard-go/device" - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/control/controlknobs" - "tailscale.com/drive" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/dns" - "tailscale.com/net/flowtrack" - "tailscale.com/net/ipset" - "tailscale.com/net/netmon" - "tailscale.com/net/packet" - "tailscale.com/net/sockstats" - "tailscale.com/net/tsaddr" - "tailscale.com/net/tsdial" - "tailscale.com/net/tshttpproxy" - "tailscale.com/net/tstun" - "tailscale.com/syncs" - "tailscale.com/tailcfg" - "tailscale.com/tstime/mono" - "tailscale.com/types/dnstype" - "tailscale.com/types/ipproto" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/types/netmap" - "tailscale.com/types/views" - "tailscale.com/util/clientmetric" - "tailscale.com/util/deephash" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/util/testenv" - "tailscale.com/util/usermetric" - "tailscale.com/version" - "tailscale.com/wgengine/capture" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/magicsock" - "tailscale.com/wgengine/netlog" - "tailscale.com/wgengine/netstack/gro" - "tailscale.com/wgengine/router" - "tailscale.com/wgengine/wgcfg" - "tailscale.com/wgengine/wgint" - "tailscale.com/wgengine/wglog" + "github.com/sagernet/tailscale/control/controlknobs" + "github.com/sagernet/tailscale/drive" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/health" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/net/flowtrack" + "github.com/sagernet/tailscale/net/ipset" + "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/packet" + "github.com/sagernet/tailscale/net/sockstats" + "github.com/sagernet/tailscale/net/tsaddr" + "github.com/sagernet/tailscale/net/tsdial" + "github.com/sagernet/tailscale/net/tshttpproxy" + "github.com/sagernet/tailscale/net/tstun" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/tstime/mono" + "github.com/sagernet/tailscale/types/dnstype" + "github.com/sagernet/tailscale/types/ipproto" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/types/views" + "github.com/sagernet/tailscale/util/clientmetric" + "github.com/sagernet/tailscale/util/deephash" + "github.com/sagernet/tailscale/util/mak" + "github.com/sagernet/tailscale/util/set" + "github.com/sagernet/tailscale/util/testenv" + "github.com/sagernet/tailscale/util/usermetric" + "github.com/sagernet/tailscale/version" + "github.com/sagernet/tailscale/wgengine/capture" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/magicsock" + "github.com/sagernet/tailscale/wgengine/netlog" + "github.com/sagernet/tailscale/wgengine/netstack/gro" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/tailscale/wgengine/wgint" + "github.com/sagernet/tailscale/wgengine/wglog" + "github.com/sagernet/wireguard-go/device" + "github.com/sagernet/wireguard-go/tun" ) // Lazy wireguard-go configuration parameters. @@ -90,6 +90,8 @@ const statusPollInterval = 1 * time.Minute const networkLoggerUploadTimeout = 5 * time.Second type userspaceEngine struct { + ctx context.Context + workers int logf logger.Logf wgLogger *wglog.Logger //a wireguard-go logging wrapper reqCh chan struct{} @@ -165,6 +167,9 @@ type BIRDClient interface { // Config is the engine configuration. type Config struct { + Context context.Context + Workers int + // Tun is the device used by the Engine to exchange packets with // the OS. // If nil, a fake Device that does nothing is used. @@ -324,6 +329,8 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error) } e := &userspaceEngine{ + ctx: conf.Context, + workers: conf.Workers, timeNow: mono.Now, logf: logf, reqCh: make(chan struct{}, 1), @@ -457,7 +464,7 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error) // wgdev takes ownership of tundev, will close it when closed. e.logf("Creating WireGuard device...") - e.wgdev = wgcfg.NewDevice(e.tundev, e.magicConn.Bind(), e.wgLogger.DeviceLogger) + e.wgdev = wgcfg.NewDevice(e.ctx, e.tundev, e.magicConn.Bind(), e.wgLogger.DeviceLogger, e.workers) closePool.addFunc(e.wgdev.Close) closePool.addFunc(func() { if err := e.magicConn.Close(); err != nil { diff --git a/wgengine/userspace_ext_test.go b/wgengine/userspace_ext_test.go deleted file mode 100644 index cc29be234d4ea..0000000000000 --- a/wgengine/userspace_ext_test.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package wgengine_test - -import ( - "testing" - - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/net/tstun" - "tailscale.com/tsd" - "tailscale.com/tstest" - "tailscale.com/types/logger" - "tailscale.com/wgengine" - "tailscale.com/wgengine/router" -) - -func TestIsNetstack(t *testing.T) { - sys := new(tsd.System) - e, err := wgengine.NewUserspaceEngine( - tstest.WhileTestRunningLogger(t), - wgengine.Config{ - SetSubsystem: sys.Set, - HealthTracker: sys.HealthTracker(), - Metrics: sys.UserMetricsRegistry(), - }, - ) - if err != nil { - t.Fatal(err) - } - defer e.Close() - if !sys.IsNetstack() { - t.Errorf("IsNetstack = false; want true") - } -} - -func TestIsNetstackRouter(t *testing.T) { - tests := []struct { - name string - conf wgengine.Config - setNetstackRouter bool - want bool - }{ - { - name: "no_netstack", - conf: wgengine.Config{ - Tun: newFakeOSTUN(), - Router: newFakeOSRouter(), - }, - want: false, - }, - { - name: "netstack", - conf: wgengine.Config{}, - want: true, - }, - { - name: "hybrid_netstack", - conf: wgengine.Config{ - Tun: newFakeOSTUN(), - Router: newFakeOSRouter(), - }, - setNetstackRouter: true, - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sys := &tsd.System{} - if tt.setNetstackRouter { - sys.NetstackRouter.Set(true) - } - conf := tt.conf - conf.SetSubsystem = sys.Set - conf.HealthTracker = sys.HealthTracker() - conf.Metrics = sys.UserMetricsRegistry() - e, err := wgengine.NewUserspaceEngine(logger.Discard, conf) - if err != nil { - t.Fatal(err) - } - defer e.Close() - if got := sys.IsNetstackRouter(); got != tt.want { - t.Errorf("IsNetstackRouter = %v; want %v", got, tt.want) - } - }) - } -} - -func newFakeOSRouter() router.Router { - return someRandoOSRouter{router.NewFake(logger.Discard)} -} - -type someRandoOSRouter struct { - router.Router -} - -func newFakeOSTUN() tun.Device { - return someRandoOSTUN{tstun.NewFake()} -} - -type someRandoOSTUN struct { - tun.Device -} - -// Name returns something that is not FakeTUN. -func (t someRandoOSTUN) Name() (string, error) { return "some_os_tun0", nil } diff --git a/wgengine/userspace_test.go b/wgengine/userspace_test.go deleted file mode 100644 index 0514218625a60..0000000000000 --- a/wgengine/userspace_test.go +++ /dev/null @@ -1,386 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package wgengine - -import ( - "fmt" - "net/netip" - "os" - "reflect" - "runtime" - "testing" - - "go4.org/mem" - "tailscale.com/cmd/testwrapper/flakytest" - "tailscale.com/control/controlknobs" - "tailscale.com/envknob" - "tailscale.com/health" - "tailscale.com/net/dns" - "tailscale.com/net/netaddr" - "tailscale.com/net/tstun" - "tailscale.com/tailcfg" - "tailscale.com/tstest" - "tailscale.com/tstime/mono" - "tailscale.com/types/key" - "tailscale.com/types/netmap" - "tailscale.com/types/opt" - "tailscale.com/util/usermetric" - "tailscale.com/wgengine/router" - "tailscale.com/wgengine/wgcfg" -) - -func TestNoteReceiveActivity(t *testing.T) { - now := mono.Time(123456) - var logBuf tstest.MemLogger - - confc := make(chan bool, 1) - gotConf := func() bool { - select { - case <-confc: - return true - default: - return false - } - } - e := &userspaceEngine{ - timeNow: func() mono.Time { return now }, - recvActivityAt: map[key.NodePublic]mono.Time{}, - logf: logBuf.Logf, - tundev: new(tstun.Wrapper), - testMaybeReconfigHook: func() { confc <- true }, - trimmedNodes: map[key.NodePublic]bool{}, - } - ra := e.recvActivityAt - - nk := key.NewNode().Public() - - // Activity on an untracked key should do nothing. - e.noteRecvActivity(nk) - if len(ra) != 0 { - t.Fatalf("unexpected growth in map: now has %d keys; want 0", len(ra)) - } - if logBuf.Len() != 0 { - t.Fatalf("unexpected log write (and thus activity): %s", logBuf.Bytes()) - } - - // Now track it, but don't mark it trimmed, so shouldn't update. - ra[nk] = 0 - e.noteRecvActivity(nk) - if len(ra) != 1 { - t.Fatalf("unexpected growth in map: now has %d keys; want 1", len(ra)) - } - if got := ra[nk]; got != now { - t.Fatalf("time in map = %v; want %v", got, now) - } - if gotConf() { - t.Fatalf("unexpected reconfig") - } - - // Now mark it trimmed and expect an update. - e.trimmedNodes[nk] = true - e.noteRecvActivity(nk) - if len(ra) != 1 { - t.Fatalf("unexpected growth in map: now has %d keys; want 1", len(ra)) - } - if got := ra[nk]; got != now { - t.Fatalf("time in map = %v; want %v", got, now) - } - if !gotConf() { - t.Fatalf("didn't get expected reconfig") - } -} - -func nodeViews(v []*tailcfg.Node) []tailcfg.NodeView { - nv := make([]tailcfg.NodeView, len(v)) - for i, n := range v { - nv[i] = n.View() - } - return nv -} - -func TestUserspaceEngineReconfig(t *testing.T) { - ht := new(health.Tracker) - reg := new(usermetric.Registry) - e, err := NewFakeUserspaceEngine(t.Logf, 0, ht, reg) - if err != nil { - t.Fatal(err) - } - t.Cleanup(e.Close) - ue := e.(*userspaceEngine) - - routerCfg := &router.Config{} - - for _, nodeHex := range []string{ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - } { - nm := &netmap.NetworkMap{ - Peers: nodeViews([]*tailcfg.Node{ - { - ID: 1, - Key: nkFromHex(nodeHex), - }, - }), - } - nk, err := key.ParseNodePublicUntyped(mem.S(nodeHex)) - if err != nil { - t.Fatal(err) - } - cfg := &wgcfg.Config{ - Peers: []wgcfg.Peer{ - { - PublicKey: nk, - AllowedIPs: []netip.Prefix{ - netip.PrefixFrom(netaddr.IPv4(100, 100, 99, 1), 32), - }, - }, - }, - } - - e.SetNetworkMap(nm) - err = e.Reconfig(cfg, routerCfg, &dns.Config{}) - if err != nil { - t.Fatal(err) - } - - wantRecvAt := map[key.NodePublic]mono.Time{ - nkFromHex(nodeHex): 0, - } - if got := ue.recvActivityAt; !reflect.DeepEqual(got, wantRecvAt) { - t.Errorf("wrong recvActivityAt\n got: %v\nwant: %v\n", got, wantRecvAt) - } - - wantTrimmedNodes := map[key.NodePublic]bool{ - nkFromHex(nodeHex): true, - } - if got := ue.trimmedNodes; !reflect.DeepEqual(got, wantTrimmedNodes) { - t.Errorf("wrong wantTrimmedNodes\n got: %v\nwant: %v\n", got, wantTrimmedNodes) - } - } -} - -func TestUserspaceEnginePortReconfig(t *testing.T) { - flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/2855") - const defaultPort = 49983 - - var knobs controlknobs.Knobs - - // Keep making a wgengine until we find an unused port - var ue *userspaceEngine - ht := new(health.Tracker) - reg := new(usermetric.Registry) - for i := range 100 { - attempt := uint16(defaultPort + i) - e, err := NewFakeUserspaceEngine(t.Logf, attempt, &knobs, ht, reg) - if err != nil { - t.Fatal(err) - } - ue = e.(*userspaceEngine) - if ue.magicConn.LocalPort() == attempt { - break - } - ue.Close() - ue = nil - } - if ue == nil { - t.Fatal("could not create a wgengine with a specific port") - } - t.Cleanup(ue.Close) - - startingPort := ue.magicConn.LocalPort() - nodeKey, err := key.ParseNodePublicUntyped(mem.S("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) - if err != nil { - t.Fatal(err) - } - cfg := &wgcfg.Config{ - Peers: []wgcfg.Peer{ - { - PublicKey: nodeKey, - AllowedIPs: []netip.Prefix{ - netip.PrefixFrom(netaddr.IPv4(100, 100, 99, 1), 32), - }, - }, - }, - } - routerCfg := &router.Config{} - if err := ue.Reconfig(cfg, routerCfg, &dns.Config{}); err != nil { - t.Fatal(err) - } - if got := ue.magicConn.LocalPort(); got != startingPort { - t.Errorf("no debug setting changed local port to %d from %d", got, startingPort) - } - - knobs.RandomizeClientPort.Store(true) - if err := ue.Reconfig(cfg, routerCfg, &dns.Config{}); err != nil { - t.Fatal(err) - } - if got := ue.magicConn.LocalPort(); got == startingPort { - t.Errorf("debug setting did not change local port from %d", startingPort) - } - - lastPort := ue.magicConn.LocalPort() - knobs.RandomizeClientPort.Store(false) - if err := ue.Reconfig(cfg, routerCfg, &dns.Config{}); err != nil { - t.Fatal(err) - } - if startingPort == defaultPort { - // Only try this if we managed to bind defaultPort the first time. - // Otherwise, assume someone else on the computer is using defaultPort - // and so Reconfig would have caused magicSockt to bind some other port. - if got := ue.magicConn.LocalPort(); got != defaultPort { - t.Errorf("debug setting did not change local port from %d to %d", startingPort, defaultPort) - } - } - if got := ue.magicConn.LocalPort(); got == lastPort { - t.Errorf("Reconfig did not change local port from %d", lastPort) - } -} - -// Test that enabling and disabling peer path MTU discovery works correctly. -func TestUserspaceEnginePeerMTUReconfig(t *testing.T) { - if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - t.Skipf("skipping on %q; peer MTU not supported", runtime.GOOS) - } - - defer os.Setenv("TS_DEBUG_ENABLE_PMTUD", os.Getenv("TS_DEBUG_ENABLE_PMTUD")) - envknob.Setenv("TS_DEBUG_ENABLE_PMTUD", "") - // Turn on debugging to help diagnose problems. - defer os.Setenv("TS_DEBUG_PMTUD", os.Getenv("TS_DEBUG_PMTUD")) - envknob.Setenv("TS_DEBUG_PMTUD", "true") - - var knobs controlknobs.Knobs - - ht := new(health.Tracker) - reg := new(usermetric.Registry) - e, err := NewFakeUserspaceEngine(t.Logf, 0, &knobs, ht, reg) - if err != nil { - t.Fatal(err) - } - t.Cleanup(e.Close) - ue := e.(*userspaceEngine) - - if ue.magicConn.PeerMTUEnabled() != false { - t.Error("peer MTU enabled by default, should not be") - } - osDefaultDF, err := ue.magicConn.DontFragSetting() - if err != nil { - t.Errorf("get don't fragment bit failed: %v", err) - } - t.Logf("Info: OS default don't fragment bit(s) setting: %v", osDefaultDF) - - // Build a set of configs to use as we change the peer MTU settings. - nodeKey, err := key.ParseNodePublicUntyped(mem.S("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) - if err != nil { - t.Fatal(err) - } - cfg := &wgcfg.Config{ - Peers: []wgcfg.Peer{ - { - PublicKey: nodeKey, - AllowedIPs: []netip.Prefix{ - netip.PrefixFrom(netaddr.IPv4(100, 100, 99, 1), 32), - }, - }, - }, - } - routerCfg := &router.Config{} - - tests := []struct { - desc string // test description - wantP bool // desired value of PMTUD setting - wantDF bool // desired value of don't fragment bits - shouldP opt.Bool // if set, force peer MTU to this value - }{ - {desc: "after_first_reconfig", wantP: false, wantDF: osDefaultDF, shouldP: ""}, - {desc: "enabling_PMTUD_first_time", wantP: true, wantDF: true, shouldP: "true"}, - {desc: "disabling_PMTUD", wantP: false, wantDF: false, shouldP: "false"}, - {desc: "enabling_PMTUD_second_time", wantP: true, wantDF: true, shouldP: "true"}, - {desc: "returning_to_default_PMTUD", wantP: false, wantDF: false, shouldP: ""}, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - if v, ok := tt.shouldP.Get(); ok { - knobs.PeerMTUEnable.Store(v) - } else { - knobs.PeerMTUEnable.Store(false) - } - if err := ue.Reconfig(cfg, routerCfg, &dns.Config{}); err != nil { - t.Fatal(err) - } - if v := ue.magicConn.PeerMTUEnabled(); v != tt.wantP { - t.Errorf("peer MTU set to %v, want %v", v, tt.wantP) - } - if v, err := ue.magicConn.DontFragSetting(); v != tt.wantDF || err != nil { - t.Errorf("don't fragment bit set to %v, want %v, err %v", v, tt.wantP, err) - } - }) - } -} - -func nkFromHex(hex string) key.NodePublic { - if len(hex) != 64 { - panic(fmt.Sprintf("%q is len %d; want 64", hex, len(hex))) - } - k, err := key.ParseNodePublicUntyped(mem.S(hex[:64])) - if err != nil { - panic(fmt.Sprintf("%q is not hex: %v", hex, err)) - } - return k -} - -// an experiment to see if genLocalAddrFunc was worth it. As of Go -// 1.16, it still very much is. (30-40x faster) -func BenchmarkGenLocalAddrFunc(b *testing.B) { - la1 := netip.MustParseAddr("1.2.3.4") - la2 := netip.MustParseAddr("::4") - lanot := netip.MustParseAddr("5.5.5.5") - var x bool - b.Run("map1", func(b *testing.B) { - b.ReportAllocs() - b.ResetTimer() - m := map[netip.Addr]bool{ - la1: true, - } - for range b.N { - x = m[la1] - x = m[lanot] - } - }) - b.Run("map2", func(b *testing.B) { - b.ReportAllocs() - b.ResetTimer() - m := map[netip.Addr]bool{ - la1: true, - la2: true, - } - for range b.N { - x = m[la1] - x = m[lanot] - } - }) - b.Run("or1", func(b *testing.B) { - b.ReportAllocs() - b.ResetTimer() - f := func(t netip.Addr) bool { - return t == la1 - } - for range b.N { - x = f(la1) - x = f(lanot) - } - }) - b.Run("or2", func(b *testing.B) { - b.ReportAllocs() - b.ResetTimer() - f := func(t netip.Addr) bool { - return t == la1 || t == la2 - } - for range b.N { - x = f(la1) - x = f(lanot) - } - }) - b.Logf("x = %v", x) -} diff --git a/wgengine/watchdog.go b/wgengine/watchdog.go index 232591f5eca60..ff0855f09e463 100644 --- a/wgengine/watchdog.go +++ b/wgengine/watchdog.go @@ -14,17 +14,17 @@ import ( "sync" "time" - "tailscale.com/envknob" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/dns" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/netmap" - "tailscale.com/wgengine/capture" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/router" - "tailscale.com/wgengine/wgcfg" - "tailscale.com/wgengine/wgint" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/wgengine/capture" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/tailscale/wgengine/wgint" ) // NewWatchdog wraps an Engine and makes sure that all methods complete diff --git a/wgengine/watchdog_js.go b/wgengine/watchdog_js.go index 872ce36d5fd5d..6ab49b405a506 100644 --- a/wgengine/watchdog_js.go +++ b/wgengine/watchdog_js.go @@ -5,7 +5,7 @@ package wgengine -import "tailscale.com/net/dns/resolver" +import "github.com/sagernet/tailscale/net/dns/resolver" type watchdogEngine struct { Engine diff --git a/wgengine/watchdog_test.go b/wgengine/watchdog_test.go deleted file mode 100644 index b05cd421fe309..0000000000000 --- a/wgengine/watchdog_test.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package wgengine - -import ( - "runtime" - "testing" - "time" - - "tailscale.com/health" - "tailscale.com/util/usermetric" -) - -func TestWatchdog(t *testing.T) { - t.Parallel() - - var maxWaitMultiple time.Duration = 1 - if runtime.GOOS == "darwin" { - // Work around slow close syscalls on Big Sur with content filter Network Extensions installed. - // See https://github.com/tailscale/tailscale/issues/1598. - maxWaitMultiple = 15 - } - - t.Run("default watchdog does not fire", func(t *testing.T) { - t.Parallel() - ht := new(health.Tracker) - reg := new(usermetric.Registry) - e, err := NewFakeUserspaceEngine(t.Logf, 0, ht, reg) - if err != nil { - t.Fatal(err) - } - - e = NewWatchdog(e) - e.(*watchdogEngine).maxWait = maxWaitMultiple * 150 * time.Millisecond - e.(*watchdogEngine).logf = t.Logf - e.(*watchdogEngine).fatalf = t.Fatalf - - e.RequestStatus() - e.RequestStatus() - e.RequestStatus() - e.Close() - }) -} diff --git a/wgengine/wgcfg/config.go b/wgengine/wgcfg/config.go index 154dc0a304773..e877e7951cc67 100644 --- a/wgengine/wgcfg/config.go +++ b/wgengine/wgcfg/config.go @@ -7,9 +7,9 @@ package wgcfg import ( "net/netip" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logid" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logid" ) //go:generate go run tailscale.com/cmd/cloner -type=Config,Peer diff --git a/wgengine/wgcfg/device.go b/wgengine/wgcfg/device.go index 80fa159e38972..6aaaa004272be 100644 --- a/wgengine/wgcfg/device.go +++ b/wgengine/wgcfg/device.go @@ -4,19 +4,23 @@ package wgcfg import ( + "context" "io" "sort" - "github.com/tailscale/wireguard-go/conn" - "github.com/tailscale/wireguard-go/device" - "github.com/tailscale/wireguard-go/tun" - "tailscale.com/types/logger" - "tailscale.com/util/multierr" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/util/multierr" + "github.com/sagernet/wireguard-go/conn" + "github.com/sagernet/wireguard-go/device" + "github.com/sagernet/wireguard-go/tun" ) // NewDevice returns a wireguard-go Device configured for Tailscale use. -func NewDevice(tunDev tun.Device, bind conn.Bind, logger *device.Logger) *device.Device { - ret := device.NewDevice(tunDev, bind, logger) +func NewDevice(ctx context.Context, tunDev tun.Device, bind conn.Bind, logger *device.Logger, workers int) *device.Device { + if ctx == nil { + ctx = context.Background() + } + ret := device.NewDevice(ctx, tunDev, bind, logger, workers) ret.DisableSomeRoamingForBrokenMobileSemantics() return ret } diff --git a/wgengine/wgcfg/device_test.go b/wgengine/wgcfg/device_test.go deleted file mode 100644 index d54282e4bdf04..0000000000000 --- a/wgengine/wgcfg/device_test.go +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package wgcfg - -import ( - "bufio" - "bytes" - "io" - "net/netip" - "os" - "sort" - "strings" - "sync" - "testing" - - "github.com/tailscale/wireguard-go/conn" - "github.com/tailscale/wireguard-go/device" - "github.com/tailscale/wireguard-go/tun" - "go4.org/mem" - "tailscale.com/types/key" -) - -func TestDeviceConfig(t *testing.T) { - newK := func() (key.NodePublic, key.NodePrivate) { - t.Helper() - k := key.NewNode() - return k.Public(), k - } - k1, pk1 := newK() - ip1 := netip.MustParsePrefix("10.0.0.1/32") - - k2, pk2 := newK() - ip2 := netip.MustParsePrefix("10.0.0.2/32") - - k3, _ := newK() - ip3 := netip.MustParsePrefix("10.0.0.3/32") - - cfg1 := &Config{ - PrivateKey: pk1, - Peers: []Peer{{ - PublicKey: k2, - AllowedIPs: []netip.Prefix{ip2}, - }}, - } - - cfg2 := &Config{ - PrivateKey: pk2, - Peers: []Peer{{ - PublicKey: k1, - AllowedIPs: []netip.Prefix{ip1}, - PersistentKeepalive: 5, - }}, - } - - device1 := NewDevice(newNilTun(), new(noopBind), device.NewLogger(device.LogLevelError, "device1")) - device2 := NewDevice(newNilTun(), new(noopBind), device.NewLogger(device.LogLevelError, "device2")) - defer device1.Close() - defer device2.Close() - - cmp := func(t *testing.T, d *device.Device, want *Config) { - t.Helper() - got, err := DeviceConfig(d) - if err != nil { - t.Fatal(err) - } - prev := new(Config) - gotbuf := new(strings.Builder) - err = got.ToUAPI(t.Logf, gotbuf, prev) - gotStr := gotbuf.String() - if err != nil { - t.Errorf("got.ToUAPI(): error: %v", err) - return - } - wantbuf := new(strings.Builder) - err = want.ToUAPI(t.Logf, wantbuf, prev) - wantStr := wantbuf.String() - if err != nil { - t.Errorf("want.ToUAPI(): error: %v", err) - return - } - if gotStr != wantStr { - buf := new(bytes.Buffer) - w := bufio.NewWriter(buf) - if err := d.IpcGetOperation(w); err != nil { - t.Errorf("on error, could not IpcGetOperation: %v", err) - } - w.Flush() - t.Errorf("config mismatch:\n---- got:\n%s\n---- want:\n%s\n---- uapi:\n%s", gotStr, wantStr, buf.String()) - } - } - - t.Run("device1 config", func(t *testing.T) { - if err := ReconfigDevice(device1, cfg1, t.Logf); err != nil { - t.Fatal(err) - } - cmp(t, device1, cfg1) - }) - - t.Run("device2 config", func(t *testing.T) { - if err := ReconfigDevice(device2, cfg2, t.Logf); err != nil { - t.Fatal(err) - } - cmp(t, device2, cfg2) - }) - - // This is only to test that Config and Reconfig are properly synchronized. - t.Run("device2 config/reconfig", func(t *testing.T) { - var wg sync.WaitGroup - wg.Add(2) - - go func() { - ReconfigDevice(device2, cfg2, t.Logf) - wg.Done() - }() - - go func() { - DeviceConfig(device2) - wg.Done() - }() - - wg.Wait() - }) - - t.Run("device1 modify peer", func(t *testing.T) { - cfg1.Peers[0].DiscoKey = key.DiscoPublicFromRaw32(mem.B([]byte{0: 1, 31: 0})) - if err := ReconfigDevice(device1, cfg1, t.Logf); err != nil { - t.Fatal(err) - } - cmp(t, device1, cfg1) - }) - - t.Run("device1 replace endpoint", func(t *testing.T) { - cfg1.Peers[0].DiscoKey = key.DiscoPublicFromRaw32(mem.B([]byte{0: 2, 31: 0})) - if err := ReconfigDevice(device1, cfg1, t.Logf); err != nil { - t.Fatal(err) - } - cmp(t, device1, cfg1) - }) - - t.Run("device1 add new peer", func(t *testing.T) { - cfg1.Peers = append(cfg1.Peers, Peer{ - PublicKey: k3, - AllowedIPs: []netip.Prefix{ip3}, - }) - sort.Slice(cfg1.Peers, func(i, j int) bool { - return cfg1.Peers[i].PublicKey.Less(cfg1.Peers[j].PublicKey) - }) - - origCfg, err := DeviceConfig(device1) - if err != nil { - t.Fatal(err) - } - - if err := ReconfigDevice(device1, cfg1, t.Logf); err != nil { - t.Fatal(err) - } - cmp(t, device1, cfg1) - - newCfg, err := DeviceConfig(device1) - if err != nil { - t.Fatal(err) - } - - peer0 := func(cfg *Config) Peer { - p, ok := cfg.PeerWithKey(k2) - if !ok { - t.Helper() - t.Fatal("failed to look up peer 2") - } - return p - } - peersEqual := func(p, q Peer) bool { - return p.PublicKey == q.PublicKey && p.DiscoKey == q.DiscoKey && p.PersistentKeepalive == q.PersistentKeepalive && cidrsEqual(p.AllowedIPs, q.AllowedIPs) - } - if !peersEqual(peer0(origCfg), peer0(newCfg)) { - t.Error("reconfig modified old peer") - } - }) - - t.Run("device1 remove peer", func(t *testing.T) { - removeKey := cfg1.Peers[len(cfg1.Peers)-1].PublicKey - cfg1.Peers = cfg1.Peers[:len(cfg1.Peers)-1] - - if err := ReconfigDevice(device1, cfg1, t.Logf); err != nil { - t.Fatal(err) - } - cmp(t, device1, cfg1) - - newCfg, err := DeviceConfig(device1) - if err != nil { - t.Fatal(err) - } - - _, ok := newCfg.PeerWithKey(removeKey) - if ok { - t.Error("reconfig failed to remove peer") - } - }) -} - -// TODO: replace with a loopback tunnel -type nilTun struct { - events chan tun.Event - closed chan struct{} -} - -func newNilTun() tun.Device { - return &nilTun{ - events: make(chan tun.Event), - closed: make(chan struct{}), - } -} - -func (t *nilTun) File() *os.File { return nil } -func (t *nilTun) Flush() error { return nil } -func (t *nilTun) MTU() (int, error) { return 1420, nil } -func (t *nilTun) Name() (string, error) { return "niltun", nil } -func (t *nilTun) Events() <-chan tun.Event { return t.events } - -func (t *nilTun) Read(data [][]byte, sizes []int, offset int) (int, error) { - <-t.closed - return 0, io.EOF -} - -func (t *nilTun) Write(data [][]byte, offset int) (int, error) { - <-t.closed - return 0, io.EOF -} - -func (t *nilTun) Close() error { - close(t.events) - close(t.closed) - return nil -} - -func (t *nilTun) BatchSize() int { return 1 } - -// A noopBind is a conn.Bind that does no actual binding work. -type noopBind struct{} - -func (noopBind) Open(port uint16) (fns []conn.ReceiveFunc, actualPort uint16, err error) { - return nil, 1, nil -} -func (noopBind) Close() error { return nil } -func (noopBind) SetMark(mark uint32) error { return nil } -func (noopBind) Send(b [][]byte, ep conn.Endpoint) error { return nil } -func (noopBind) ParseEndpoint(s string) (conn.Endpoint, error) { - return dummyEndpoint(s), nil -} -func (noopBind) BatchSize() int { return 1 } - -// A dummyEndpoint is a string holding the endpoint destination. -type dummyEndpoint string - -func (e dummyEndpoint) ClearSrc() {} -func (e dummyEndpoint) SrcToString() string { return "" } -func (e dummyEndpoint) DstToString() string { return string(e) } -func (e dummyEndpoint) DstToBytes() []byte { return nil } -func (e dummyEndpoint) DstIP() netip.Addr { return netip.Addr{} } -func (dummyEndpoint) SrcIP() netip.Addr { return netip.Addr{} } diff --git a/wgengine/wgcfg/nmcfg/nmcfg.go b/wgengine/wgcfg/nmcfg/nmcfg.go index e7d5edf150537..496673d3956bc 100644 --- a/wgengine/wgcfg/nmcfg/nmcfg.go +++ b/wgengine/wgcfg/nmcfg/nmcfg.go @@ -10,11 +10,11 @@ import ( "net/netip" "strings" - "tailscale.com/tailcfg" - "tailscale.com/types/logger" - "tailscale.com/types/logid" - "tailscale.com/types/netmap" - "tailscale.com/wgengine/wgcfg" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/wgengine/wgcfg" ) func nodeDebugName(n tailcfg.NodeView) string { diff --git a/wgengine/wgcfg/parser.go b/wgengine/wgcfg/parser.go index ec3d008f7de97..98609cd8985b8 100644 --- a/wgengine/wgcfg/parser.go +++ b/wgengine/wgcfg/parser.go @@ -12,8 +12,8 @@ import ( "strconv" "strings" + "github.com/sagernet/tailscale/types/key" "go4.org/mem" - "tailscale.com/types/key" ) type ParseError struct { diff --git a/wgengine/wgcfg/parser_test.go b/wgengine/wgcfg/parser_test.go deleted file mode 100644 index a5d7ad44f2e39..0000000000000 --- a/wgengine/wgcfg/parser_test.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package wgcfg - -import ( - "bufio" - "bytes" - "io" - "net/netip" - "reflect" - "runtime" - "testing" - - "tailscale.com/types/key" -) - -func noError(t *testing.T, err error) bool { - if err == nil { - return true - } - _, fn, line, _ := runtime.Caller(1) - t.Errorf("Error at %s:%d: %#v", fn, line, err) - return false -} - -func equal(t *testing.T, expected, actual any) bool { - if reflect.DeepEqual(expected, actual) { - return true - } - _, fn, line, _ := runtime.Caller(1) - t.Errorf("Failed equals at %s:%d\nactual %#v\nexpected %#v", fn, line, actual, expected) - return false -} - -func TestParseEndpoint(t *testing.T) { - _, _, err := parseEndpoint("[192.168.42.0:]:51880") - if err == nil { - t.Error("Error was expected") - } - host, port, err := parseEndpoint("192.168.42.0:51880") - if noError(t, err) { - equal(t, "192.168.42.0", host) - equal(t, uint16(51880), port) - } - host, port, err = parseEndpoint("test.wireguard.com:18981") - if noError(t, err) { - equal(t, "test.wireguard.com", host) - equal(t, uint16(18981), port) - } - host, port, err = parseEndpoint("[2607:5300:60:6b0::c05f:543]:2468") - if noError(t, err) { - equal(t, "2607:5300:60:6b0::c05f:543", host) - equal(t, uint16(2468), port) - } - _, _, err = parseEndpoint("[::::::invalid:18981") - if err == nil { - t.Error("Error was expected") - } -} - -func BenchmarkFromUAPI(b *testing.B) { - newK := func() (key.NodePublic, key.NodePrivate) { - b.Helper() - k := key.NewNode() - return k.Public(), k - } - k1, pk1 := newK() - ip1 := netip.MustParsePrefix("10.0.0.1/32") - - peer := Peer{ - PublicKey: k1, - AllowedIPs: []netip.Prefix{ip1}, - } - cfg1 := &Config{ - PrivateKey: pk1, - Peers: []Peer{peer, peer, peer, peer}, - } - - buf := new(bytes.Buffer) - w := bufio.NewWriter(buf) - if err := cfg1.ToUAPI(b.Logf, w, &Config{}); err != nil { - b.Fatal(err) - } - w.Flush() - r := bytes.NewReader(buf.Bytes()) - b.ReportAllocs() - for range b.N { - r.Seek(0, io.SeekStart) - _, err := FromUAPI(r) - if err != nil { - b.Errorf("failed from UAPI: %v", err) - } - } -} diff --git a/wgengine/wgcfg/wgcfg_clone.go b/wgengine/wgcfg/wgcfg_clone.go index 749d8d8160579..4ff549d094505 100644 --- a/wgengine/wgcfg/wgcfg_clone.go +++ b/wgengine/wgcfg/wgcfg_clone.go @@ -8,10 +8,10 @@ package wgcfg import ( "net/netip" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/logid" - "tailscale.com/types/ptr" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logid" + "github.com/sagernet/tailscale/types/ptr" ) // Clone makes a deep copy of Config. diff --git a/wgengine/wgcfg/writer.go b/wgengine/wgcfg/writer.go index 9cdd31df2e38c..5d1950c0a65f9 100644 --- a/wgengine/wgcfg/writer.go +++ b/wgengine/wgcfg/writer.go @@ -9,8 +9,8 @@ import ( "net/netip" "strconv" - "tailscale.com/types/key" - "tailscale.com/types/logger" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" ) // ToUAPI writes cfg in UAPI format to w. diff --git a/wgengine/wgengine.go b/wgengine/wgengine.go index c165ccdf3c3aa..188680ae19fe8 100644 --- a/wgengine/wgengine.go +++ b/wgengine/wgengine.go @@ -9,16 +9,16 @@ import ( "net/netip" "time" - "tailscale.com/ipn/ipnstate" - "tailscale.com/net/dns" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/netmap" - "tailscale.com/wgengine/capture" - "tailscale.com/wgengine/filter" - "tailscale.com/wgengine/router" - "tailscale.com/wgengine/wgcfg" - "tailscale.com/wgengine/wgint" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/net/dns" + "github.com/sagernet/tailscale/tailcfg" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/netmap" + "github.com/sagernet/tailscale/wgengine/capture" + "github.com/sagernet/tailscale/wgengine/filter" + "github.com/sagernet/tailscale/wgengine/router" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/tailscale/wgengine/wgint" ) // Status is the Engine status. diff --git a/wgengine/wgint/wgint.go b/wgengine/wgint/wgint.go index 309113df71d41..fab8c7d6214f1 100644 --- a/wgengine/wgint/wgint.go +++ b/wgengine/wgint/wgint.go @@ -11,7 +11,7 @@ import ( "time" "unsafe" - "github.com/tailscale/wireguard-go/device" + "github.com/sagernet/wireguard-go/device" ) var ( diff --git a/wgengine/wgint/wgint_test.go b/wgengine/wgint/wgint_test.go deleted file mode 100644 index 714d2044b1806..0000000000000 --- a/wgengine/wgint/wgint_test.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package wgint - -import ( - "testing" - - "github.com/tailscale/wireguard-go/device" -) - -func TestInternalOffsets(t *testing.T) { - peer := new(device.Peer) - if got := peerLastHandshakeNano(peer); got != 0 { - t.Errorf("PeerLastHandshakeNano = %v, want 0", got) - } - if got := peerRxBytes(peer); got != 0 { - t.Errorf("PeerRxBytes = %v, want 0", got) - } - if got := peerTxBytes(peer); got != 0 { - t.Errorf("PeerTxBytes = %v, want 0", got) - } - if got := peerHandshakeAttempts(peer); got != 0 { - t.Errorf("PeerHandshakeAttempts = %v, want 0", got) - } -} diff --git a/wgengine/wglog/wglog.go b/wgengine/wglog/wglog.go index dabd4562ad704..89038a9a25167 100644 --- a/wgengine/wglog/wglog.go +++ b/wgengine/wglog/wglog.go @@ -9,12 +9,12 @@ import ( "strings" "sync" - "github.com/tailscale/wireguard-go/device" - "tailscale.com/envknob" - "tailscale.com/syncs" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/wgengine/wgcfg" + "github.com/sagernet/tailscale/envknob" + "github.com/sagernet/tailscale/syncs" + "github.com/sagernet/tailscale/types/key" + "github.com/sagernet/tailscale/types/logger" + "github.com/sagernet/tailscale/wgengine/wgcfg" + "github.com/sagernet/wireguard-go/device" ) // A Logger is a wireguard-go log wrapper that cleans up and rewrites log lines. diff --git a/wgengine/wglog/wglog_test.go b/wgengine/wglog/wglog_test.go deleted file mode 100644 index 9e9850f39ef59..0000000000000 --- a/wgengine/wglog/wglog_test.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package wglog_test - -import ( - "fmt" - "testing" - - "go4.org/mem" - "tailscale.com/types/key" - "tailscale.com/types/logger" - "tailscale.com/wgengine/wgcfg" - "tailscale.com/wgengine/wglog" -) - -func TestLogger(t *testing.T) { - tests := []struct { - format string - args []any - want string - omit bool - }{ - {"hi", nil, "wg: hi", false}, - {"Routine: starting", nil, "", true}, - {"%v says it misses you", []any{stringer("peer(IMTB…r7lM)")}, "wg: [IMTBr] says it misses you", false}, - } - - type log struct { - format string - args []any - } - - c := make(chan log, 1) - logf := func(format string, args ...any) { - select { - case c <- log{format, args}: - default: - t.Errorf("wrote %q, but shouldn't have", fmt.Sprintf(format, args...)) - } - } - - x := wglog.NewLogger(logf) - key, err := key.ParseNodePublicUntyped(mem.S("20c4c1ae54e1fd37cab6e9a532ca20646aff496796cc41d4519560e5e82bee53")) - if err != nil { - t.Fatal(err) - } - x.SetPeers([]wgcfg.Peer{{PublicKey: key}}) - - for _, tt := range tests { - if tt.omit { - // Write a message ourselves into the channel. - // Then if logf also attempts to write into the channel, it'll fail. - c <- log{} - } - x.DeviceLogger.Errorf(tt.format, tt.args...) - gotLog := <-c - if tt.omit { - continue - } - if got := fmt.Sprintf(gotLog.format, gotLog.args...); got != tt.want { - t.Errorf("Printf(%q, %v) = %q want %q", tt.format, tt.args, got, tt.want) - } - } -} - -func TestSuppressLogs(t *testing.T) { - var logs []string - logf := func(format string, args ...any) { - logs = append(logs, fmt.Sprintf(format, args...)) - } - x := wglog.NewLogger(logf) - x.DeviceLogger.Verbosef("pass") - x.DeviceLogger.Verbosef("UAPI: Adding allowedip") - - if len(logs) != 1 { - t.Fatalf("got %d logs, want 1", len(logs)) - } - if logs[0] != "wg: [v2] pass" { - t.Errorf("got %q, want \"wg: [v2] pass\"", logs[0]) - } -} - -func stringer(s string) stringerString { - return stringerString(s) -} - -type stringerString string - -func (s stringerString) String() string { return string(s) } - -func BenchmarkSetPeers(b *testing.B) { - b.ReportAllocs() - x := wglog.NewLogger(logger.Discard) - peers := [][]wgcfg.Peer{genPeers(0), genPeers(15), genPeers(16), genPeers(15)} - for range b.N { - for _, p := range peers { - x.SetPeers(p) - } - } -} - -func genPeers(n int) []wgcfg.Peer { - if n > 32 { - panic("too many peers") - } - if n == 0 { - return nil - } - peers := make([]wgcfg.Peer, n) - for i := range peers { - var k [32]byte - k[n] = byte(n) - peers[i].PublicKey = key.NodePublicFromRaw32(mem.B(k[:])) - } - return peers -} diff --git a/words/words_test.go b/words/words_test.go deleted file mode 100644 index a9691792a5c00..0000000000000 --- a/words/words_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package words - -import ( - "strings" - "testing" -) - -func TestWords(t *testing.T) { - test := func(t *testing.T, words []string) { - t.Helper() - if len(words) == 0 { - t.Error("no words") - } - seen := map[string]bool{} - for _, w := range words { - if seen[w] { - t.Errorf("dup word %q", w) - } - seen[w] = true - if w == "" || strings.IndexFunc(w, nonASCIILower) != -1 { - t.Errorf("malformed word %q", w) - } - } - } - t.Run("tails", func(t *testing.T) { test(t, Tails()) }) - t.Run("scales", func(t *testing.T) { test(t, Scales()) }) - t.Logf("%v tails * %v scales = %v beautiful combinations", len(Tails()), len(Scales()), len(Tails())*len(Scales())) -} - -func nonASCIILower(r rune) bool { - if 'a' <= r && r <= 'z' { - return false - } - return true -}